import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { useSearchParams } from "react-router"; import { Button, DocumentationHelpLink, DismissibleAlert, IconButton, LoadingIndicator, PageScrollViewport, useGuardedNavigate, usePlatformModules, type PlatformRouteContext } from "@govoplan/core-webui"; import { listSearchProviders, search, type SearchResourceType, type SearchResponse } from "../../api/search"; export default function SearchPage({ settings }: PlatformRouteContext) { const navigate = useGuardedNavigate(); const platformModules = usePlatformModules(); const [params, setParams] = useSearchParams(); const query = params.get("q") ?? ""; const moduleKey = params.getAll("module").join("\u001f"); const resourceTypeKey = params.getAll("resource_type").join("\u001f"); const contextId = params.get("context") ?? undefined; const modules = useMemo( () => moduleKey ? moduleKey.split("\u001f") : [], [moduleKey] ); const resourceTypes = useMemo( () => resourceTypeKey ? resourceTypeKey.split("\u001f") : [], [resourceTypeKey] ); const [draft, setDraft] = useState(query); const [response, setResponse] = useState(null); const [resourceCatalogue, setResourceCatalogue] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [filtersOpen, setFiltersOpen] = useState(false); const filtersRef = useRef(null); const requestKey = useMemo( () => JSON.stringify([query, moduleKey, resourceTypeKey, contextId]), [contextId, moduleKey, query, resourceTypeKey] ); useEffect(() => { setDraft(query); }, [query]); useEffect(() => { 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(); }, [settings]); useEffect(() => { function closeFilters(event: MouseEvent) { if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) { setFiltersOpen(false); } } function closeFiltersWithKeyboard(event: KeyboardEvent) { if (event.key === "Escape") setFiltersOpen(false); } window.addEventListener("mousedown", closeFilters); window.addEventListener("keydown", closeFiltersWithKeyboard); return () => { window.removeEventListener("mousedown", closeFilters); window.removeEventListener("keydown", closeFiltersWithKeyboard); }; }, []); const moduleLabels = useMemo( () => new Map(platformModules.map((module) => [module.id, module.label])), [platformModules] ); 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 (modules.length === 0 || modules.includes(resource.module_id)) { labels.set(resource.resource_type, resource.label); } } for (const result of response?.results ?? []) { if (modules.length === 0 || modules.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)); }, [modules, resourceCatalogue, resourceTypes, response]); const activeFilterCount = modules.length + resourceTypes.length; const loadResults = useCallback((cursor?: string) => { const controller = new AbortController(); setLoading(true); setError(""); search( settings, { query, modules, resourceTypes, contextKind: modules.length ? "module" : "global", contextId, limit: 50, cursor }, controller.signal ). then((next) => { setResponse((current) => cursor && current ? { ...next, results: [...current.results, ...next.results], diagnostics: [ ...current.diagnostics, ...next.diagnostics.filter((item) => !current.diagnostics.some( (currentItem) => currentItem.provider_id === item.provider_id ) ) ] } : next ); }). catch((reason) => { if ((reason as Error).name !== "AbortError") { setError(reason instanceof Error ? reason.message : "Search failed."); } }). finally(() => setLoading(false)); return controller; }, [contextId, modules, query, resourceTypes, settings]); useEffect(() => { if (!query.trim()) { setResponse(null); return; } setResponse(null); const controller = loadResults(); return () => controller.abort(); }, [loadResults, requestKey]); function submit(event: FormEvent) { event.preventDefault(); const next = new URLSearchParams(params); if (draft.trim()) next.set("q", draft.trim()); else next.delete("q"); setParams(next); } function toggleFilter(name: "module" | "resource_type", value: string) { const selected = name === "module" ? modules : resourceTypes; const nextValues = selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]; const next = new URLSearchParams(params); next.delete(name); for (const item of [...nextValues].sort()) next.append(name, item); setParams(next); } function clearFilters() { const next = new URLSearchParams(params); next.delete("module"); next.delete("resource_type"); next.delete("context"); setParams(next); } return (
{error && setError("")}> {error} } {response?.diagnostics.map((diagnostic) => {diagnostic.message} )} {query && response && response.results.length === 0 &&
No results for “{query}”.
}
{response?.results.map((result) => )}
{response?.next_cursor && }
); } function humanizeIdentifier(value: string): string { return value. replace(/[._:-]+/g, " "). replace(/\b\w/g, (character) => character.toUpperCase()); }