feat(search): add global titlebar search filters

This commit is contained in:
2026-07-29 22:01:18 +02:00
parent 6fcd9e0e65
commit bed704eb7a
8 changed files with 477 additions and 61 deletions
+211 -3
View File
@@ -1,18 +1,32 @@
import { ChevronDown, ExternalLink, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent
} from "react";
import { useSearchParams } from "react-router-dom";
import {
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
useGuardedNavigate,
usePlatformModules,
type PlatformRouteContext
} from "@govoplan/core-webui";
import { search, type SearchResponse } from "../../api/search";
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");
@@ -28,8 +42,11 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
);
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]
@@ -39,6 +56,73 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
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);
@@ -101,6 +185,25 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
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">
@@ -115,7 +218,106 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
/>
<button type="submit">Search</button>
</form>
<div className="search-filter-menu" ref={filtersRef}>
<button
type="button"
className={`btn 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>
<button
type="button"
className="btn btn-ghost icon-button"
aria-label="Close filters"
onClick={() => setFiltersOpen(false)}>
<X size={16} />
</button>
</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"
className="btn btn-ghost"
disabled={activeFilterCount === 0}
onClick={clearFilters}>
Clear filters
</button>
</div>
</div>
}
</div>
{loading && <LoadingIndicator size="sm" label="Searching" />}
{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 &&
@@ -170,3 +372,9 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
</main>
);
}
function humanizeIdentifier(value: string): string {
return value.
replace(/[._:-]+/g, " ").
replace(/\b\w/g, (character) => character.toUpperCase());
}