From 577d8d2fba94a1183caeccdc3096d120407a8107 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Fri, 3 Apr 2026 09:59:54 +0100 Subject: [PATCH] feat: improve search functionality in ComponentsModal with scoring and highlighting --- .../Components/Workflow/ComponentsModal.tsx | 405 ++++++++++++++---- 1 file changed, 328 insertions(+), 77 deletions(-) diff --git a/Common/UI/Components/Workflow/ComponentsModal.tsx b/Common/UI/Components/Workflow/ComponentsModal.tsx index a63bbc4d3a..6ba41fb31f 100644 --- a/Common/UI/Components/Workflow/ComponentsModal.tsx +++ b/Common/UI/Components/Workflow/ComponentsModal.tsx @@ -23,6 +23,50 @@ export interface ComponentProps { categories: Array; } +const escapeRegExp: (value: string) => string = (value: string): string => { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}; + +const getSearchScore: ( + componentMetadata: ComponentMetadata, + searchTerm: string, +) => number = ( + componentMetadata: ComponentMetadata, + searchTerm: string, +): number => { + const title: string = componentMetadata.title.toLowerCase(); + const description: string = componentMetadata.description.toLowerCase(); + const category: string = componentMetadata.category.toLowerCase(); + + let score: number = 0; + + if (title.startsWith(searchTerm)) { + score += 140; + } else if (title.includes(searchTerm)) { + score += 100; + } + + if (category.startsWith(searchTerm)) { + score += 75; + } else if (category.includes(searchTerm)) { + score += 55; + } + + if (description.includes(searchTerm)) { + score += 35; + } + + if ( + title.split(" ").some((word: string) => { + return word.trim().startsWith(searchTerm); + }) + ) { + score += 15; + } + + return score; +}; + const ComponentsModal: FunctionComponent = ( props: ComponentProps, ): ReactElement => { @@ -32,13 +76,9 @@ const ComponentsModal: FunctionComponent = ( const [components, setComponents] = useState>([]); const [categories, setCategories] = useState>([]); - const [componentsToShow, setComponentsToShow] = useState< Array >([]); - - const [isSearching, setIsSearching] = useState(false); - const [selectedComponentMetadata, setSelectedComponentMetadata] = useState(null); @@ -46,43 +86,135 @@ const ComponentsModal: FunctionComponent = ( setComponents(props.components); setComponentsToShow([...props.components]); setCategories(props.categories); + }, [props.categories, props.components]); + + useEffect(() => { + const normalizedSearch: string = search.trim().toLowerCase(); + + const filteredComponents: Array = components + .filter((componentMetadata: ComponentMetadata) => { + return componentMetadata.componentType === props.componentsType; + }) + .filter((componentMetadata: ComponentMetadata) => { + if (!normalizedSearch) { + return true; + } + + return ( + componentMetadata.title.toLowerCase().includes(normalizedSearch) || + componentMetadata.description + .toLowerCase() + .includes(normalizedSearch) || + componentMetadata.category.toLowerCase().includes(normalizedSearch) + ); + }) + .sort((componentA: ComponentMetadata, componentB: ComponentMetadata) => { + if (!normalizedSearch) { + return componentA.title.localeCompare(componentB.title); + } + + const scoreDifference: number = + getSearchScore(componentB, normalizedSearch) - + getSearchScore(componentA, normalizedSearch); + + if (scoreDifference !== 0) { + return scoreDifference; + } + + return componentA.title.localeCompare(componentB.title); + }); + + setComponentsToShow(filteredComponents); + }, [components, props.componentsType, search]); + + useEffect(() => { + searchInputRef.current?.focus(); }, []); useEffect(() => { - if (!isSearching) { - return; - } - if (!search) { - setComponentsToShow([ - ...components.filter((componentMetadata: ComponentMetadata) => { - return componentMetadata.componentType === props.componentsType; - }), - ]); - } + const handleKeyDown: (event: KeyboardEvent) => void = ( + event: KeyboardEvent, + ): void => { + const target: HTMLElement | null = event.target as HTMLElement | null; - setComponentsToShow([ - ...components.filter((componentMetadata: ComponentMetadata) => { - return ( - componentMetadata.componentType === props.componentsType && - (componentMetadata.title - .toLowerCase() - .includes(search.trim().toLowerCase()) || - componentMetadata.description - .toLowerCase() - .includes(search.trim().toLowerCase()) || - componentMetadata.category - .toLowerCase() - .includes(search.trim().toLowerCase())) - ); - }), - ]); - }, [search]); + const isTypingContext: boolean = Boolean( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.getAttribute("contenteditable") === "true"), + ); + + if ( + event.key === "/" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !isTypingContext + ) { + event.preventDefault(); + searchInputRef.current?.focus(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); const hasSearchTerm: boolean = search.trim().length > 0; + const normalizedSearch: string = search.trim().toLowerCase(); const totalComponentCount: number = components.length; const componentTypeLabel: string = `${props.componentsType.toLowerCase()}${ totalComponentCount === 1 ? "" : "s" }`; + const suggestedCategories: Array = categories + .filter((category: ComponentCategory) => { + return components.some((componentMetadata: ComponentMetadata) => { + return componentMetadata.category === category.name; + }); + }) + .slice(0, 4); + + const renderHighlightedText: ( + text: string, + markClassName?: string, + ) => React.ReactNode = ( + text: string, + markClassName?: string, + ): React.ReactNode => { + if (!hasSearchTerm) { + return text; + } + + const highlightedParts: Array = text.split( + new RegExp(`(${escapeRegExp(search.trim())})`, "ig"), + ); + + return ( + <> + {highlightedParts.map((part: string, index: number) => { + if (part.toLowerCase() === normalizedSearch) { + return ( + + {part} + + ); + } + + return ( + {part} + ); + })} + + ); + }; return ( = (
{/* Search box */}
- -
-
- -
-
- ) => { - setIsSearching(true); - setSearch(event.target.value); - }} - /> -
-

+

+
+
+ +

{hasSearchTerm - ? `${componentsToShow.length} of ${totalComponentCount} ${componentTypeLabel} shown` - : "Search by title, description, or category."} + ? "Showing the closest matches first across title, description, and category." + : `Find the right ${props.componentsType.toLowerCase()} by name, category, or the job you need it to do.`}

+
+
+ {hasSearchTerm + ? `${componentsToShow.length} match${ + componentsToShow.length === 1 ? "" : "es" + }` + : `${totalComponentCount} available`} +
+
- {hasSearchTerm && ( +
+
+ +
+
+ ) => { + setSearch(event.target.value); + }} + onKeyDown={( + event: React.KeyboardEvent, + ) => { + if (event.key === "Escape" && hasSearchTerm) { + setSearch(""); + searchInputRef.current?.focus(); + } + }} + /> +
+ + {hasSearchTerm + ? `Showing ${componentsToShow.length} of ${totalComponentCount} ${componentTypeLabel}.` + : "Searches title, description, and category."} + + {!hasSearchTerm && ( + + + / + + Quick focus + + )} + {hasSearchTerm && ( + + + Esc + + Clear search + + )} +
+
+ + {hasSearchTerm && ( +
- )} -
+
+ )}
+ + {suggestedCategories.length > 0 && ( +
+ + Quick filters: + + {suggestedCategories.map((category: ComponentCategory) => { + const isActive: boolean = + normalizedSearch === category.name.toLowerCase(); + + return ( + + ); + })} +
+ )}
{!componentsToShow || (componentsToShow.length === 0 && ( -
- +
+
+ +
+ {hasSearchTerm && ( + + )}
))} @@ -261,17 +483,41 @@ const ComponentsModal: FunctionComponent = ( {/* Text */}
-

- {componentMetadata.title} -

+
+

+ {renderHighlightedText( + componentMetadata.title, + isSelected + ? "rounded bg-white/80 px-0.5 text-current" + : "rounded bg-amber-100 px-0.5 text-current", + )} +

+ + {hasSearchTerm && ( + + {renderHighlightedText( + componentMetadata.category, + isSelected + ? "rounded bg-indigo-100 px-0.5 text-current" + : "rounded bg-white px-0.5 text-current", + )} + + )} +

= ( overflow: "hidden", }} > - {componentMetadata.description} + {renderHighlightedText( + componentMetadata.description, + isSelected + ? "rounded bg-white/80 px-0.5 text-current" + : "rounded bg-amber-100 px-0.5 text-current", + )}