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
+36 -2
View File
@@ -18,6 +18,7 @@ from govoplan_search.backend.schemas import (
SearchProviderListResponse,
SearchProviderResponse,
SearchRebuildResponse,
SearchResourceTypeResponse,
SearchResponse,
SearchResultResponse,
)
@@ -68,6 +69,37 @@ def _service(registry: PlatformRegistry) -> SearchIndexService:
return capability
def _search_resource_catalogue(
registry: PlatformRegistry,
) -> list[SearchResourceTypeResponse]:
resources = {
(
descriptor.module_id,
descriptor.resource_type,
descriptor.provider_id,
): SearchResourceTypeResponse(
provider_id=descriptor.provider_id,
module_id=descriptor.module_id,
resource_type=descriptor.resource_type,
label=descriptor.label,
order=registered.registration.order,
)
for registered, source in registry.search_sources()
for descriptor in source.resource_types()
}
return [
resources[key]
for key in sorted(
resources,
key=lambda item: (
resources[item].order,
resources[item].label.casefold(),
item,
),
)
]
@router.get("", response_model=SearchResponse)
def api_search(
request: Request,
@@ -152,7 +184,8 @@ def api_search_providers(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SearchProviderListResponse:
_require_read(principal)
registrations = _registry(request).search_provider_registrations()
registry = _registry(request)
registrations = registry.search_provider_registrations()
return SearchProviderListResponse(
providers=[
SearchProviderResponse(
@@ -162,7 +195,8 @@ def api_search_providers(
order=item.registration.order,
)
for item in registrations
]
],
resources=_search_resource_catalogue(registry),
)
+10
View File
@@ -57,8 +57,17 @@ class SearchProviderResponse(BaseModel):
order: int
class SearchResourceTypeResponse(BaseModel):
provider_id: str
module_id: str
resource_type: str
label: str
order: int
class SearchProviderListResponse(BaseModel):
providers: list[SearchProviderResponse]
resources: list[SearchResourceTypeResponse] = Field(default_factory=list)
class SearchIndexStateResponse(BaseModel):
@@ -108,6 +117,7 @@ __all__ = [
"SearchProviderDiagnosticResponse",
"SearchProviderListResponse",
"SearchProviderResponse",
"SearchResourceTypeResponse",
"SearchChangeDispatchResponse",
"SearchDiagnosticsResponse",
"SearchIndexStateResponse",
+14 -1
View File
@@ -21,6 +21,7 @@ from govoplan_search.backend.db.models import (
SearchIndexDocument,
SearchIndexState,
)
from govoplan_search.backend.router import _search_resource_catalogue
from govoplan_search.backend.service import (
SearchIndexService,
aggregate_search_page,
@@ -44,7 +45,10 @@ class _Registry:
return (
(
SimpleNamespace(
registration=SimpleNamespace(id="cases.records")
registration=SimpleNamespace(
id="cases.records",
order=25,
)
),
self.source,
),
@@ -178,6 +182,15 @@ class SearchServiceTests(unittest.TestCase):
self.assertEqual(["case-1"], [result.resource_id for result in results])
def test_search_resource_catalogue_exposes_source_filters(self) -> None:
catalogue = _search_resource_catalogue(_Registry(_Source()))
self.assertEqual(1, len(catalogue))
self.assertEqual("cases", catalogue[0].module_id)
self.assertEqual("case", catalogue[0].resource_type)
self.assertEqual("Cases", catalogue[0].label)
self.assertEqual(25, catalogue[0].order)
def test_upsert_replaces_acl_tokens_and_delete_is_idempotent(self) -> None:
document = SearchDocument(
tenant_id="tenant-1",
+31
View File
@@ -43,6 +43,26 @@ export type SearchResponse = {
has_more: boolean;
};
export type SearchProvider = {
id: string;
module_id: string;
resource_types: string[];
order: number;
};
export type SearchResourceType = {
provider_id: string;
module_id: string;
resource_type: string;
label: string;
order: number;
};
export type SearchProviderListResponse = {
providers: SearchProvider[];
resources: SearchResourceType[];
};
export type SearchRequest = {
query: string;
modules?: string[];
@@ -76,3 +96,14 @@ export function search(
{ signal }
);
}
export function listSearchProviders(
settings: ApiSettings,
signal?: AbortSignal
): Promise<SearchProviderListResponse> {
return apiFetch<SearchProviderListResponse>(
settings,
"/api/v1/search/providers",
{ signal }
);
}
+10 -49
View File
@@ -1,54 +1,21 @@
import { Search, X } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent
} from "react";
import { useLocation } from "react-router-dom";
import {
useGuardedNavigate,
usePlatformUiCapabilities,
type GlobalSearchProps,
type SearchContextContribution,
type SearchContextsUiCapability
type GlobalSearchProps
} from "@govoplan/core-webui";
import { search, type SearchResult } from "../api/search";
const MIN_SUGGESTION_LENGTH = 2;
function currentContext(
pathname: string,
contexts: SearchContextContribution[]
): SearchContextContribution | null {
return [...contexts].
filter((context) =>
context.pathPrefixes.some((prefix) =>
pathname === prefix || pathname.startsWith(`${prefix}/`)
)
).
sort((left, right) => {
const leftLength = Math.max(...left.pathPrefixes.map((item) => item.length));
const rightLength = Math.max(...right.pathPrefixes.map((item) => item.length));
return rightLength - leftLength || (left.order ?? 100) - (right.order ?? 100);
})[0] ?? null;
}
export default function GlobalSearch({ settings }: GlobalSearchProps) {
const navigate = useGuardedNavigate();
const location = useLocation();
const contextCapabilities =
usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts");
const contexts = useMemo(
() => contextCapabilities.flatMap((capability) => capability.contexts),
[contextCapabilities]
);
const context = useMemo(
() => currentContext(location.pathname, contexts),
[contexts, location.pathname]
);
const [value, setValue] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [open, setOpen] = useState(false);
@@ -58,9 +25,12 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
useEffect(() => {
function focusSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
const commandSearch =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k";
if (event.key === "F3" || commandSearch) {
event.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
}
window.addEventListener("keydown", focusSearch);
@@ -90,10 +60,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
settings,
{
query,
modules: context ? [context.moduleId] : undefined,
resourceTypes: context?.resourceTypes,
contextKind: context ? "module" : "global",
contextId: context?.id,
contextKind: "global",
limit: 6
},
controller.signal
@@ -113,7 +80,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
window.clearTimeout(timer);
controller.abort();
};
}, [context, settings, value]);
}, [settings, value]);
function openResult(result: SearchResult) {
setOpen(false);
@@ -125,13 +92,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
const query = value.trim();
if (!query) return;
const params = new URLSearchParams({ q: query });
if (context) {
params.set("module", context.moduleId);
params.set("context", context.id);
for (const resourceType of context.resourceTypes ?? []) {
params.append("resource_type", resourceType);
}
}
setOpen(false);
navigate(`/search?${params.toString()}`);
}
@@ -172,8 +132,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
ref={inputRef}
type="search"
value={value}
placeholder={context?.placeholder ?? "Search"}
aria-label={context ? `Search ${context.label}` : "Search"}
placeholder="Search"
aria-label="Global search"
aria-keyshortcuts="F3 Control+K Meta+K"
aria-expanded={open}
aria-controls="global-search-results"
onFocus={() => setOpen(results.length > 0)}
+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());
}
+2 -1
View File
@@ -10,7 +10,8 @@ import "./styles/search.css";
const SearchPage = lazy(() => import("./features/search/SearchPage"));
const searchRuntime: SearchRuntimeUiCapability = {
GlobalSearch
GlobalSearch,
anyOf: ["search:result:read"]
};
export const searchModule: PlatformWebModule = {
+163 -5
View File
@@ -2,8 +2,8 @@
position: relative;
display: flex;
align-items: center;
width: min(360px, 28vw);
min-width: 190px;
width: 100%;
min-width: 0;
height: 34px;
box-sizing: border-box;
border: 1px solid var(--control-border);
@@ -57,8 +57,9 @@
position: absolute;
z-index: 300;
top: calc(100% + 7px);
right: 0;
left: 50%;
width: min(460px, 80vw);
transform: translateX(-50%);
overflow: hidden;
border: var(--border-line);
border-radius: 6px;
@@ -121,6 +122,7 @@
.search-page-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
flex: 0 0 auto;
border-bottom: var(--border-line);
@@ -131,8 +133,10 @@
.search-page-form {
display: flex;
align-items: center;
width: min(760px, 100%);
min-width: min(280px, 100%);
max-width: 760px;
height: 38px;
flex: 1 1 520px;
border: 1px solid var(--control-border);
border-radius: 4px;
background: var(--control-bg);
@@ -173,6 +177,157 @@
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
}
.search-filter-menu {
position: relative;
flex: 0 0 auto;
}
.search-filter-trigger {
min-height: 38px;
}
.search-filter-trigger.is-active {
border-color: var(--input-border-focus);
color: var(--text-strong);
}
.search-filter-count {
min-width: 19px;
height: 19px;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent);
padding: 0 5px;
font-size: 11px;
line-height: 1;
}
.search-filter-popover {
position: absolute;
z-index: 400;
top: calc(100% + 7px);
right: 0;
display: flex;
width: min(340px, calc(100vw - 40px));
max-height: min(520px, calc(100vh - 150px));
flex-direction: column;
overflow: hidden;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
box-shadow: var(--shadow-menu);
}
.search-filter-popover-header,
.search-filter-popover-footer {
display: flex;
align-items: center;
flex: 0 0 auto;
padding: 9px 12px;
}
.search-filter-popover-header {
justify-content: space-between;
border-bottom: var(--border-line);
}
.search-filter-popover-header .icon-button {
width: 30px;
height: 30px;
padding: 0;
}
.search-filter-popover-footer {
justify-content: flex-end;
border-top: var(--border-line);
}
.search-filter-group {
min-height: 0;
margin: 0;
border: 0;
border-bottom: var(--border-line);
padding: 11px 12px 12px;
}
.search-filter-group:last-of-type {
border-bottom: 0;
}
.search-filter-group legend {
color: var(--muted);
padding: 0;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.search-filter-options {
display: grid;
max-height: 150px;
gap: 2px;
overflow: auto;
margin-top: 7px;
}
.search-filter-options label {
display: flex;
align-items: center;
gap: 8px;
min-height: 31px;
border-radius: 4px;
cursor: pointer;
padding: 4px 7px;
color: var(--text);
}
.search-filter-options label:hover {
background: var(--sidebar-hover-bg);
color: var(--text-strong);
}
.search-filter-options input {
margin: 0;
accent-color: var(--accent);
}
.search-filter-empty {
color: var(--muted);
padding: 5px 7px;
font-size: 12px;
}
.search-active-filters {
display: flex;
flex: 1 0 100%;
flex-wrap: wrap;
gap: 7px;
}
.search-active-filters button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 27px;
border: 1px solid var(--control-border);
border-radius: 4px;
background: var(--control-bg);
color: var(--text);
cursor: pointer;
padding: 3px 8px;
font: inherit;
font-size: 12px;
}
.search-active-filters button:hover {
border-color: var(--input-border-focus);
background: var(--sidebar-hover-bg);
}
.search-results-viewport {
min-height: 0;
flex: 1;
@@ -261,8 +416,11 @@
.global-search:focus-within {
position: absolute;
right: 170px;
z-index: 2;
top: 50%;
left: 50%;
width: min(360px, calc(100vw - 190px));
transform: translate(-50%, -50%);
}
.global-search:not(:focus-within) input,