Release govoplan-search v0.1.20: centralize filtering and refresh behavior
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-09-08 01:32:53 +02:00
parent 6c5545af66
commit 6ad7c6e0ef
20 changed files with 530 additions and 788 deletions
+8
View File
@@ -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");
+102
View File
@@ -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");