Files
govoplan-search/webui/src/components/GlobalSearch.tsx
T

702 lines
25 KiB
TypeScript

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<SearchContextsUiCapability>("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<string[]>([]);
const [resourceTypes, setResourceTypes] = useState<string[]>([]);
const [scope, setScope] = useState<SearchScope>(currentContext ? "context" : "global");
const [response, setResponse] = useState<SearchResponse | null>(null);
const [resourceCatalogue, setResourceCatalogue] = useState<SearchResourceType[]>([]);
const [open, setOpen] = useState(false);
const [layout, setLayout] = useState<SearchOverlayLayout | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null);
const sourceInputRef = useRef<HTMLInputElement>(null);
const overlayInputRef = useRef<HTMLInputElement>(null);
const filtersRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const requestSequenceRef = useRef(0);
const loadMoreControllerRef = useRef<AbortController | null>(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<string, string>();
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<HTMLElement>(`[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<HTMLInputElement>) {
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 (
<>
<div
ref={rootRef}
className={`global-search global-search-source${open ? " is-overlay-open" : ""}`}>
<Search size={16} aria-hidden="true" />
<input
ref={sourceInputRef}
type="search"
value={query}
readOnly
tabIndex={open ? -1 : 0}
placeholder={currentContext?.placeholder ?? "Search"}
aria-label="Global search"
aria-keyshortcuts="F3 Control+K Meta+K"
aria-expanded={open}
onFocus={handleSourceFocus}
onClick={openOverlay}
/>
{query &&
<button
type="button"
className="global-search-clear"
tabIndex={open ? -1 : 0}
aria-label="Clear search"
onClick={() => {
setQuery("");
setResponse(null);
}}>
<X size={14} />
</button>
}
</div>
<Dialog
open={open && Boolean(layout)}
title="Search"
onClose={closeOverlay}
showCloseButton={false}
portal
className="search-overlay-dialog"
backdropClassName="search-overlay-backdrop"
headerClassName="search-overlay-dialog-header"
bodyClassName="search-overlay-dialog-body"
panelStyle={layout
? {
top: layout.top,
left: layout.left,
width: layout.width,
maxHeight: layout.maxHeight
}
: undefined}>
{layout &&
<>
<div
className="search-overlay-input-position"
style={{
width: layout.inputWidth,
height: layout.inputHeight,
marginLeft: layout.inputOffset
}}>
<form className="global-search global-search-overlay-input" onSubmit={handleOverlaySubmit}>
<Search size={16} aria-hidden="true" />
<input
ref={overlayInputRef}
autoFocus
type="search"
value={query}
placeholder={
scope === "context" && currentContext?.placeholder
? currentContext.placeholder
: "Search"
}
aria-label="Global search"
aria-keyshortcuts="F3 Control+K Meta+K"
aria-expanded={true}
aria-controls="global-search-overlay-results"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleOverlayKeyDown}
/>
{query &&
<button
type="button"
className="global-search-clear"
aria-label="Clear search"
onClick={() => {
setQuery("");
setResponse(null);
setActiveIndex(-1);
overlayInputRef.current?.focus();
}}>
<X size={14} />
</button>
}
</form>
</div>
<section
className="search-overlay-results-panel"
style={{ height: layout.resultsHeight }}>
<div className="search-overlay-toolbar">
{currentContext &&
<SegmentedControl<SearchScope>
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);
}}
/>
}
<div className="search-filter-menu" ref={filtersRef}>
<Button
type="button"
variant="secondary"
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>
{scope === "global" &&
<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" />}
<IconButton
label="Close search"
icon={<X size={17} />}
variant="ghost"
className="search-overlay-close"
onClick={closeOverlay}
/>
{activeFilterCount > 0 &&
<div className="search-active-filters" aria-label="Active search filters">
{scope === "global" && 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
id="global-search-overlay-results"
ref={resultsRef}
className="search-overlay-results-viewport"
role="listbox"
aria-label="Search results">
{error &&
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
{error}
</DismissibleAlert>
}
{response?.diagnostics.map((diagnostic) =>
<DismissibleAlert
key={diagnostic.provider_id}
tone="warning"
compact>
{diagnostic.message}
</DismissibleAlert>
)}
{query.trim().length < MIN_QUERY_LENGTH &&
<div className="search-empty search-overlay-empty">
Type at least {MIN_QUERY_LENGTH} characters to search.
</div>
}
{query.trim().length >= MIN_QUERY_LENGTH && !loading && response?.results.length === 0 &&
<div className="search-empty search-overlay-empty">No results for {query.trim()}.</div>
}
<div className="search-result-list">
{response?.results.map((result, index) =>
<button
type="button"
role="option"
aria-selected={activeIndex === index}
data-search-result-index={index}
className={`search-result${activeIndex === index ? " active" : ""}`}
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
onMouseMove={() => setActiveIndex(index)}
onClick={() => openResult(result)}>
<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(" · ")
: `${moduleLabels.get(result.module_id) ?? humanizeIdentifier(result.module_id)} · ${humanizeIdentifier(result.resource_type)}`}
</span>
</button>
)}
</div>
{response?.next_cursor &&
<Button
type="button"
variant="secondary"
className="search-load-more"
disabled={loading}
onClick={() => void loadMore()}>
<ChevronDown size={16} />
<span>Load more</span>
</Button>
}
</PageScrollViewport>
</section>
</>
}
</Dialog>
</>
);
}
function humanizeIdentifier(value: string): string {
return value
.replace(/[._:-]+/g, " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}