diff --git a/Website/components/attributes/ChoiceAttribute.tsx b/Website/components/attributes/ChoiceAttribute.tsx index aa087fb..bb5e7c2 100644 --- a/Website/components/attributes/ChoiceAttribute.tsx +++ b/Website/components/attributes/ChoiceAttribute.tsx @@ -3,7 +3,7 @@ import { ChoiceAttributeType } from "@/lib/Types" import { formatNumberSeperator } from "@/lib/utils" import { CheckCircle, Circle, Square, CheckSquare } from "lucide-react" -export default function ChoiceAttribute({ attribute }: { attribute: ChoiceAttributeType }) { +export default function ChoiceAttribute({ attribute, highlightMatch, highlightTerm }: { attribute: ChoiceAttributeType, highlightMatch: (text: string, term: string) => string | React.JSX.Element, highlightTerm: string }) { const isMobile = useIsMobile(); @@ -39,7 +39,7 @@ export default function ChoiceAttribute({ attribute }: { attribute: ChoiceAttrib ) )} - {option.Name} + {highlightMatch(option.Name, highlightTerm)} {option.Color && (
("asc") const [typeFilter, setTypeFilter] = useState("all") const [hideStandardFields, setHideStandardFields] = useState(true) + const [searchQuery, setSearchQuery] = useState("") const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -50,6 +52,21 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri } } + // Helper function to check if an attribute matches a search query + const attributeMatchesSearch = (attr: AttributeType, query: string): boolean => { + const basicMatch = attr.DisplayName.toLowerCase().includes(query) || + attr.SchemaName.toLowerCase().includes(query) || + (attr.Description && attr.Description.toLowerCase().includes(query)); + + // Check options for ChoiceAttribute and StatusAttribute + let optionsMatch = false; + if (attr.AttributeType === 'ChoiceAttribute' || attr.AttributeType === 'StatusAttribute') { + optionsMatch = attr.Options.some(option => option.Name.toLowerCase().includes(query)); + } + + return basicMatch || optionsMatch; + }; + const getSortedAttributes = () => { let filteredAttributes = entity.Attributes @@ -57,13 +74,15 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri filteredAttributes = filteredAttributes.filter(attr => attr.AttributeType === typeFilter) } - // Filter by search prop (from parent) + if (searchQuery) { + const query = searchQuery.toLowerCase() + filteredAttributes = filteredAttributes.filter(attr => attributeMatchesSearch(attr, query)) + } + + // Also filter by parent search prop if provided if (search && search.length >= 3) { - const query = search.toLowerCase(); - filteredAttributes = filteredAttributes.filter(attr => - attr.DisplayName.toLowerCase().includes(query) || - attr.SchemaName.toLowerCase().includes(query) - ); + const query = search.toLowerCase() + filteredAttributes = filteredAttributes.filter(attr => attributeMatchesSearch(attr, query)) } if (hideStandardFields) filteredAttributes = filteredAttributes.filter(attr => attr.IsCustomAttribute || attr.IsStandardFieldModified); @@ -104,6 +123,7 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri } const sortedAttributes = getSortedAttributes(); + const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting // Notify parent of visible count React.useEffect(() => { @@ -134,35 +154,104 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri ] return <> -
- {/* Removed internal search input, now using parent search */} - - +
+
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchQuery("") + } + }} + className="pl-6 pr-8 h-8 text-xs md:pl-8 md:pr-10 md:h-10 md:text-sm" + /> + {searchQuery && ( + + )} +
+ + + {(searchQuery || typeFilter !== "all") && ( + + )} +
+ {search && search.length >= 3 && searchQuery && ( +
+ + Warning: Global search "{search}" is also active +
+ )}
{sortedAttributes.length === 0 ? ( -
-

No attributes available for this table

+
+ {searchQuery || typeFilter !== "all" ? ( +
+

+ {searchQuery && typeFilter !== "all" + ? `No ${typeFilter === "all" ? "" : typeFilter.replace("Attribute", "")} attributes found matching "${searchQuery}"` + : searchQuery + ? `No attributes found matching "${searchQuery}"` + : `No ${typeFilter === "all" ? "" : typeFilter.replace("Attribute", "")} attributes available` + } +

+ +
+ ) : ( +

No attributes available for this table

+ )}
) : ( @@ -216,14 +305,16 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri }`} > - {highlightMatch(attribute.DisplayName, search)} + {highlightMatch(attribute.DisplayName, highlightTerm)} - {highlightMatch(attribute.SchemaName, search)} + {highlightMatch(attribute.SchemaName, highlightTerm)} - {getAttributeComponent(entity, attribute)} + {getAttributeComponent(entity, attribute, highlightMatch, highlightTerm)} - {attribute.Description} + + {highlightMatch(attribute.Description ?? "", highlightTerm)} + ))} @@ -232,12 +323,12 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri } -function getAttributeComponent(entity: EntityType, attribute: AttributeType) { +function getAttributeComponent(entity: EntityType, attribute: AttributeType, highlightMatch: (text: string, term: string) => string | React.JSX.Element, highlightTerm: string) { const key = `${attribute.SchemaName}-${entity.SchemaName}`; switch (attribute.AttributeType) { case 'ChoiceAttribute': - return ; + return ; case 'DateTimeAttribute': return ; case 'GenericAttribute': diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index a5df8c7..f570f7b 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -95,7 +95,7 @@ function DatamodelViewContent() { useEffect(() => { if (!workerRef.current) { - workerRef.current = new Worker(new URL("./searchWorker.js", import.meta.url)); + workerRef.current = new Worker(new URL("./searchWorker.ts", import.meta.url)); } // Initialize or re-initialize worker with groups when groups change diff --git a/Website/components/datamodelview/Keys.tsx b/Website/components/datamodelview/Keys.tsx index 9795319..610f494 100644 --- a/Website/components/datamodelview/Keys.tsx +++ b/Website/components/datamodelview/Keys.tsx @@ -50,6 +50,16 @@ function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { sear ) } + // Also filter by parent search prop if provided + if (search && search.length >= 3) { + const query = search.toLowerCase() + filteredKeys = filteredKeys.filter(key => + key.Name.toLowerCase().includes(query) || + key.LogicalName.toLowerCase().includes(query) || + key.KeyAttributes.some(attr => attr.toLowerCase().includes(query)) + ) + } + if (!sortColumn || !sortDirection) return filteredKeys return [...filteredKeys].sort((a, b) => { @@ -82,6 +92,7 @@ function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { sear } const sortedKeys = getSortedKeys(); + const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting React.useEffect(() => { if (onVisibleCountChange) { @@ -98,26 +109,50 @@ function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { sear return ( <> -
-
- - setSearchQuery(e.target.value)} - className="pl-6 h-8 text-xs md:pl-8 md:h-10 md:text-sm" - /> +
+
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchQuery("") + } + }} + className="pl-6 pr-8 h-8 text-xs md:pl-8 md:pr-10 md:h-10 md:text-sm" + /> + {searchQuery && ( + + )} +
+ {searchQuery && ( + + )}
- {searchQuery && ( - + {search && search.length >= 3 && searchQuery && ( +
+ + Warning: Global search "{search}" is also active +
)}
@@ -180,10 +215,10 @@ function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { sear }`} > - {highlightMatch(key.Name, search)} + {highlightMatch(key.Name, highlightTerm)} - {highlightMatch(key.LogicalName, search)} + {highlightMatch(key.LogicalName, highlightTerm)}
@@ -192,7 +227,7 @@ function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { sear key={i} className="inline-flex items-center px-1.5 py-0.5 text-xs rounded-md font-medium bg-blue-50 text-blue-700 md:px-2 md:py-1 md:text-sm" > - {highlightMatch(attr, search)} + {highlightMatch(attr, highlightTerm)} ))}
diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index cf1ce9d..9d5d054 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -129,10 +129,31 @@ export const List = ({ }: IListProps) => { }, [flatItems, rowVirtualizer]); useEffect(() => { - requestAnimationFrame(() => { - rowVirtualizer.measure(); - }); - }, [flatItems]); + // Only measure if we're not filtering - let the virtualizer handle filtered states naturally + if (!search || search.length < 3) { + requestAnimationFrame(() => { + rowVirtualizer.measure(); + }); + } + }, [flatItems, search, rowVirtualizer]); + + // Handle scrolling to top when starting a search + const prevSearchLengthRef = useRef(search.length); + useEffect(() => { + const currentSearchLength = search.length; + const prevSearchLength = prevSearchLengthRef.current; + + // If we just crossed from < 3 to >= 3 characters (starting a search) + if (prevSearchLength < 3 && currentSearchLength >= 3) { + setTimeout(() => { + if (parentRef.current) { + parentRef.current.scrollTop = 0; + } + }, 50); // Small delay to ensure virtualizer has processed the new items + } + + prevSearchLengthRef.current = currentSearchLength; + }, [search]); // Throttled scroll handler to reduce calculations const handleScroll = useCallback(() => { @@ -210,8 +231,9 @@ export const List = ({ }: IListProps) => { return (
- {/* Add skeleton loading state */} - {flatItems.length === 0 && datamodelView.loading && ( + + {/* Show skeleton loading state only when initially loading */} + {flatItems.length === 0 && datamodelView.loading && (!search || search.length < 3) && (
{[...Array(5)].map((_, i) => (
@@ -227,6 +249,16 @@ export const List = ({ }: IListProps) => { ))}
)} + + {/* Show no results message when searching but no items found */} + {flatItems.length === 0 && search && search.length >= 3 && ( +
+
No tables found
+
+ No attributes match your search for "{search}" +
+
+ )} {/* Virtualized list */}
{ height: `${rowVirtualizer.getTotalSize()}px`, width: '100%', position: 'relative', - visibility: flatItems.length === 0 && datamodelView.loading ? 'hidden' : 'visible' + visibility: flatItems.length === 0 ? 'hidden' : 'visible' }} > {rowVirtualizer.getVirtualItems().map((virtualItem) => { diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 38cfe0c..b8782a1 100644 --- a/Website/components/datamodelview/Relationships.tsx +++ b/Website/components/datamodelview/Relationships.tsx @@ -65,6 +65,17 @@ export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRe ) } + // Also filter by parent search prop if provided + if (search && search.length >= 3) { + const query = search.toLowerCase() + filteredRelationships = filteredRelationships.filter(rel => + rel.Name.toLowerCase().includes(query) || + rel.TableSchema.toLowerCase().includes(query) || + rel.LookupDisplayName.toLowerCase().includes(query) || + rel.RelationshipSchema.toLowerCase().includes(query) + ) + } + if (!sortColumn || !sortDirection) return filteredRelationships return [...filteredRelationships].sort((a, b) => { @@ -118,6 +129,7 @@ export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRe ] const sortedRelationships = getSortedRelationships(); + const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting React.useEffect(() => { if (onVisibleCountChange) { @@ -126,41 +138,65 @@ export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRe }, [onVisibleCountChange, sortedRelationships.length]); return <> -
-
- - setSearchQuery(e.target.value)} - className="pl-6 h-8 text-xs md:pl-8 md:h-10 md:text-sm" - /> +
+
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchQuery("") + } + }} + className="pl-6 pr-8 h-8 text-xs md:pl-8 md:pr-10 md:h-10 md:text-sm" + /> + {searchQuery && ( + + )} +
+ + {(searchQuery || typeFilter !== "all") && ( + + )}
- - {(searchQuery || typeFilter !== "all") && ( - + {search && search.length >= 3 && searchQuery && ( +
+ + Warning: Global search "{search}" is also active +
)}
@@ -252,7 +288,7 @@ export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRe }`} > - {highlightMatch(relationship.Name, search)} + {highlightMatch(relationship.Name, highlightTerm)} {relationship.LookupDisplayName} diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 88f2dbd..009a6d6 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -30,6 +30,7 @@ export const TimeSlicedSearch = ({ const [localValue, setLocalValue] = useState(''); const [isTyping, setIsTyping] = useState(false); const [portalRoot, setPortalRoot] = useState(null); + const [lastValidSearch, setLastValidSearch] = useState(''); const { isOpen } = useSidebar(); const isMobile = useIsMobile(); @@ -46,11 +47,28 @@ export const TimeSlicedSearch = ({ clearTimeout(searchTimeoutRef.current); } + // If we're going from a valid search to an invalid one, clear the search + if (value.length < 3 && lastValidSearch.length >= 3) { + onSearch(''); + setLastValidSearch(''); + setIsTyping(false); + onLoadingChange(false); + return; + } + + // Don't search if less than 3 characters + if (value.length < 3) { + setIsTyping(false); + onLoadingChange(false); + return; + } + searchTimeoutRef.current = window.setTimeout(() => { // Use MessageChannel for immediate callback without blocking main thread const channel = new MessageChannel(); channel.port2.onmessage = () => { onSearch(value); + setLastValidSearch(value); // Reset typing state in next frame frameRef.current = requestAnimationFrame(() => { @@ -59,7 +77,7 @@ export const TimeSlicedSearch = ({ }; channel.port1.postMessage(null); }, 350); - }, [onSearch]); + }, [onSearch, onLoadingChange, lastValidSearch]); const handleChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; @@ -67,24 +85,36 @@ export const TimeSlicedSearch = ({ // Immediate visual update (highest priority) setLocalValue(value); - // Manage typing state - if (!isTyping) { - setIsTyping(true); - onLoadingChange(true); - } + // Only manage typing state and loading for searches >= 3 characters + if (value.length >= 3) { + // Manage typing state + if (!isTyping) { + setIsTyping(true); + onLoadingChange(true); + } - // Reset typing timeout - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); + // Reset typing timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + // Auto-reset typing state if user stops typing + typingTimeoutRef.current = window.setTimeout(() => { + setIsTyping(false); + }, 2000); + } else { + // Clear typing state for short searches + setIsTyping(false); + onLoadingChange(false); + + // Clear any pending timeouts + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } } - // Schedule search + // Schedule search (will handle short searches internally) scheduleSearch(value); - - // Auto-reset typing state if user stops typing - typingTimeoutRef.current = window.setTimeout(() => { - setIsTyping(false); - }, 2000); }, [isTyping, onLoadingChange, scheduleSearch]); @@ -106,7 +136,13 @@ export const TimeSlicedSearch = ({ // Handle keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { + if (e.key === 'Escape') { + e.preventDefault(); + setLocalValue(''); + onSearch(''); // Only clear when explicitly using ESC + setIsTyping(false); + onLoadingChange(false); + } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onNavigateNext?.(); if ('vibrate' in navigator) { @@ -125,7 +161,7 @@ export const TimeSlicedSearch = ({ e.preventDefault(); onNavigatePrevious?.(); } - }, [onNavigateNext, onNavigatePrevious]); + }, [onNavigateNext, onNavigatePrevious, onSearch, onLoadingChange]); const hasResults = totalResults > 0; const showNavigation = hasResults && localValue.length >= 3; @@ -193,7 +229,7 @@ export const TimeSlicedSearch = ({ /> {/* Clear button or loading indicator */}
- {isTyping ? ( + {isTyping && localValue.length >= 3 ? (
) : localValue ? (