From c5bb8743fd2ccbf93f5a701685387e6d394e1237 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 11 Aug 2025 19:46:48 +0200 Subject: [PATCH 1/8] feat: local search for attributes, relationships and keys. With clear option and small indication for active globalsearch. Also small warning if both search options are being used. --- .../components/datamodelview/Attributes.tsx | 150 ++++++++++++++---- Website/components/datamodelview/Keys.tsx | 79 ++++++--- .../datamodelview/Relationships.tsx | 108 ++++++++----- 3 files changed, 245 insertions(+), 92 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index c89d934..1724d0d 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -4,8 +4,9 @@ import { EntityType, AttributeType } from "@/lib/Types" import { TableHeader, TableRow, TableHead, TableBody, TableCell, Table } from "../ui/table" import { Button } from "../ui/button" import { useState } from "react" -import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff, Eye } from "lucide-react" +import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff, Eye, Search, X } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./../ui/select" +import { Input } from "../ui/input" import { AttributeDetails } from "./../entity/AttributeDetails" import BooleanAttribute from "./../attributes/BooleanAttribute" import ChoiceAttribute from "./../attributes/ChoiceAttribute" @@ -33,6 +34,7 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") const [hideStandardFields, setHideStandardFields] = useState(true) + const [searchQuery, setSearchQuery] = useState("") const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -57,13 +59,23 @@ 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 => + attr.DisplayName.toLowerCase().includes(query) || + attr.SchemaName.toLowerCase().includes(query) || + (attr.Description && attr.Description.toLowerCase().includes(query)) + ) + } + + // Also filter by parent search prop if provided if (search && search.length >= 3) { - const query = search.toLowerCase(); + const query = search.toLowerCase() filteredAttributes = filteredAttributes.filter(attr => attr.DisplayName.toLowerCase().includes(query) || - attr.SchemaName.toLowerCase().includes(query) - ); + attr.SchemaName.toLowerCase().includes(query) || + (attr.Description && attr.Description.toLowerCase().includes(query)) + ) } if (hideStandardFields) filteredAttributes = filteredAttributes.filter(attr => attr.IsCustomAttribute || attr.IsStandardFieldModified); @@ -104,6 +116,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 +147,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,10 +298,10 @@ 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)} diff --git a/Website/components/datamodelview/Keys.tsx b/Website/components/datamodelview/Keys.tsx index 9795319..5dc63d5 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/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 38cfe0c..f8c28f5 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} From f231d8eb2e3d70d5141c225d1bdda93f6836d977 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 11 Aug 2025 19:48:30 +0200 Subject: [PATCH 2/8] chore: ESC clears searchfields --- Website/components/datamodelview/TimeSlicedSearch.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 88f2dbd..1c8519d 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -106,7 +106,11 @@ 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(''); + } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onNavigateNext?.(); if ('vibrate' in navigator) { @@ -125,7 +129,7 @@ export const TimeSlicedSearch = ({ e.preventDefault(); onNavigatePrevious?.(); } - }, [onNavigateNext, onNavigatePrevious]); + }, [onNavigateNext, onNavigatePrevious, onSearch]); const hasResults = totalResults > 0; const showNavigation = hasResults && localValue.length >= 3; From 39b7b09cb73cf59a7ba01b25c6326c8c459e20eb Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 11 Aug 2025 19:54:14 +0200 Subject: [PATCH 3/8] fix: dont search before 3 characters --- .../datamodelview/TimeSlicedSearch.tsx | 68 ++++++++++++++----- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 1c8519d..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]); @@ -109,7 +139,9 @@ export const TimeSlicedSearch = ({ if (e.key === 'Escape') { e.preventDefault(); setLocalValue(''); - onSearch(''); + onSearch(''); // Only clear when explicitly using ESC + setIsTyping(false); + onLoadingChange(false); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onNavigateNext?.(); @@ -129,7 +161,7 @@ export const TimeSlicedSearch = ({ e.preventDefault(); onNavigatePrevious?.(); } - }, [onNavigateNext, onNavigatePrevious, onSearch]); + }, [onNavigateNext, onNavigatePrevious, onSearch, onLoadingChange]); const hasResults = totalResults > 0; const showNavigation = hasResults && localValue.length >= 3; @@ -197,7 +229,7 @@ export const TimeSlicedSearch = ({ /> {/* Clear button or loading indicator */}
- {isTyping ? ( + {isTyping && localValue.length >= 3 ? (
) : localValue ? (