-
-
- Loading section...
-
+ {/* Add skeleton loading state */}
+ {flatItems.length === 0 && datamodelView.loading && (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
)}
+
+ {/* Virtualized list */}
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const item = flatItems[virtualItem.index];
+ const sectionRef = item.type === 'entity' ? getSectionRefCallback(item.entity.SchemaName) : undefined;
return (
{
+ if (sectionRef) sectionRef(el);
+ if (el) rowVirtualizer.measureElement(el);
+ }
+ : rowVirtualizer.measureElement
+ }
style={{
position: 'absolute',
top: 0,
@@ -205,7 +270,12 @@ export const List = ({ }: IListProps) => {
) : (
-
+ remeasureSection(item.entity.SchemaName)}
+ search={search}
+ />
)}
diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx
index 8b48d73..38cfe0c 100644
--- a/Website/components/datamodelview/Relationships.tsx
+++ b/Website/components/datamodelview/Relationships.tsx
@@ -9,15 +9,18 @@ import { ArrowUpDown, ArrowUp, ArrowDown, Search, X } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Input } from "../ui/input"
import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"
+import React from "react"
+import { highlightMatch } from "../datamodelview/List";
type SortDirection = 'asc' | 'desc' | null
type SortColumn = 'name' | 'tableSchema' | 'lookupField' | 'type' | 'behavior' | 'schemaName' | null
interface IRelationshipsProps {
- entity: EntityType
+ entity: EntityType;
+ onVisibleCountChange?: (count: number) => void;
}
-export const Relationships = ({ entity }: IRelationshipsProps) => {
+export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRelationshipsProps & { search?: string }) => {
const [sortColumn, setSortColumn] = useState
("name")
const [sortDirection, setSortDirection] = useState("asc")
const [typeFilter, setTypeFilter] = useState("all")
@@ -114,6 +117,14 @@ export const Relationships = ({ entity }: IRelationshipsProps) => {
{ value: "one-to-many", label: "One-to-Many" }
]
+ const sortedRelationships = getSortedRelationships();
+
+ React.useEffect(() => {
+ if (onVisibleCountChange) {
+ onVisibleCountChange(sortedRelationships.length);
+ }
+ }, [onVisibleCountChange, sortedRelationships.length]);
+
return <>
@@ -177,7 +188,7 @@ export const Relationships = ({ entity }: IRelationshipsProps) => {
) : (
-
No relationships available for this entity
+
No relationships available for this table
)}
) : (
@@ -233,14 +244,16 @@ export const Relationships = ({ entity }: IRelationshipsProps) => {
- {getSortedRelationships().map((relationship, index) =>
+ {sortedRelationships.map((relationship, index) =>
- {relationship.Name}
+
+ {highlightMatch(relationship.Name, search)}
+
{relationship.LookupDisplayName}
diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx
index 60b5e66..9536ec7 100644
--- a/Website/components/datamodelview/Section.tsx
+++ b/Website/components/datamodelview/Section.tsx
@@ -13,13 +13,35 @@ import React from "react"
interface ISectionProps {
entity: EntityType;
group: GroupType;
+ onContentChange?: () => void;
+ search?: string;
}
-export const Section = React.memo(({ entity, group }: ISectionProps) =>
- {
+export const Section = React.memo(
+ ({ entity, group, onContentChange, search }: ISectionProps) => {
+ // Use useRef to track previous props for comparison
+ const prevSearch = React.useRef(search);
+
+ const [tab, setTab] = React.useState("attributes");
+
+ // Only compute these counts when needed
+ const visibleAttributeCount = React.useMemo(() => entity.Attributes.length, [entity.Attributes]);
+ const visibleRelationshipCount = React.useMemo(() => entity.Relationships.length, [entity.Relationships]);
+ const visibleKeyCount = React.useMemo(() => entity.Keys.length, [entity.Keys]);
+
+ // Only call onContentChange when something actually changes
+ React.useEffect(() => {
+ if (onContentChange &&
+ (prevSearch.current !== search ||
+ tab !== "attributes")) {
+ prevSearch.current = search;
+ onContentChange();
+ }
+ }, [tab, search, onContentChange]);
+
return (
-
+
{/* Removed conditional styling and indicator */}
{entity.SecurityRoles.length > 0 && (
@@ -29,39 +51,46 @@ export const Section = React.memo(({ entity, group }: ISectionProps) =>
)}
-
+
- Attributes [{entity.Attributes.length}]
+ Attributes [{visibleAttributeCount}]
{entity.Relationships.length ?
- Relationships [{entity.Relationships.length}]
+ Relationships [{visibleRelationshipCount}]
: <>>
}
- Keys [{entity.Keys.length}]
+ Keys [{visibleKeyCount}]
-
+
-
+
-
+
)
+ },
+ // Custom comparison function to prevent unnecessary re-renders
+ (prevProps, nextProps) => {
+ // Only re-render if entity, search or group changes
+ return prevProps.entity.SchemaName === nextProps.entity.SchemaName &&
+ prevProps.search === nextProps.search &&
+ prevProps.group.Name === nextProps.group.Name;
}
);
diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx
index 67936c2..b55545b 100644
--- a/Website/components/datamodelview/SidebarDatamodelView.tsx
+++ b/Website/components/datamodelview/SidebarDatamodelView.tsx
@@ -1,14 +1,14 @@
import { EntityType, GroupType } from "@/lib/Types";
-import { Groups } from "../../generated/Data"
import { useTouch } from '../ui/hybridtooltop';
import { useSidebarDispatch } from '@/contexts/SidebarContext';
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@radix-ui/react-collapsible";
import { Slot } from "@radix-ui/react-slot";
-import { ChevronDown, Puzzle, Search, X } from "lucide-react";
+import { ExternalLink, Puzzle, Search, X } from "lucide-react";
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext";
+import { useDatamodelData } from "@/contexts/DatamodelDataContext";
interface ISidebarDatamodelViewProps {
@@ -24,6 +24,8 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
const dispatch = useSidebarDispatch();
const { currentSection, currentGroup, scrollToSection } = useDatamodelView();
const dataModelDispatch = useDatamodelViewDispatch();
+
+ const { groups } = useDatamodelData();
const [searchTerm, setSearchTerm] = useState("");
const [expandedGroups, setExpandedGroups] = useState
>(new Set());
@@ -37,7 +39,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
setSearchTerm(term);
if (term.trim()) {
const newExpandedGroups = new Set();
- Groups.forEach(group => {
+ groups.forEach(group => {
const hasMatchingEntity = group.Entities.some(entity =>
entity.SchemaName.toLowerCase().includes(term.toLowerCase()) ||
entity.DisplayName.toLowerCase().includes(term.toLowerCase())
@@ -79,6 +81,8 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
};
const handleSectionClick = (sectionId: string) => {
+ console.log("Loading true for section click");
+ dataModelDispatch({ type: 'SET_LOADING', payload: true });
if (scrollToSection) {
scrollToSection(sectionId);
}
@@ -123,7 +127,25 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
>
{group.Name}
{group.Entities.length}
- handleGroupClick(group.Name)}/>
+ {
+ e.stopPropagation();
+ if (group.Entities.length > 0) handleSectionClick(group.Entities[0].SchemaName);
+ }}
+ aria-label={`Link to first entity in ${group.Name}`}
+ tabIndex={0}
+ >
+ {
+ e.stopPropagation();
+ if (group.Entities.length > 0) handleGroupClick(group.Name);
+ }} />
+
@@ -170,8 +192,8 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
handleSearch(e.target.value)}
className="pl-8 pr-8 h-8 text-xs"
@@ -189,7 +211,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
{
- Groups.map((group) =>
+ groups.map((group) =>
)
}
diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx
new file mode 100644
index 0000000..3b195dd
--- /dev/null
+++ b/Website/components/datamodelview/TimeSlicedSearch.tsx
@@ -0,0 +1,223 @@
+'use client'
+
+import React, { useState, useRef, useCallback, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import { Input } from '../ui/input';
+import { Search, ChevronUp, ChevronDown } from 'lucide-react';
+
+interface TimeSlicedSearchProps {
+ onSearch: (value: string) => void;
+ onLoadingChange: (loading: boolean) => void;
+ onNavigateNext?: () => void;
+ onNavigatePrevious?: () => void;
+ currentIndex?: number;
+ totalResults?: number;
+ placeholder?: string;
+}
+
+// Time-sliced input that maintains 60fps regardless of background work
+export const TimeSlicedSearch = ({
+ onSearch,
+ onLoadingChange,
+ onNavigateNext,
+ onNavigatePrevious,
+ currentIndex = 0,
+ totalResults = 0,
+ placeholder = "Search attributes...",
+}: TimeSlicedSearchProps) => {
+ const [localValue, setLocalValue] = useState('');
+ const [isTyping, setIsTyping] = useState(false);
+ const [portalRoot, setPortalRoot] = useState
(null);
+
+ const searchTimeoutRef = useRef();
+ const typingTimeoutRef = useRef();
+ const frameRef = useRef();
+
+ // Time-sliced debouncing using requestAnimationFrame
+ const scheduleSearch = useCallback((value: string) => {
+ if (searchTimeoutRef.current) {
+ clearTimeout(searchTimeoutRef.current);
+ }
+
+ searchTimeoutRef.current = window.setTimeout(() => {
+ // Use MessageChannel for immediate callback without blocking main thread
+ const channel = new MessageChannel();
+ channel.port2.onmessage = () => {
+ onSearch(value);
+
+ // Reset typing state in next frame
+ frameRef.current = requestAnimationFrame(() => {
+ setIsTyping(false);
+ });
+ };
+ channel.port1.postMessage(null);
+ }, 350);
+ }, [onSearch]);
+
+ const handleChange = useCallback((e: React.ChangeEvent) => {
+ const value = e.target.value;
+
+ // Immediate visual update (highest priority)
+ setLocalValue(value);
+
+ // Manage typing state
+ if (!isTyping) {
+ setIsTyping(true);
+ onLoadingChange(true);
+ }
+
+ // Reset typing timeout
+ if (typingTimeoutRef.current) {
+ clearTimeout(typingTimeoutRef.current);
+ }
+
+ // Schedule search
+ scheduleSearch(value);
+
+ // Auto-reset typing state if user stops typing
+ typingTimeoutRef.current = window.setTimeout(() => {
+ setIsTyping(false);
+ }, 2000);
+
+ }, [isTyping, onLoadingChange, scheduleSearch]);
+
+ // Handle keyboard navigation
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ onNavigateNext?.();
+ if ('vibrate' in navigator) {
+ navigator.vibrate(50);
+ }
+ } else if (e.key === 'Enter' && e.shiftKey) {
+ e.preventDefault();
+ onNavigatePrevious?.();
+ if ('vibrate' in navigator) {
+ navigator.vibrate(50);
+ }
+ } else if (e.key === 'ArrowDown' && e.ctrlKey) {
+ e.preventDefault();
+ onNavigateNext?.();
+ } else if (e.key === 'ArrowUp' && e.ctrlKey) {
+ e.preventDefault();
+ onNavigatePrevious?.();
+ }
+ }, [onNavigateNext, onNavigatePrevious]);
+
+ const hasResults = totalResults > 0;
+ const showNavigation = hasResults && localValue.length >= 3;
+
+ // Cleanup
+ useEffect(() => {
+ return () => {
+ if (searchTimeoutRef.current) {
+ clearTimeout(searchTimeoutRef.current);
+ }
+ if (typingTimeoutRef.current) {
+ clearTimeout(typingTimeoutRef.current);
+ }
+ if (frameRef.current) {
+ cancelAnimationFrame(frameRef.current);
+ }
+ };
+ }, []);
+
+ // Portal setup
+ useEffect(() => {
+ let container = document.getElementById('time-sliced-search-portal');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'time-sliced-search-portal';
+ container.style.position = 'fixed';
+ container.style.top = '0';
+ container.style.left = '0';
+ container.style.width = '100%';
+ container.style.height = '100%';
+ container.style.pointerEvents = 'none';
+ container.style.zIndex = '9999';
+ document.body.appendChild(container);
+ }
+
+ const searchContainer = document.createElement('div');
+ searchContainer.style.pointerEvents = 'auto';
+ container.appendChild(searchContainer);
+ setPortalRoot(searchContainer);
+
+ return () => {
+ if (searchContainer && container?.contains(searchContainer)) {
+ container.removeChild(searchContainer);
+ }
+ };
+ }, []);
+
+ const searchInput = (
+
+ {/* Search Input Container */}
+
+
+
+
+ {isTyping && (
+
+ )}
+
+
+ {/* Navigation Buttons */}
+ {showNavigation && (
+
+
+
+
+
+ )}
+
+
+ {/* Results Counter */}
+ {showNavigation && (
+
+
+ {totalResults > 0 ? (
+ `${currentIndex} of ${totalResults} sections`
+ ) : (
+ 'No results'
+ )}
+
+
+ Enter next section •
+ Shift+Enter prev section •
+ Ctrl+↑↓ navigate
+
+
+ )}
+
+ );
+
+ return portalRoot ? createPortal(searchInput, portalRoot) : null;
+};
diff --git a/Website/components/datamodelview/dataLoaderWorker.js b/Website/components/datamodelview/dataLoaderWorker.js
new file mode 100644
index 0000000..df2f774
--- /dev/null
+++ b/Website/components/datamodelview/dataLoaderWorker.js
@@ -0,0 +1,5 @@
+import { Groups } from '../../generated/Data';
+
+self.onmessage = function() {
+ self.postMessage(Groups);
+};
\ No newline at end of file
diff --git a/Website/components/datamodelview/searchWorker.js b/Website/components/datamodelview/searchWorker.js
new file mode 100644
index 0000000..2ac5573
--- /dev/null
+++ b/Website/components/datamodelview/searchWorker.js
@@ -0,0 +1,65 @@
+let groups = null;
+const CHUNK_SIZE = 20; // Process results in chunks
+
+self.onmessage = async function(e) {
+ if (e.data && e.data.type === 'init') {
+ groups = e.data.groups;
+ return;
+ }
+
+ const search = (e.data || '').trim().toLowerCase();
+ if (!groups) {
+ self.postMessage({ type: 'results', data: [], complete: true });
+ return;
+ }
+
+ // First quickly send back a "started" message
+ self.postMessage({ type: 'started' });
+
+ const allItems = [];
+
+ // Find all matches
+ for (const group of groups) {
+ const filteredEntities = group.Entities.filter(entity => {
+ if (!search) return true;
+ const entityMatch = entity.SchemaName.toLowerCase().includes(search) ||
+ (entity.DisplayName && entity.DisplayName.toLowerCase().includes(search));
+ const attrMatch = entity.Attributes.some(attr =>
+ attr.SchemaName.toLowerCase().includes(search) ||
+ (attr.DisplayName && attr.DisplayName.toLowerCase().includes(search))
+ );
+ return entityMatch || attrMatch;
+ });
+
+ if (filteredEntities.length > 0) {
+ allItems.push({ type: 'group', group });
+ for (const entity of filteredEntities) {
+ allItems.push({ type: 'entity', group, entity });
+ }
+ }
+ }
+
+ // Send results in chunks to prevent UI blocking
+ for (let i = 0; i < allItems.length; i += CHUNK_SIZE) {
+ const chunk = allItems.slice(i, i + CHUNK_SIZE);
+ const isLastChunk = i + CHUNK_SIZE >= allItems.length;
+
+ self.postMessage({
+ type: 'results',
+ data: chunk,
+ complete: isLastChunk,
+ progress: Math.min(100, Math.round((i + CHUNK_SIZE) / allItems.length * 100))
+ });
+
+ // Small delay between chunks to let the UI breathe
+ if (!isLastChunk) {
+ // Use a proper yielding mechanism to let the UI breathe
+ await sleep(5);
+ }
+ }
+
+ // Helper function to pause execution for a specified duration
+ function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+};
\ No newline at end of file
diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx
new file mode 100644
index 0000000..708f8e8
--- /dev/null
+++ b/Website/contexts/DatamodelDataContext.tsx
@@ -0,0 +1,59 @@
+'use client'
+
+import React, { createContext, useContext, useReducer, ReactNode } from "react";
+import { GroupType } from "@/lib/Types";
+
+interface DatamodelDataState {
+ groups: GroupType[];
+ search: string;
+ filtered: any[];
+}
+
+const initialState: DatamodelDataState = {
+ groups: [],
+ search: "",
+ filtered: []
+};
+
+const DatamodelDataContext = createContext(initialState);
+const DatamodelDataDispatchContext = createContext>(() => {});
+
+const datamodelDataReducer = (state: DatamodelDataState, action: any): DatamodelDataState => {
+ switch (action.type) {
+ case "SET_GROUPS":
+ return { ...state, groups: action.payload };
+ case "SET_SEARCH":
+ return { ...state, search: action.payload };
+ case "SET_FILTERED":
+ return { ...state, filtered: action.payload };
+ case "APPEND_FILTERED":
+ return { ...state, filtered: [...state.filtered, ...action.payload] };
+ default:
+ return state;
+ }
+};
+
+export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => {
+ const [state, dispatch] = useReducer(datamodelDataReducer, initialState);
+
+ React.useEffect(() => {
+ const worker = new Worker(new URL("../components/datamodelview/dataLoaderWorker.js", import.meta.url));
+ worker.onmessage = (e) => {
+ dispatch({ type: "SET_GROUPS", payload: e.data });
+ worker.terminate();
+ };
+ worker.postMessage({});
+ return () => worker.terminate();
+ }, []);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export const useDatamodelData = () => useContext(DatamodelDataContext);
+export const useDatamodelDataDispatch = () => useContext(DatamodelDataDispatchContext);
\ No newline at end of file
diff --git a/Website/contexts/DatamodelViewContext.tsx b/Website/contexts/DatamodelViewContext.tsx
index a67c9de..3d9b756 100644
--- a/Website/contexts/DatamodelViewContext.tsx
+++ b/Website/contexts/DatamodelViewContext.tsx
@@ -8,18 +8,21 @@ export interface DatamodelViewState {
currentGroup: string | null;
currentSection: string | null;
scrollToSection: (sectionId: string) => void;
+ loading: boolean;
}
const initialState: DatamodelViewState = {
currentGroup: null,
currentSection: null,
scrollToSection: () => { throw new Error("scrollToSection not initialized yet!"); },
+ loading: true,
}
type DatamodelViewAction =
| { type: 'SET_CURRENT_GROUP', payload: string | null }
| { type: 'SET_CURRENT_SECTION', payload: string | null }
| { type: 'SET_SCROLL_TO_SECTION', payload: (sectionId: string) => void }
+ | { type: 'SET_LOADING', payload: boolean }
const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAction): DatamodelViewState => {
@@ -30,6 +33,8 @@ const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAc
return { ...state, currentSection: action.payload }
case 'SET_SCROLL_TO_SECTION':
return { ...state, scrollToSection: action.payload }
+ case 'SET_LOADING':
+ return { ...state, loading: action.payload }
default:
return state;
}
diff --git a/Website/contexts/SearchPerformanceContext.tsx b/Website/contexts/SearchPerformanceContext.tsx
new file mode 100644
index 0000000..4825503
--- /dev/null
+++ b/Website/contexts/SearchPerformanceContext.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import React, { createContext, useContext, useRef, useCallback, ReactNode } from 'react';
+
+interface SearchPerformanceContextType {
+ scheduleImmediateUpdate: (callback: () => void) => void;
+ scheduleBackgroundUpdate: (callback: () => void) => void;
+ cancelScheduledUpdate: (id: number) => void;
+}
+
+const SearchPerformanceContext = createContext(null);
+
+export const SearchPerformanceProvider = ({ children }: { children: ReactNode }) => {
+ const immediateUpdatesRef = useRef void>>(new Set());
+ const backgroundUpdatesRef = useRef void>>(new Set());
+
+ const immediateUpdateMap = useRef