feat(search): add global titlebar search filters
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user