388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
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<SearchResponse | null>(null);
|
|
const [resourceCatalogue, setResourceCatalogue] = useState<SearchResourceType[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
const filtersRef = useRef<HTMLDivElement>(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<string, string>();
|
|
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 (
|
|
<main className="search-page">
|
|
<div className="search-page-toolbar">
|
|
<form className="search-page-form" onSubmit={submit}>
|
|
<Search size={18} aria-hidden="true" />
|
|
<input
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
aria-label="Search query"
|
|
placeholder="Search"
|
|
autoFocus
|
|
/>
|
|
<Button type="submit" variant="primary">Search</Button>
|
|
</form>
|
|
<div className="search-filter-menu" ref={filtersRef}>
|
|
<Button
|
|
type="button"
|
|
className={`search-filter-trigger${activeFilterCount ? " is-active" : ""}`}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={filtersOpen}
|
|
onClick={() => setFiltersOpen((current) => !current)}>
|
|
<Filter size={16} aria-hidden="true" />
|
|
<span>Filters</span>
|
|
{activeFilterCount > 0 &&
|
|
<span className="search-filter-count" aria-label={`${activeFilterCount} active filters`}>
|
|
{activeFilterCount}
|
|
</span>
|
|
}
|
|
</Button>
|
|
{filtersOpen &&
|
|
<div className="search-filter-popover" role="dialog" aria-label="Filter search results">
|
|
<div className="search-filter-popover-header">
|
|
<strong>Filter results</strong>
|
|
<IconButton
|
|
label="Close filters"
|
|
icon={<X size={16} />}
|
|
variant="ghost"
|
|
onClick={() => setFiltersOpen(false)}
|
|
/>
|
|
</div>
|
|
<fieldset className="search-filter-group">
|
|
<legend>Modules</legend>
|
|
<div className="search-filter-options">
|
|
{moduleOptions.map((option) =>
|
|
<label key={option.value}>
|
|
<input
|
|
type="checkbox"
|
|
checked={modules.includes(option.value)}
|
|
onChange={() => toggleFilter("module", option.value)}
|
|
/>
|
|
<span>{option.label}</span>
|
|
</label>
|
|
)}
|
|
{moduleOptions.length === 0 &&
|
|
<span className="search-filter-empty">No module filters available.</span>
|
|
}
|
|
</div>
|
|
</fieldset>
|
|
<fieldset className="search-filter-group">
|
|
<legend>Result types</legend>
|
|
<div className="search-filter-options">
|
|
{resourceTypeOptions.map((option) =>
|
|
<label key={option.value}>
|
|
<input
|
|
type="checkbox"
|
|
checked={resourceTypes.includes(option.value)}
|
|
onChange={() => toggleFilter("resource_type", option.value)}
|
|
/>
|
|
<span>{option.label}</span>
|
|
</label>
|
|
)}
|
|
{resourceTypeOptions.length === 0 &&
|
|
<span className="search-filter-empty">No result type filters available.</span>
|
|
}
|
|
</div>
|
|
</fieldset>
|
|
<div className="search-filter-popover-footer">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
disabled={activeFilterCount === 0}
|
|
onClick={clearFilters}>
|
|
Clear filters
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
|
<DocumentationHelpLink
|
|
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
|
|
label="Open search documentation"
|
|
/>
|
|
{activeFilterCount > 0 &&
|
|
<div className="search-active-filters" aria-label="Active search filters">
|
|
{modules.map((moduleId) =>
|
|
<button
|
|
type="button"
|
|
key={`module:${moduleId}`}
|
|
onClick={() => toggleFilter("module", moduleId)}
|
|
aria-label={`Remove ${moduleLabels.get(moduleId) ?? moduleId} filter`}>
|
|
<span>{moduleLabels.get(moduleId) ?? humanizeIdentifier(moduleId)}</span>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
{resourceTypes.map((resourceType) =>
|
|
<button
|
|
type="button"
|
|
key={`resource-type:${resourceType}`}
|
|
onClick={() => toggleFilter("resource_type", resourceType)}
|
|
aria-label={`Remove ${humanizeIdentifier(resourceType)} filter`}>
|
|
<span>{humanizeIdentifier(resourceType)}</span>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
}
|
|
</div>
|
|
<PageScrollViewport className="search-results-viewport">
|
|
{error &&
|
|
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
|
|
{error}
|
|
</DismissibleAlert>
|
|
}
|
|
{response?.diagnostics.map((diagnostic) =>
|
|
<DismissibleAlert
|
|
key={diagnostic.provider_id}
|
|
tone="warning"
|
|
compact>
|
|
{diagnostic.message}
|
|
</DismissibleAlert>
|
|
)}
|
|
{query && response && response.results.length === 0 &&
|
|
<div className="search-empty">No results for “{query}”.</div>
|
|
}
|
|
<div className="search-result-list">
|
|
{response?.results.map((result) =>
|
|
<button
|
|
type="button"
|
|
className="search-result"
|
|
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
|
|
onClick={() => navigate(result.url)}>
|
|
<span className="search-result-heading">
|
|
<strong>{result.title}</strong>
|
|
{result.external_reference?.canonical_url &&
|
|
<ExternalLink size={14} aria-label="External result" />
|
|
}
|
|
</span>
|
|
{result.summary && <span className="search-result-summary">{result.summary}</span>}
|
|
<span className="search-result-meta">
|
|
{result.breadcrumbs.length > 0 ?
|
|
result.breadcrumbs.join(" · ") :
|
|
`${result.module_id} · ${result.resource_type}`}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{response?.next_cursor &&
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
className="search-load-more"
|
|
disabled={loading}
|
|
onClick={() => loadResults(response.next_cursor ?? undefined)}>
|
|
<ChevronDown size={16} />
|
|
<span>Load more</span>
|
|
</Button>
|
|
}
|
|
</PageScrollViewport>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function humanizeIdentifier(value: string): string {
|
|
return value.
|
|
replace(/[._:-]+/g, " ").
|
|
replace(/\b\w/g, (character) => character.toUpperCase());
|
|
}
|