import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useLocation } from "react-router-dom"; import { Button, Dialog, DismissibleAlert, IconButton, LoadingIndicator, PageScrollViewport, SegmentedControl, useGuardedNavigate, usePlatformModules, usePlatformUiCapabilities, type GlobalSearchProps, type SearchContextsUiCapability } from "@govoplan/core-webui"; import { listSearchProviders, search, type SearchResourceType, type SearchResponse, type SearchResult } from "../api/search"; import { calculateSearchOverlayLayout, selectSearchContext, type SearchOverlayLayout } from "./searchOverlayLayout"; const MIN_QUERY_LENGTH = 2; type SearchScope = "global" | "context"; export default function GlobalSearch({ settings }: GlobalSearchProps) { const navigate = useGuardedNavigate(); const location = useLocation(); const platformModules = usePlatformModules(); const contextCapabilities = usePlatformUiCapabilities("search.contexts"); const availableContexts = useMemo( () => contextCapabilities.flatMap((capability) => capability.contexts), [contextCapabilities] ); const currentContext = useMemo( () => selectSearchContext(availableContexts, location.pathname), [availableContexts, location.pathname] ); const moduleLabels = useMemo( () => new Map(platformModules.map((module) => [module.id, module.label])), [platformModules] ); const [query, setQuery] = useState(""); const [modules, setModules] = useState([]); const [resourceTypes, setResourceTypes] = useState([]); const [scope, setScope] = useState(currentContext ? "context" : "global"); const [response, setResponse] = useState(null); const [resourceCatalogue, setResourceCatalogue] = useState([]); const [open, setOpen] = useState(false); const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [filtersOpen, setFiltersOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const rootRef = useRef(null); const sourceInputRef = useRef(null); const overlayInputRef = useRef(null); const filtersRef = useRef(null); const resultsRef = useRef(null); const requestSequenceRef = useRef(0); const loadMoreControllerRef = useRef(null); const suppressRestoredFocusRef = useRef(false); const effectiveModules = useMemo( () => scope === "context" && currentContext ? [currentContext.moduleId] : modules, [currentContext, modules, scope] ); const effectiveResourceTypes = useMemo(() => { if (scope !== "context" || !currentContext?.resourceTypes?.length) return resourceTypes; if (resourceTypes.length === 0) return currentContext.resourceTypes; return resourceTypes.filter((resourceType) => currentContext.resourceTypes?.includes(resourceType)); }, [currentContext, resourceTypes, scope]); const effectiveModuleKey = effectiveModules.join("\u001f"); const effectiveResourceTypeKey = effectiveResourceTypes.join("\u001f"); const activeFilterCount = (scope === "global" ? modules.length : 0) + resourceTypes.length; const moduleOptions = useMemo(() => { const values = new Set(resourceCatalogue.map((resource) => resource.module_id)); for (const result of response?.results ?? []) values.add(result.module_id); for (const moduleId of modules) values.add(moduleId); return [...values] .map((value) => ({ value, label: moduleLabels.get(value) ?? humanizeIdentifier(value) })) .sort((left, right) => left.label.localeCompare(right.label)); }, [moduleLabels, modules, resourceCatalogue, response]); const resourceTypeOptions = useMemo(() => { const labels = new Map(); for (const resource of resourceCatalogue) { if (effectiveModules.length === 0 || effectiveModules.includes(resource.module_id)) { labels.set(resource.resource_type, resource.label); } } for (const result of response?.results ?? []) { if (effectiveModules.length === 0 || effectiveModules.includes(result.module_id)) { if (!labels.has(result.resource_type)) { labels.set(result.resource_type, humanizeIdentifier(result.resource_type)); } } } for (const resourceType of resourceTypes) { if (!labels.has(resourceType)) { labels.set(resourceType, humanizeIdentifier(resourceType)); } } return [...labels] .map(([value, label]) => ({ value, label })) .sort((left, right) => left.label.localeCompare(right.label)); }, [effectiveModules, resourceCatalogue, resourceTypes, response]); const measureOverlay = useCallback(() => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect) return null; const next = calculateSearchOverlayLayout( { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, window.innerWidth, window.innerHeight ); setLayout(next); return next; }, []); const closeOverlay = useCallback(() => { loadMoreControllerRef.current?.abort(); suppressRestoredFocusRef.current = true; setOpen(false); setFiltersOpen(false); setActiveIndex(-1); }, []); const openOverlay = useCallback(() => { if (!measureOverlay()) return; setOpen(true); }, [measureOverlay]); const handleSourceFocus = useCallback(() => { if (suppressRestoredFocusRef.current) { suppressRestoredFocusRef.current = false; return; } openOverlay(); }, [openOverlay]); useEffect(() => { function focusSearch(event: KeyboardEvent) { const commandSearch = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; if (event.key !== "F3" && !commandSearch) return; event.preventDefault(); openOverlay(); } window.addEventListener("keydown", focusSearch); return () => window.removeEventListener("keydown", focusSearch); }, [openOverlay]); useEffect(() => { if (open) return; setScope(currentContext ? "context" : "global"); }, [currentContext, open]); useEffect(() => { if (scope !== "context" || !currentContext?.resourceTypes?.length) return; setResourceTypes((selected) => { const compatible = selected.filter((resourceType) => currentContext.resourceTypes?.includes(resourceType) ); return compatible.length === selected.length ? selected : compatible; }); }, [currentContext, scope]); useLayoutEffect(() => { if (!open) return undefined; measureOverlay(); const observer = typeof ResizeObserver === "undefined" || !rootRef.current ? null : new ResizeObserver(measureOverlay); observer?.observe(rootRef.current); window.addEventListener("resize", measureOverlay); return () => { observer?.disconnect(); window.removeEventListener("resize", measureOverlay); }; }, [measureOverlay, open]); useEffect(() => { if (!open) return undefined; const controller = new AbortController(); listSearchProviders(settings, controller.signal) .then((result) => setResourceCatalogue(result.resources ?? [])) .catch((reason) => { if ((reason as Error).name !== "AbortError") setResourceCatalogue([]); }); return () => controller.abort(); }, [open, settings]); useEffect(() => { if (!open || !filtersOpen) return undefined; function closeFilters(event: MouseEvent) { if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) { setFiltersOpen(false); } } window.addEventListener("mousedown", closeFilters); return () => window.removeEventListener("mousedown", closeFilters); }, [filtersOpen, open]); useEffect(() => { if (!open) return undefined; const normalizedQuery = query.trim(); requestSequenceRef.current += 1; const sequence = requestSequenceRef.current; if (normalizedQuery.length < MIN_QUERY_LENGTH) { setResponse(null); setLoading(false); setError(""); setActiveIndex(-1); return undefined; } const controller = new AbortController(); const timer = window.setTimeout(() => { setLoading(true); setError(""); search( settings, { query: normalizedQuery, modules: effectiveModules, resourceTypes: effectiveResourceTypes, contextKind: scope === "context" && currentContext ? "module" : "global", contextId: scope === "context" ? currentContext?.id : undefined, limit: 50 }, controller.signal ) .then((next) => { if (sequence !== requestSequenceRef.current) return; setResponse(next); setActiveIndex(-1); }) .catch((reason) => { if ((reason as Error).name !== "AbortError" && sequence === requestSequenceRef.current) { setError(reason instanceof Error ? reason.message : "Search failed."); setResponse(null); } }) .finally(() => { if (sequence === requestSequenceRef.current) setLoading(false); }); }, 180); return () => { window.clearTimeout(timer); controller.abort(); }; }, [ currentContext, effectiveModuleKey, effectiveResourceTypeKey, open, query, scope, settings ]); useEffect(() => { if (activeIndex < 0) return; resultsRef.current ?.querySelector(`[data-search-result-index="${activeIndex}"]`) ?.scrollIntoView({ block: "nearest" }); }, [activeIndex]); function openResult(result: SearchResult) { closeOverlay(); navigate(result.url); } function toggleFilter(name: "module" | "resource_type", value: string) { if (name === "module") { setModules((selected) => selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value].sort() ); return; } setResourceTypes((selected) => selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value].sort() ); } function clearFilters() { setModules([]); setResourceTypes([]); } function handleOverlaySubmit(event: FormEvent) { event.preventDefault(); const activeResult = activeIndex >= 0 ? response?.results[activeIndex] : null; if (activeResult) openResult(activeResult); } function handleOverlayKeyDown(event: ReactKeyboardEvent) { const results = response?.results ?? []; if (event.key === "ArrowDown" && results.length > 0) { event.preventDefault(); setActiveIndex((current) => (current + 1) % results.length); return; } if (event.key === "ArrowUp" && results.length > 0) { event.preventDefault(); setActiveIndex((current) => current <= 0 ? results.length - 1 : current - 1); } } async function loadMore() { const cursor = response?.next_cursor; if (!cursor || loading) return; loadMoreControllerRef.current?.abort(); const controller = new AbortController(); loadMoreControllerRef.current = controller; setLoading(true); setError(""); try { const next = await search( settings, { query: query.trim(), modules: effectiveModules, resourceTypes: effectiveResourceTypes, contextKind: scope === "context" && currentContext ? "module" : "global", contextId: scope === "context" ? currentContext?.id : undefined, limit: 50, cursor }, controller.signal ); setResponse((current) => current ? { ...next, results: [...current.results, ...next.results], diagnostics: [ ...current.diagnostics, ...next.diagnostics.filter((diagnostic) => !current.diagnostics.some((currentDiagnostic) => currentDiagnostic.provider_id === diagnostic.provider_id ) ) ] } : next ); } catch (reason) { if ((reason as Error).name !== "AbortError") { setError(reason instanceof Error ? reason.message : "Search failed."); } } finally { if (loadMoreControllerRef.current === controller) { loadMoreControllerRef.current = null; setLoading(false); } } } return ( <>
{layout && <>
{currentContext && className="search-overlay-scope" value={scope} size="content" width="inline" ariaLabel="Search scope" options={[ { id: "context", label: currentContext.label }, { id: "global", label: "Everywhere" } ]} onChange={(value) => { setScope(value); setActiveIndex(-1); }} /> }
{filtersOpen &&
Filter results } variant="ghost" onClick={() => setFiltersOpen(false)} />
{scope === "global" &&
Modules
{moduleOptions.map((option) => )} {moduleOptions.length === 0 && No module filters available. }
}
Result types
{resourceTypeOptions.map((option) => )} {resourceTypeOptions.length === 0 && No result type filters available. }
}
{loading && } } variant="ghost" className="search-overlay-close" onClick={closeOverlay} /> {activeFilterCount > 0 &&
{scope === "global" && modules.map((moduleId) => )} {resourceTypes.map((resourceType) => )}
}
{error && setError("")}> {error} } {response?.diagnostics.map((diagnostic) => {diagnostic.message} )} {query.trim().length < MIN_QUERY_LENGTH &&
Type at least {MIN_QUERY_LENGTH} characters to search.
} {query.trim().length >= MIN_QUERY_LENGTH && !loading && response?.results.length === 0 &&
No results for “{query.trim()}”.
}
{response?.results.map((result, index) => )}
{response?.next_cursor && }
}
); } function humanizeIdentifier(value: string): string { return value .replace(/[._:-]+/g, " ") .replace(/\b\w/g, (character) => character.toUpperCase()); }