diff --git a/src/govoplan_search/backend/router.py b/src/govoplan_search/backend/router.py index d428048..bf347bf 100644 --- a/src/govoplan_search/backend/router.py +++ b/src/govoplan_search/backend/router.py @@ -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), ) diff --git a/src/govoplan_search/backend/schemas.py b/src/govoplan_search/backend/schemas.py index 37db624..b189e94 100644 --- a/src/govoplan_search/backend/schemas.py +++ b/src/govoplan_search/backend/schemas.py @@ -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", diff --git a/tests/test_search_service.py b/tests/test_search_service.py index 7403882..1b1b6cf 100644 --- a/tests/test_search_service.py +++ b/tests/test_search_service.py @@ -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", diff --git a/webui/src/api/search.ts b/webui/src/api/search.ts index 61e78ad..88fb065 100644 --- a/webui/src/api/search.ts +++ b/webui/src/api/search.ts @@ -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 { + return apiFetch( + settings, + "/api/v1/search/providers", + { signal } + ); +} diff --git a/webui/src/components/GlobalSearch.tsx b/webui/src/components/GlobalSearch.tsx index 51d5284..1e05bc3 100644 --- a/webui/src/components/GlobalSearch.tsx +++ b/webui/src/components/GlobalSearch.tsx @@ -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("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([]); 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)} diff --git a/webui/src/features/search/SearchPage.tsx b/webui/src/features/search/SearchPage.tsx index 0fe69dd..c5797fb 100644 --- a/webui/src/features/search/SearchPage.tsx +++ b/webui/src/features/search/SearchPage.tsx @@ -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(null); + const [resourceCatalogue, setResourceCatalogue] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const filtersRef = useRef(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(); + 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 (
@@ -115,7 +218,106 @@ export default function SearchPage({ settings }: PlatformRouteContext) { /> +
+ + {filtersOpen && +
+
+ Filter results + +
+
+ Modules +
+ {moduleOptions.map((option) => + + )} + {moduleOptions.length === 0 && + No module filters available. + } +
+
+
+ Result types +
+ {resourceTypeOptions.map((option) => + + )} + {resourceTypeOptions.length === 0 && + No result type filters available. + } +
+
+
+ +
+
+ } +
{loading && } + {activeFilterCount > 0 && +
+ {modules.map((moduleId) => + + )} + {resourceTypes.map((resourceType) => + + )} +
+ }
{error && @@ -170,3 +372,9 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
); } + +function humanizeIdentifier(value: string): string { + return value. + replace(/[._:-]+/g, " "). + replace(/\b\w/g, (character) => character.toUpperCase()); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index ea5507d..4505ceb 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -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 = { diff --git a/webui/src/styles/search.css b/webui/src/styles/search.css index d3d95ee..4588b41 100644 --- a/webui/src/styles/search.css +++ b/webui/src/styles/search.css @@ -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,