Release govoplan-search v0.1.20: centralize filtering and refresh behavior
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/search-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,11 +14,12 @@
|
||||
"./styles/search.css": "./src/styles/search.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:search-filters": "node --test scripts/test-search-filters.mjs",
|
||||
"test:search-overlay": "node scripts/test-search-overlay-structure.mjs",
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -5,13 +5,21 @@ const page = fs.readFileSync("src/features/search/SearchPage.tsx", "utf8");
|
||||
const overlay = fs.readFileSync("src/components/GlobalSearch.tsx", "utf8");
|
||||
const admin = fs.readFileSync("src/features/search/SearchAdminPanel.tsx", "utf8");
|
||||
const styles = fs.readFileSync("src/styles/search.css", "utf8");
|
||||
const filters = fs.readFileSync("src/components/SearchFilters.tsx", "utf8");
|
||||
const lifecycle = fs.readFileSync("src/components/useSearchResults.ts", "utf8");
|
||||
|
||||
for (const source of [page, overlay]) {
|
||||
assert.ok(source.includes("DocumentationHelpLink"), "Search surfaces expose configured-system help");
|
||||
assert.ok(source.includes("DismissibleAlert"), "Search failures use the shared alert contract");
|
||||
assert.ok(!source.includes("window.alert("), "Search must not use browser alerts");
|
||||
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(source), "Search uses semantic interactive elements");
|
||||
assert.ok(source.includes("<SearchFilters"), "Search route and overlay share one filter composition");
|
||||
assert.ok(source.includes("useSearchResults"), "Search route and overlay share one request lifecycle");
|
||||
assert.ok(!source.includes("search-filter-popover"), "Search surfaces must not duplicate dropdown implementations");
|
||||
}
|
||||
assert.ok(filters.includes("MultiSelectFilter"), "Search facets reuse Core's multi-selection dropdown");
|
||||
assert.ok(lifecycle.includes("currentIdentity.current === identity"), "Late responses are gated by the current request identity");
|
||||
assert.ok(lifecycle.includes("controllerRef.current?.abort()"), "Cursor and initial reads are cancelled together");
|
||||
|
||||
assert.ok(page.includes("PageScrollViewport"), "The full search route owns bounded result scrolling");
|
||||
assert.ok(overlay.includes("<Dialog"), "The title-bar search uses the shared focus-contained dialog");
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
|
||||
const requireCore = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||
const ts = requireCore("typescript");
|
||||
|
||||
function loadTypeScript(relativePath, dependencies = {}) {
|
||||
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
const { outputText } = ts.transpileModule(source, { compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS,
|
||||
} });
|
||||
const module = { exports: {} };
|
||||
new Function("require", "module", "exports", outputText)((id) => {
|
||||
if (id in dependencies) return dependencies[id];
|
||||
throw new Error(`Unexpected runtime dependency: ${id}`);
|
||||
}, module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
const { readSearchFilter, writeSearchFilter, effectiveSearchFilters, searchFilterOptions } = loadTypeScript("../src/components/searchFilters.ts");
|
||||
|
||||
test("legacy missing filters remain all; explicit none wins over repeated values", () => {
|
||||
assert.equal(readSearchFilter(new URLSearchParams("q=permit"), "module"), null);
|
||||
assert.deepEqual(readSearchFilter(new URLSearchParams("module=files&module=cases&module=files"), "module"), ["cases", "files"]);
|
||||
assert.deepEqual(readSearchFilter(new URLSearchParams("module=cases&module_none=1"), "module"), []);
|
||||
assert.deepEqual(readSearchFilter(new URLSearchParams("resource_type=case&resource_type_none=1"), "resource_type"), []);
|
||||
});
|
||||
|
||||
test("filter URL roundtrips preserve query, context, language and the other filter", () => {
|
||||
const original = new URLSearchParams("q=permit&context=cases.current&language=german&resource_type=case&other=keep");
|
||||
const selected = writeSearchFilter(original, "module", ["files", "cases", "files"]);
|
||||
assert.deepEqual(selected.getAll("module"), ["cases", "files"]);
|
||||
assert.equal(original.has("module"), false);
|
||||
const none = writeSearchFilter(selected, "module", []);
|
||||
assert.equal(none.get("module_none"), "1");
|
||||
assert.equal(none.has("module"), false);
|
||||
assert.deepEqual(readSearchFilter(none, "module"), []);
|
||||
const all = writeSearchFilter(none, "module", null);
|
||||
assert.equal(all.toString(), original.toString());
|
||||
assert.equal(readSearchFilter(all, "module"), null);
|
||||
});
|
||||
|
||||
test("global all, none and subset selections keep distinct effective requests", () => {
|
||||
assert.deepEqual(effectiveSearchFilters(null, null), { modules: undefined, resourceTypes: undefined, matchNone: false });
|
||||
assert.equal(effectiveSearchFilters([], null).matchNone, true);
|
||||
assert.equal(effectiveSearchFilters(null, []).matchNone, true);
|
||||
assert.deepEqual(effectiveSearchFilters(["cases", "files"], ["case", "file"]), {
|
||||
modules: ["cases", "files"], resourceTypes: ["case", "file"], matchNone: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("context bounds never broaden an empty resource-type intersection", () => {
|
||||
const context = { id: "files.context", moduleId: "files", label: "Files", pathPrefixes: ["/files"], resourceTypes: ["file", "folder"] };
|
||||
assert.deepEqual(effectiveSearchFilters(null, null, context), { modules: ["files"], resourceTypes: ["file", "folder"], matchNone: false });
|
||||
assert.deepEqual(effectiveSearchFilters(["cases"], ["case", "file"], context), { modules: ["files"], resourceTypes: ["file"], matchNone: false });
|
||||
assert.deepEqual(effectiveSearchFilters(null, ["case"], context), { modules: ["files"], resourceTypes: [], matchNone: true });
|
||||
assert.equal(effectiveSearchFilters(null, [], context).matchNone, true);
|
||||
// The hidden global module selection must not override the current context.
|
||||
assert.equal(effectiveSearchFilters([], null, context).matchNone, false);
|
||||
assert.equal(effectiveSearchFilters(null, null, { ...context, resourceTypes: [] }).matchNone, false);
|
||||
});
|
||||
|
||||
const catalogue = [
|
||||
{ provider_id: "files.source", module_id: "files", resource_type: "file", label: "Files", order: 1 },
|
||||
{ provider_id: "files.source", module_id: "files", resource_type: "folder", label: "Folders", order: 2 },
|
||||
{ provider_id: "cases.source", module_id: "cases", resource_type: "case", label: "Cases", order: 3 },
|
||||
];
|
||||
const observed = [{ module_id: "mail", resource_type: "message" }];
|
||||
const labels = new Map([["files", "Dateien"], ["cases", "Fälle"]]);
|
||||
|
||||
test("filter choices combine catalogue, observed values and removable stale selections", () => {
|
||||
const all = searchFilterOptions(catalogue, observed, labels, ["files", "retired-source"], ["unknown-type"]);
|
||||
assert.deepEqual(new Set(all.modules.map((item) => item.value)), new Set(["files", "cases", "mail", "retired-source"]));
|
||||
assert.equal(all.modules.find((item) => item.value === "files").label, "Dateien");
|
||||
assert.deepEqual(new Set(all.resourceTypes.map((item) => item.value)), new Set(["file", "folder", "unknown-type"]));
|
||||
assert.deepEqual(searchFilterOptions(catalogue, observed, labels, [], null).resourceTypes, []);
|
||||
const context = { id: "files.context", moduleId: "files", label: "Files", pathPrefixes: ["/files"], resourceTypes: ["file"] };
|
||||
assert.deepEqual(searchFilterOptions(catalogue, observed, labels, null, null, context).resourceTypes, [{ value: "file", label: "Files" }]);
|
||||
});
|
||||
|
||||
test("explicit none is a frontend-only empty response; legacy API empty arrays still request all", async () => {
|
||||
const paths = [], calls = [];
|
||||
const response = { query: "permit", results: [], diagnostics: [], next_cursor: null, has_more: false };
|
||||
const { search } = loadTypeScript("../src/api/search.ts", { "@govoplan/core-webui": {
|
||||
apiPath: (path, parameters) => { paths.push({ path, parameters }); return path; },
|
||||
apiFetch: async (...args) => { calls.push(args); return response; },
|
||||
} });
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
assert.deepEqual(await search(settings, { query: "permit", matchNone: true, modules: [] }), response);
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(paths.length, 0);
|
||||
await search(settings, { query: "permit", modules: [], resourceTypes: [] });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(paths[0].parameters.module, []);
|
||||
assert.deepEqual(paths[0].parameters.resource_type, []);
|
||||
assert.equal("matchNone" in paths[0].parameters, false);
|
||||
await search(settings, { query: "permit", modules: ["cases", "files"], resourceTypes: ["case", "file"] });
|
||||
assert.deepEqual(paths[1].parameters.module, ["cases", "files"]);
|
||||
assert.deepEqual(paths[1].parameters.resource_type, ["case", "file"]);
|
||||
});
|
||||
@@ -8,6 +8,8 @@ function assert(condition, message) {
|
||||
const source = readFileSync("src/components/GlobalSearch.tsx", "utf8");
|
||||
const layoutSource = readFileSync("src/components/searchOverlayLayout.ts", "utf8");
|
||||
const styles = readFileSync("src/styles/search.css", "utf8");
|
||||
const requestSource = readFileSync("src/components/useSearchResults.ts", "utf8");
|
||||
const filterSource = readFileSync("src/components/SearchFilters.tsx", "utf8");
|
||||
|
||||
assert(source.includes("titlebar-icon-link titlebar-search-button"), "Search uses the shared titlebar icon-button appearance");
|
||||
assert(source.includes("onClick={openOverlay}"), "clicking the titlebar Search command opens Search");
|
||||
@@ -15,7 +17,8 @@ assert(!source.includes("sourceInputRef"), "the titlebar no longer reserves a pe
|
||||
assert(source.includes("<Dialog"), "Search uses the shared Dialog component");
|
||||
assert(source.includes("portal"), "the Search dialog portals above the complete shell");
|
||||
assert(source.includes("calculateSearchOverlayLayout"), "the overlay position is derived from the titlebar command");
|
||||
assert(source.includes("listSearchProviders"), "the overlay loads the complete filter catalogue");
|
||||
assert(source.includes("useSearchCatalogue") && requestSource.includes("listSearchProviders"), "the overlay loads the complete filter catalogue through the shared Search hook");
|
||||
assert(source.includes("<SearchFilters") && filterSource.includes("MultiSelectFilter"), "the overlay uses Search-owned facets with Core-owned dropdown behavior");
|
||||
assert(source.includes("limit: 50"), "the overlay requests full result windows rather than titlebar suggestions");
|
||||
assert(source.includes("response?.next_cursor"), "the overlay retains cursor pagination");
|
||||
assert(source.includes('usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts")'), "contextual Search contributions are consumed");
|
||||
|
||||
@@ -102,6 +102,8 @@ export type SearchModuleReconcile = {
|
||||
|
||||
export type SearchRequest = {
|
||||
query: string;
|
||||
/** UI-only explicit empty selection; never serialized to the Search API. */
|
||||
matchNone?: boolean;
|
||||
modules?: string[];
|
||||
resourceTypes?: string[];
|
||||
contextKind?: "global" | "module" | "resource";
|
||||
@@ -117,6 +119,7 @@ export function search(
|
||||
request: SearchRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<SearchResponse> {
|
||||
if (request.matchNone) return Promise.resolve({ query: request.query, results: [], diagnostics: [], next_cursor: null, has_more: false });
|
||||
return apiFetch<SearchResponse>(
|
||||
settings,
|
||||
apiPath("/api/v1/search", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
|
||||
import { ChevronDown, ExternalLink, Search, X } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { useLocation } from "react-router";
|
||||
import { ActionToolbar,
|
||||
Button,
|
||||
CountBadge,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
@@ -26,13 +25,10 @@ import { ActionToolbar,
|
||||
type GlobalSearchProps,
|
||||
type SearchContextsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
listSearchProviders,
|
||||
search,
|
||||
type SearchResourceType,
|
||||
type SearchResponse,
|
||||
type SearchResult
|
||||
} from "../api/search";
|
||||
import type { SearchResult } from "../api/search";
|
||||
import SearchFilters from "./SearchFilters";
|
||||
import { effectiveSearchFilters, humanizeIdentifier, searchAuthorityKey, searchFilterOptions, type SearchFilterSelection } from "./searchFilters";
|
||||
import { useSearchCatalogue, useSearchResults } from "./useSearchResults";
|
||||
import {
|
||||
calculateSearchOverlayLayout,
|
||||
selectSearchContext,
|
||||
@@ -43,7 +39,7 @@ import {
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
type SearchScope = "global" | "context";
|
||||
|
||||
export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
export default function GlobalSearch({ settings, auth }: GlobalSearchProps) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const platformModules = usePlatformModules();
|
||||
@@ -62,75 +58,27 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [modules, setModules] = useState<string[]>([]);
|
||||
const [resourceTypes, setResourceTypes] = useState<string[]>([]);
|
||||
const [modules, setModules] = useState<SearchFilterSelection>(null);
|
||||
const [resourceTypes, setResourceTypes] = useState<SearchFilterSelection>(null);
|
||||
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<HTMLButtonElement>(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 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 context = scope === "context" ? currentContext : null;
|
||||
const authority = searchAuthorityKey(auth);
|
||||
const resourceCatalogue = useSearchCatalogue(settings, open, authority);
|
||||
const { response, loading, error, loadMore, requestIdentity } = useSearchResults(settings, {
|
||||
query: query.trim(), ...effectiveSearchFilters(modules, resourceTypes, context),
|
||||
contextKind: context ? "module" : "global", contextId: context?.id, limit: 50,
|
||||
}, open && query.trim().length >= MIN_QUERY_LENGTH, 180, authority);
|
||||
const options = searchFilterOptions(resourceCatalogue, response?.results ?? [], moduleLabels, modules, resourceTypes, context);
|
||||
|
||||
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]);
|
||||
useEffect(() => { setActiveIndex(-1); }, [requestIdentity]);
|
||||
|
||||
const measureOverlay = useCallback(() => {
|
||||
const rect = rootRef.current?.getBoundingClientRect();
|
||||
@@ -150,9 +98,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
}, []);
|
||||
|
||||
const closeOverlay = useCallback(() => {
|
||||
loadMoreControllerRef.current?.abort();
|
||||
setOpen(false);
|
||||
setFiltersOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}, []);
|
||||
|
||||
@@ -178,23 +124,13 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
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);
|
||||
if (rootRef.current) observer?.observe(rootRef.current);
|
||||
window.addEventListener("resize", measureOverlay);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
@@ -202,86 +138,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
};
|
||||
}, [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
|
||||
@@ -294,25 +150,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
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([]);
|
||||
if (!context) setModules(null);
|
||||
setResourceTypes(null);
|
||||
}
|
||||
|
||||
function handleOverlaySubmit(event: FormEvent) {
|
||||
@@ -334,55 +174,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
@@ -455,7 +246,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setResponse(null);
|
||||
setActiveIndex(-1);
|
||||
overlayInputRef.current?.focus();
|
||||
}}>
|
||||
@@ -486,83 +276,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<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 &&
|
||||
<CountBadge size="compact" aria-label={`${activeFilterCount} active filters`}>
|
||||
{activeFilterCount}
|
||||
</CountBadge>
|
||||
}
|
||||
</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>
|
||||
<SearchFilters modules={modules} resourceTypes={resourceTypes} options={options}
|
||||
hideModules={Boolean(context)} onModulesChange={setModules}
|
||||
onResourceTypesChange={setResourceTypes} onClear={clearFilters} />
|
||||
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
||||
<DocumentationHelpLink
|
||||
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
|
||||
@@ -575,30 +291,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
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>
|
||||
}
|
||||
</ActionToolbar>
|
||||
|
||||
<PageScrollViewport
|
||||
@@ -608,7 +300,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
role="listbox"
|
||||
aria-label="Search results">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
@@ -673,9 +365,3 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeIdentifier(value: string): string {
|
||||
return value
|
||||
.replace(/[._:-]+/g, " ")
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button, MultiSelectFilter, ToolbarGroup } from "@govoplan/core-webui";
|
||||
import { searchFilterOptions, type SearchFilterSelection } from "./searchFilters";
|
||||
|
||||
type Props = {
|
||||
modules: SearchFilterSelection;
|
||||
resourceTypes: SearchFilterSelection;
|
||||
options: ReturnType<typeof searchFilterOptions>;
|
||||
hideModules?: boolean;
|
||||
onModulesChange: (value: SearchFilterSelection) => void;
|
||||
onResourceTypesChange: (value: SearchFilterSelection) => void;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
/** Search owns the facets; Core owns dropdown, selection, focus, and layout. */
|
||||
export default function SearchFilters({ modules, resourceTypes, options, hideModules = false, onModulesChange, onResourceTypesChange, onClear }: Props) {
|
||||
const active = (!hideModules && modules !== null) || resourceTypes !== null;
|
||||
return <ToolbarGroup data-help-context-id="search.filters" data-help-module-id="search">
|
||||
{!hideModules && <MultiSelectFilter label="i18n:govoplan-search.filter_modules" options={options.modules} value={modules} onChange={onModulesChange} />}
|
||||
<MultiSelectFilter label="i18n:govoplan-search.filter_types" options={options.resourceTypes} value={resourceTypes} onChange={onResourceTypesChange} />
|
||||
{active && <Button type="button" variant="ghost" onClick={onClear}>i18n:govoplan-search.clear_filters</Button>}
|
||||
</ToolbarGroup>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AuthInfo, SearchContextContribution } from "@govoplan/core-webui";
|
||||
import type { SearchRequest, SearchResourceType, SearchResult } from "../api/search";
|
||||
|
||||
/** Same selection contract as Core's list filter: null = all, [] = none. */
|
||||
export type SearchFilterSelection = string[] | null;
|
||||
export type SearchFilterName = "module" | "resource_type";
|
||||
|
||||
/** Cookie-backed tenant switches need not change the API settings or token. */
|
||||
export function searchAuthorityKey(auth: AuthInfo | null): string {
|
||||
return JSON.stringify([auth?.user.id, auth?.user.account_id, auth?.principal?.membership_id,
|
||||
auth?.active_tenant?.id ?? auth?.tenant.id, [...(auth?.scopes ?? [])].sort()]);
|
||||
}
|
||||
|
||||
export function readSearchFilter(params: URLSearchParams, name: SearchFilterName): SearchFilterSelection {
|
||||
if (params.get(`${name}_none`) === "1") return [];
|
||||
const values = [...new Set(params.getAll(name).filter(Boolean))].sort();
|
||||
return values.length ? values : null;
|
||||
}
|
||||
|
||||
export function writeSearchFilter(params: URLSearchParams, name: SearchFilterName, value: SearchFilterSelection): URLSearchParams {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete(name);
|
||||
next.delete(`${name}_none`);
|
||||
if (value?.length === 0) next.set(`${name}_none`, "1");
|
||||
else for (const item of [...new Set(value ?? [])].sort()) next.append(name, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function effectiveSearchFilters(
|
||||
modules: SearchFilterSelection,
|
||||
resourceTypes: SearchFilterSelection,
|
||||
context?: SearchContextContribution | null
|
||||
): Pick<SearchRequest, "modules" | "resourceTypes" | "matchNone"> {
|
||||
const effectiveModules = context ? [context.moduleId] : modules;
|
||||
const allowedTypes = context?.resourceTypes;
|
||||
const effectiveTypes = allowedTypes?.length
|
||||
? resourceTypes === null ? allowedTypes : resourceTypes.filter((value) => allowedTypes.includes(value))
|
||||
: resourceTypes;
|
||||
return {
|
||||
modules: effectiveModules ?? undefined,
|
||||
resourceTypes: effectiveTypes ?? undefined,
|
||||
// An empty context intersection must never turn into an unrestricted API query.
|
||||
matchNone: effectiveModules?.length === 0 || effectiveTypes?.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function humanizeIdentifier(value: string): string {
|
||||
return value.replace(/[._:-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
export function searchFilterOptions(
|
||||
catalogue: SearchResourceType[],
|
||||
results: SearchResult[],
|
||||
moduleLabels: ReadonlyMap<string, string>,
|
||||
modules: SearchFilterSelection,
|
||||
resourceTypes: SearchFilterSelection,
|
||||
context?: SearchContextContribution | null
|
||||
) {
|
||||
const moduleIds = new Set([...catalogue.map((item) => item.module_id), ...results.map((item) => item.module_id), ...(modules ?? [])]);
|
||||
const effectiveModules = context ? [context.moduleId] : modules;
|
||||
const labels = new Map<string, string>();
|
||||
for (const item of [...catalogue, ...results]) {
|
||||
if (effectiveModules !== null && !effectiveModules.includes(item.module_id)) continue;
|
||||
if (context?.resourceTypes?.length && !context.resourceTypes.includes(item.resource_type)) continue;
|
||||
if (!labels.has(item.resource_type)) labels.set(item.resource_type, "label" in item ? item.label : humanizeIdentifier(item.resource_type));
|
||||
}
|
||||
// Keep stale or unknown selections removable; do not silently broaden the query.
|
||||
for (const value of resourceTypes ?? []) if (!labels.has(value)) labels.set(value, humanizeIdentifier(value));
|
||||
const byLabel = (left: { label: string }, right: { label: string }) => left.label.localeCompare(right.label);
|
||||
return {
|
||||
modules: [...moduleIds].map((value) => ({ value, label: moduleLabels.get(value) ?? humanizeIdentifier(value) })).sort(byLabel),
|
||||
resourceTypes: [...labels].map(([value, label]) => ({ value, label })).sort(byLabel),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { listSearchProviders, search, type SearchRequest, type SearchResourceType, type SearchResponse } from "../api/search";
|
||||
|
||||
export function useSearchCatalogue(settings: ApiSettings, enabled = true, authority = ""): SearchResourceType[] {
|
||||
const identity = useMemo(() => ({}), [settings, enabled, authority]);
|
||||
const [state, setState] = useState<{ identity: object; resources: SearchResourceType[] } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
setState(null);
|
||||
listSearchProviders(settings, controller.signal)
|
||||
.then((result) => { if (!controller.signal.aborted) setState({ identity, resources: result.resources ?? [] }); })
|
||||
.catch(() => { if (!controller.signal.aborted) setState(null); });
|
||||
return () => controller.abort();
|
||||
}, [settings, enabled, identity]);
|
||||
return enabled && state?.identity === identity ? state.resources : [];
|
||||
}
|
||||
|
||||
type ResultState = { identity: object; response: SearchResponse | null; loading: boolean; error: string };
|
||||
|
||||
/** One request lifecycle for route and overlay, including cursor cancellation. */
|
||||
export function useSearchResults(settings: ApiSettings, request: SearchRequest, enabled = true, delay = 0, authority = "") {
|
||||
const requestKey = JSON.stringify(request);
|
||||
const identity = useMemo(() => ({ settings, request: JSON.parse(requestKey) as SearchRequest, enabled, delay }), [settings, requestKey, enabled, delay, authority]);
|
||||
const currentIdentity = useRef(identity);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const [state, setState] = useState<ResultState | null>(null);
|
||||
// Gate rendering as well as promise completion: stale results cannot be opened
|
||||
// with Enter during the debounce interval or while an effect is being cleaned up.
|
||||
currentIdentity.current = identity;
|
||||
const visible = state?.identity === identity ? state : null;
|
||||
|
||||
useEffect(() => {
|
||||
controllerRef.current?.abort();
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setState({ identity, response: null, loading: true, error: "" });
|
||||
const timer = window.setTimeout(() => {
|
||||
search(settings, identity.request, controller.signal)
|
||||
.then((response) => {
|
||||
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response, loading: false, error: "" });
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response: null, loading: false, error: reason instanceof Error ? reason.message : "Search failed." });
|
||||
});
|
||||
}, identity.request.matchNone ? 0 : delay);
|
||||
return () => { window.clearTimeout(timer); controller.abort(); controllerRef.current?.abort(); };
|
||||
}, [identity, settings, enabled, delay]);
|
||||
|
||||
async function loadMore() {
|
||||
const cursor = visible?.response?.next_cursor;
|
||||
if (!cursor || visible.loading || currentIdentity.current !== identity) return;
|
||||
// Also guards a second click before React has rendered the loading state.
|
||||
if (controllerRef.current?.signal.aborted === false && controllerRef.current !== null) controllerRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setState((current) => current?.identity === identity ? { ...current, loading: true, error: "" } : current);
|
||||
try {
|
||||
const next = await search(settings, { ...identity.request, cursor }, controller.signal);
|
||||
if (controller.signal.aborted || currentIdentity.current !== identity) return;
|
||||
setState((current) => current?.identity === identity && current.response ? {
|
||||
identity, loading: false, error: "", response: {
|
||||
...next,
|
||||
results: [...current.response.results, ...next.results],
|
||||
diagnostics: [...current.response.diagnostics, ...next.diagnostics.filter((item) => !current.response!.diagnostics.some((previous) => previous.provider_id === item.provider_id))],
|
||||
},
|
||||
} : current);
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted && currentIdentity.current === identity) setState((current) => current?.identity === identity ? { ...current, loading: false, error: reason instanceof Error ? reason.message : "Search failed." } : current);
|
||||
}
|
||||
}
|
||||
|
||||
return { response: visible?.response ?? null, error: visible?.error ?? "", loading: enabled && (visible?.loading ?? true), loadMore, requestIdentity: identity };
|
||||
}
|
||||
@@ -1,185 +1,32 @@
|
||||
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import { ChevronDown, ExternalLink, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { ActionToolbar,
|
||||
Button,
|
||||
CountBadge,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
useGuardedNavigate,
|
||||
usePlatformModules,
|
||||
type PlatformRouteContext
|
||||
import { ActionToolbar, Button, DocumentationHelpLink, DismissibleAlert, LoadingIndicator,
|
||||
PageScrollViewport, useGuardedNavigate, usePlatformModules, type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
listSearchProviders,
|
||||
search,
|
||||
type SearchResourceType,
|
||||
type SearchResponse
|
||||
} from "../../api/search";
|
||||
import SearchFilters from "../../components/SearchFilters";
|
||||
import { effectiveSearchFilters, readSearchFilter, searchAuthorityKey, searchFilterOptions, writeSearchFilter } from "../../components/searchFilters";
|
||||
import { useSearchCatalogue, useSearchResults } from "../../components/useSearchResults";
|
||||
|
||||
|
||||
export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
export default function SearchPage({ settings, auth }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const platformModules = usePlatformModules();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const query = params.get("q") ?? "";
|
||||
const moduleKey = params.getAll("module").join("\u001f");
|
||||
const resourceTypeKey = params.getAll("resource_type").join("\u001f");
|
||||
const modules = readSearchFilter(params, "module");
|
||||
const resourceTypes = readSearchFilter(params, "resource_type");
|
||||
const contextId = params.get("context") ?? undefined;
|
||||
const modules = useMemo(
|
||||
() => moduleKey ? moduleKey.split("\u001f") : [],
|
||||
[moduleKey]
|
||||
);
|
||||
const resourceTypes = useMemo(
|
||||
() => resourceTypeKey ? resourceTypeKey.split("\u001f") : [],
|
||||
[resourceTypeKey]
|
||||
);
|
||||
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]
|
||||
);
|
||||
const authority = searchAuthorityKey(auth);
|
||||
const resourceCatalogue = useSearchCatalogue(settings, true, authority);
|
||||
const { response, loading, error, loadMore } = useSearchResults(settings, {
|
||||
query, ...effectiveSearchFilters(modules, resourceTypes),
|
||||
contextKind: modules?.length ? "module" : "global", contextId, limit: 50,
|
||||
}, Boolean(query.trim()), 0, authority);
|
||||
const moduleLabels = useMemo(() => new Map(platformModules.map((module) => [module.id, module.label])), [platformModules]);
|
||||
const options = searchFilterOptions(resourceCatalogue, response?.results ?? [], moduleLabels, modules, resourceTypes);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
setError("");
|
||||
search(
|
||||
settings,
|
||||
{
|
||||
query,
|
||||
modules,
|
||||
resourceTypes,
|
||||
contextKind: modules.length ? "module" : "global",
|
||||
contextId,
|
||||
limit: 50,
|
||||
cursor
|
||||
},
|
||||
controller.signal
|
||||
).
|
||||
then((next) => {
|
||||
setResponse((current) =>
|
||||
cursor && current ?
|
||||
{
|
||||
...next,
|
||||
results: [...current.results, ...next.results],
|
||||
diagnostics: [
|
||||
...current.diagnostics,
|
||||
...next.diagnostics.filter((item) =>
|
||||
!current.diagnostics.some(
|
||||
(currentItem) => currentItem.provider_id === item.provider_id
|
||||
)
|
||||
)
|
||||
]
|
||||
} :
|
||||
next
|
||||
);
|
||||
}).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Search failed.");
|
||||
}
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return controller;
|
||||
}, [contextId, modules, query, resourceTypes, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResponse(null);
|
||||
return;
|
||||
}
|
||||
setResponse(null);
|
||||
const controller = loadResults();
|
||||
return () => controller.abort();
|
||||
}, [loadResults, requestKey]);
|
||||
useEffect(() => { setDraft(query); }, [query]);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -189,23 +36,8 @@ 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);
|
||||
setParams(writeSearchFilter(writeSearchFilter(params, "module", null), "resource_type", null));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -222,113 +54,19 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
/>
|
||||
<Button type="submit" variant="primary">Search</Button>
|
||||
</form>
|
||||
<div className="search-filter-menu" ref={filtersRef}>
|
||||
<Button
|
||||
type="button"
|
||||
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 &&
|
||||
<CountBadge size="compact" aria-label={`${activeFilterCount} active filters`}>
|
||||
{activeFilterCount}
|
||||
</CountBadge>
|
||||
}
|
||||
</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>
|
||||
<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>
|
||||
<SearchFilters modules={modules} resourceTypes={resourceTypes} options={options}
|
||||
onModulesChange={(value) => setParams(writeSearchFilter(params, "module", value))}
|
||||
onResourceTypesChange={(value) => setParams(writeSearchFilter(params, "resource_type", value))}
|
||||
onClear={clearFilters} />
|
||||
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
||||
<DocumentationHelpLink
|
||||
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
|
||||
label="Open search documentation"
|
||||
/>
|
||||
{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>
|
||||
}
|
||||
</ActionToolbar>
|
||||
<PageScrollViewport className="search-results-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
@@ -371,7 +109,7 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
variant="secondary"
|
||||
className="search-load-more"
|
||||
disabled={loading}
|
||||
onClick={() => loadResults(response.next_cursor ?? undefined)}>
|
||||
onClick={() => void loadMore()}>
|
||||
<ChevronDown size={16} />
|
||||
<span>Load more</span>
|
||||
</Button>
|
||||
@@ -380,9 +118,3 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeIdentifier(value: string): string {
|
||||
return value.
|
||||
replace(/[._:-]+/g, " ").
|
||||
replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const searchFilterTranslations: PlatformTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-search.filter_modules": "Modules",
|
||||
"i18n:govoplan-search.filter_types": "Result types",
|
||||
"i18n:govoplan-search.clear_filters": "Clear filters",
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-search.filter_modules": "Module",
|
||||
"i18n:govoplan-search.filter_types": "Ergebnistypen",
|
||||
"i18n:govoplan-search.clear_filters": "Filter zurücksetzen",
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SearchRuntimeUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import GlobalSearch from "./components/GlobalSearch";
|
||||
import { searchFilterTranslations } from "./i18n/searchFilterTranslations";
|
||||
import "./styles/search.css";
|
||||
|
||||
|
||||
@@ -36,6 +37,7 @@ export const searchModule: PlatformWebModule = {
|
||||
id: "search",
|
||||
label: "Search",
|
||||
version: "0.1.14",
|
||||
translations: searchFilterTranslations,
|
||||
optionalDependencies: [
|
||||
"access",
|
||||
"views",
|
||||
|
||||
@@ -139,10 +139,6 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-overlay-toolbar .search-active-filters {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.search-overlay-results-viewport {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
@@ -224,142 +220,6 @@
|
||||
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-popover {
|
||||
position: absolute;
|
||||
z-index: 400;
|
||||
top: calc(100% + 7px);
|
||||
left: 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: var(--radius-compact);
|
||||
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: var(--radius-sm);
|
||||
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: var(--radius-sm);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user