2 Commits
Author SHA1 Message Date
zemion 965244fc67 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:04:16 +02:00
zemion 6ad7c6e0ef Release govoplan-search v0.1.20: centralize filtering and refresh behavior
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:53 +02:00
21 changed files with 550 additions and 802 deletions
+60 -3
View File
@@ -7,8 +7,8 @@ authorization, and optional external engines remain provider capabilities.
| Surface | Task and archetype | Consequence and state contract |
| --- | --- | --- |
| Title-bar Search command and overlay | Global or context-sensitive focused lookup | The left-most titlebar command, F3, and Ctrl/Cmd+K open the same focus-contained Core dialog with a full-width query field. Arrow keys move through the listbox, Enter opens the selected result, Escape closes it and restores focus. |
| Overlay filters | Progressive-disclosure filter popover | Module and resource filters only narrow authorized results. Active filters stay visible and removable by keyboard. |
| `/search` | Full-page search/results fallback | Query and filters are URL-stable. Loading, empty, provider-partial, failed, and paged states remain inside the result region. |
| Overlay and page filters | Shared Search-owned `SearchFilters` with Core multi-select dropdowns | Modules and Result types use one implementation with visible selection summaries, Select all, Deselect all, and Clear filters. Multiple selected values are alternatives within a group; both groups must match. Tab, Space, and Escape work inside the Search dialog without closing it when only a dropdown should close. |
| `/search` | Full-page search/results fallback | Query and filters are URL-stable, including explicitly empty selections. Clearing filters preserves the query, context, and unrelated URL parameters. Loading, empty, provider-partial, failed, and paged states remain inside the result region. |
| Result entries | Permission-filtered list-detail destinations | A source module supplies the title, safe summary, breadcrumbs, and destination. Search does not infer or bypass source authorization. |
| Administration > Search index | Operator table and bounded recovery actions | Operators inspect source coverage and queue health, process pending changes, reconcile module activation, and advance one bounded rebuild page at a time. Quarantined work remains visible. |
@@ -19,10 +19,67 @@ without exposing source data. Search has no destructive action. The responsive
layout collapses filters and results at narrow widths, and result activation is
available without pointer interaction.
## Filter and request contract
Both Search surfaces use the same Search-owned selection, catalogue, and request
helpers; the visual dropdown and its selection body are shared Core components,
also used by DataGrid and Notifications. Search does not define a second popover
or a parallel removable-chip interface.
- All selected means unrestricted (`null` in the UI). Select all does not freeze
the list to the currently loaded catalogue; newly available sources remain
eligible. No options selected (`[]`) intentionally returns no results without
calling search providers. A subset uses OR within its group and AND across
the module and result-type groups.
- The existing API remains compatible: omitted or empty `module` and
`resource_type` arrays still mean unrestricted. The UI's `matchNone` shortcut
never becomes a backend request parameter. `/search` uses `module_none=1` and
`resource_type_none=1` to preserve explicit empty selections; these flags take
precedence over contradictory repeated values. Subsets keep the existing
repeated URL parameters.
- Contextual overlay search remains bounded to its module and declared result
types. An empty intersection is no match, not an instruction to search all
types. Clear filters preserves the active context and query. Global module
selections are retained when temporarily switching to contextual search.
- Choices combine source catalogue metadata, observed result values, and
previously selected values that are no longer in the catalogue, so stale
selections remain removable. Labels use source descriptors and localized
module names. This is not a faceted result-count endpoint: catalogue entries
do not establish object access or guarantee matching objects.
- Query, filter, context, account/tenant authority, or API identity changes abort obsolete requests and
discard their results and cursors. The shared lifecycle also guards delayed
and load-more responses, so a previous search cannot populate the new search
or remain keyboard-activatable during the overlay debounce. Cookie-backed
tenant switches invalidate results even when the API token stays unchanged.
## Bedienung auf Deutsch
Die Suchüberlagerung und die vollständige Suchseite verwenden dieselben
Auswahllisten für **Module** und **Ergebnistypen**. Mehrere gewählte Werte sind
Alternativen innerhalb einer Gruppe; zwischen den Gruppen gilt eine gemeinsame
Einschränkung. **Alle auswählen** hebt die Einschränkung auf. **Alle abwählen**
zeigt bewusst keine Ergebnisse und startet keine Anbietersuche. **Filter
zurücksetzen** behält Suchbegriff und aktuellen Kontext bei.
Im Modulkontext bleiben die Ergebnisse auf das Modul und seine vorgesehenen
Typen begrenzt; eine unvereinbare Auswahl erweitert die Suche niemals. Die
vollständige Suchseite erhält auch eine ausdrücklich leere Auswahl in ihrer
URL. Die Optionen stammen aus Quellenkatalog und beobachteten Ergebnissen,
nicht aus einer berechtigungsgefilterten Trefferzählung. Unabhängig von den
Filtern prüft die Quelle weiterhin die Berechtigung für jedes Ergebnis.
Mit Tab werden die Filtersteuerungen erreicht, die Leertaste schaltet Optionen
um. Escape schließt zuerst die geöffnete Auswahlliste und setzt den Fokus auf
ihre Schaltfläche zurück; erst ein weiteres Escape schließt die
Suchüberlagerung. Bei einem Such- oder Filterwechsel werden veraltete Anfragen
und Ergebnisse verworfen, auch beim Nachladen weiterer Ergebnisseiten.
Das gilt auch bei einem Konto- oder Mandantenwechsel mit unverändertem API-Token.
Verification:
- `npm run test:search-filters`
- `npm run test:search-overlay`
- `npm run test:interface-pattern`
- the Core TypeScript graph, structural localization audit, theme check, module
permutations, and full-product bundle budget
- Search backend and manifest tests
- Search backend compatibility, cursor binding, and bilingual manifest tests
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/search-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE"
],
"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",
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-search"
version = "0.1.19"
version = "0.1.20"
description = "Permission-aware global and contextual search for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.37"]
dependencies = ["govoplan-core>=0.1.45"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN search module."""
__version__ = "0.1.19"
__version__ = "0.1.20"
+35 -5
View File
@@ -40,7 +40,7 @@ from govoplan_search.backend.dsar_provider import (
MODULE_ID = "search"
MODULE_NAME = "Search"
MODULE_VERSION = "0.1.19"
MODULE_VERSION = "0.1.20"
READ_SCOPE = "search:result:read"
INDEX_SCOPE = "search:index:write"
ADMIN_SCOPE = "search:index:admin"
@@ -304,13 +304,20 @@ manifest = ModuleManifest(
"permission-aware interface."
),
body=(
"Documentation books sit immediately beside the visible heading or contextual label for "
"Search and Search index, not among operational action buttons. Field help remains beside its "
"label. "
"Search works with the built-in database index and can aggregate "
"optional providers. Source modules announce searchable types, "
"context scopes, and ACL-aware index entries. External engines "
"remain optional adapters. The title-bar Search command, F3, "
"or Ctrl/Cmd+K opens the "
"keyboard-navigable search overlay; filters never broaden the "
"current principal's source permissions. Provider failures are "
"current principal's source permissions. The overlay and full "
"Search page use the same Modules and Result types dropdowns. "
"Select all leaves a group unrestricted; deselecting every "
"option intentionally returns no results. Clear filters keeps "
"the query and current context. Provider failures are "
"shown as partial diagnostics without discarding safe results."
" Search administrators can inspect native source coverage, "
"process queued changes, reconcile enabled modules, and run "
@@ -339,7 +346,10 @@ manifest = ModuleManifest(
],
"steps": [
"Open Search from the title bar, F3, or Ctrl/Cmd+K and enter a precise term.",
"Narrow the result set by type, source, or context without changing source authorization.",
"Use the shared Modules and Result types dropdowns on the overlay or full Search page. Multiple values in one group are alternatives; both groups must match.",
"Select all removes a group's restriction. Deselect all intentionally shows no results; select an option or use Clear filters to search again.",
"In the overlay, a module context bounds the available result types. An incompatible type selection shows no results rather than expanding to other types. Clear filters keeps the selected context and query.",
"Use Tab to reach filter controls and Space to toggle an option. Escape closes the open dropdown first and returns focus to its button; another Escape closes the Search overlay.",
"Review partial-provider and quarantine diagnostics before relying on completeness.",
"Open an authorized result; Search rechecks the provider-owned object permission before disclosure.",
"Administrators may process queued changes, reconcile providers, or start a bounded rebuild when diagnostics require it.",
@@ -347,16 +357,21 @@ manifest = ModuleManifest(
"limitations": [
"Search is a derived discovery layer and is never authoritative for source content or access decisions.",
"A provider failure can make results incomplete but never permits unsafe results to bypass ACL filtering.",
"Filter choices describe the available source catalogue and observed results; they are not permission-filtered result counts or a promise that a source has matching objects.",
"The full Search page preserves query and filter selections in its URL, including explicit no-selection states. The title-bar overlay keeps its selections locally while mounted.",
],
"operational_consequences": {
"process_queue": "Applies pending derived index changes under current tenant and provider boundaries.",
"reconcile": "Compares enabled source coverage and retains unresolved changes in quarantine.",
"rebuild": "Recreates bounded derived projections while source data remains authoritative.",
"change_filters": "Discards obsolete result pages when query, filters, context, account/tenant authority, or API identity changes, even if the API token is unchanged. Selecting no modules or no result types performs no provider search and does not change source data or permissions.",
},
"verification": [
"Every displayed result names its source and remains openable by the current principal.",
"Partial-provider failures and quarantined changes remain visible as diagnostics.",
"Administrative rebuild status can be reconciled against the enabled provider inventory.",
"Select all, a subset, and no options produce distinct states; a full-page URL reload preserves the chosen filters without discarding the query or context.",
"Changing filters during a pending request never appends the previous query's next page to the new result set.",
],
},
translations={
@@ -367,12 +382,19 @@ manifest = ModuleManifest(
"berechtigungsbewusste Oberfläche finden."
),
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Suche und Suchindex, nicht zwischen ausführbaren "
"Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
"Search verwendet den eingebauten Datenbankindex und kann optionale Anbieter "
"zusammenführen. Quellmodule melden durchsuchbare Typen, Kontextbereiche und "
"berechtigungsgeprüfte Indexeinträge. Externe Suchmaschinen bleiben optionale "
"Adapter. Der Suchbefehl in der Titelleiste, F3 oder Strg/Cmd+K öffnet die per "
"Tastatur bedienbare Suchüberlagerung; Filter erweitern niemals die "
"Quellberechtigungen der aktuellen Person. Anbieterausfälle werden als partielle "
"Quellberechtigungen der aktuellen Person. Überlagerung und vollständige "
"Suchseite verwenden dieselben Auswahllisten für Module und Ergebnistypen. "
"Alle auswählen lässt eine Gruppe uneingeschränkt; alle Optionen abzuwählen "
"liefert bewusst keine Ergebnisse. Filter zurücksetzen behält Suchbegriff "
"und aktuellen Kontext bei. Anbieterausfälle werden als partielle "
"Diagnosen angezeigt, ohne sichere Ergebnisse zu verwerfen. Administratoren "
"können die Abdeckung nativer Quellen prüfen, eingereihte Änderungen verarbeiten, "
"aktivierte Module abgleichen und begrenzte Neuaufbauten aus der Administration "
@@ -393,7 +415,10 @@ manifest = ModuleManifest(
],
"steps": [
"Search über die Titelleiste, F3 oder Strg/Cmd+K öffnen und einen präzisen Suchbegriff eingeben.",
"Die Ergebnisse nach Typ, Quelle oder Kontext eingrenzen, ohne die Quellautorisierung zu verändern.",
"In der Überlagerung oder auf der vollständigen Suchseite die gemeinsamen Auswahllisten Module und Ergebnistypen verwenden. Mehrere Werte innerhalb einer Gruppe gelten als Alternativen; beide Gruppen müssen passen.",
"Alle auswählen hebt die Einschränkung einer Gruppe auf. Alle abwählen zeigt bewusst keine Ergebnisse; eine Option auswählen oder Filter zurücksetzen verwenden, um erneut zu suchen.",
"In der Überlagerung begrenzt ein Modulkontext die verfügbaren Ergebnistypen. Eine unvereinbare Typauswahl zeigt keine Ergebnisse, statt die Suche auf andere Typen auszuweiten. Filter zurücksetzen behält gewählten Kontext und Suchbegriff bei.",
"Mit Tab die Filtersteuerung erreichen und mit der Leertaste eine Option umschalten. Escape schließt zuerst die geöffnete Auswahlliste und setzt den Fokus auf ihre Schaltfläche zurück; ein weiteres Escape schließt die Suchüberlagerung.",
"Diagnosen zu partiellen Anbieterausfällen und Quarantäne prüfen, bevor Vollständigkeit angenommen wird.",
"Ein berechtigtes Ergebnis öffnen; Search prüft vor der Offenlegung erneut die Berechtigung am quellengeführten Objekt.",
"Administratoren können bei entsprechenden Diagnosen eingereihte Änderungen verarbeiten, Anbieter abgleichen oder einen begrenzten Neuaufbau starten.",
@@ -401,16 +426,21 @@ manifest = ModuleManifest(
"limitations": [
"Search ist eine abgeleitete Auffindbarkeitsschicht und niemals führend für Quellinhalte oder Zugriffsentscheidungen.",
"Ein Anbieterausfall kann Ergebnisse unvollständig machen, erlaubt aber niemals das Umgehen der Berechtigungsfilterung.",
"Filteroptionen beschreiben den verfügbaren Quellenkatalog und beobachtete Ergebnisse; sie sind weder berechtigungsgefilterte Trefferzahlen noch eine Zusage, dass eine Quelle passende Objekte enthält.",
"Die vollständige Suchseite erhält Suchbegriff und Filterauswahl in ihrer URL, auch ausdrücklich leere Auswahlen. Die Überlagerung der Titelleiste behält ihre Auswahl lokal, solange sie eingebunden bleibt.",
],
"operational_consequences": {
"process_queue": "Verarbeitet ausstehende abgeleitete Indexänderungen innerhalb der aktuellen Mandanten- und Anbietergrenzen.",
"reconcile": "Vergleicht die Abdeckung aktivierter Quellen und hält ungeklärte Änderungen in Quarantäne.",
"rebuild": "Erstellt begrenzte abgeleitete Projektionen neu, während die Quelldaten führend bleiben.",
"change_filters": "Verwirft veraltete Ergebnisseiten, wenn sich Suchbegriff, Filter, Kontext, Konto, Mandant, Berechtigungen oder API-Identität ändern, auch bei unverändertem API-Token. Eine leere Modul- oder Ergebnistypauswahl führt keine Anbietersuche aus und ändert weder Quelldaten noch Berechtigungen.",
},
"verification": [
"Jedes angezeigte Ergebnis nennt seine Quelle und kann von der aktuellen Person weiterhin geöffnet werden.",
"Partielle Anbieterausfälle und Änderungen in Quarantäne bleiben als Diagnosen sichtbar.",
"Der administrative Neuaufbaustatus lässt sich mit dem Inventar aktivierter Anbieter abgleichen.",
"Alle auswählen, eine Teilmenge und keine Optionen ergeben unterschiedliche Zustände; das Neuladen der vollständigen Suchseiten-URL erhält die gewählten Filter, ohne Suchbegriff oder Kontext zu verwerfen.",
"Ein Filterwechsel während einer laufenden Anfrage hängt niemals die nächste Seite des vorherigen Suchbegriffs an die neue Ergebnisliste an.",
],
}
},
+21
View File
@@ -26,6 +26,27 @@ class SearchDocumentationTests(unittest.TestCase):
for topic in manifest.documentation:
self.assertEqual((), user_workflow_scope_condition_issues(topic))
def test_filter_workflow_documents_tri_state_context_and_stale_reads(self) -> None:
topic = next(
item
for item in manifest.documentation
if item.id == "search.global-and-contextual"
)
steps = " ".join(topic.metadata["steps"])
german = topic.structured_translations["de"]
german_steps = " ".join(german["steps"])
self.assertIn("Modules and Result types dropdowns", steps)
self.assertIn("Deselect all intentionally shows no results", steps)
self.assertIn("Clear filters keeps the selected context and query", steps)
self.assertIn("Escape closes the open dropdown first", steps)
self.assertIn("Alle abwählen zeigt bewusst keine Ergebnisse", german_steps)
self.assertIn("gewählten Kontext und Suchbegriff", german_steps)
self.assertIn("Escape schließt zuerst", german_steps)
for metadata in (topic.metadata, german):
self.assertIn("change_filters", metadata["operational_consequences"])
self.assertEqual(4, len(metadata["limitations"]))
self.assertEqual(5, len(metadata["verification"]))
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -232,6 +232,63 @@ class SearchServiceTests(unittest.TestCase):
self.assertEqual("Cases", catalogue[0].label)
self.assertEqual(25, catalogue[0].order)
def test_legacy_empty_filters_remain_unrestricted_with_current_acl(self) -> None:
service = SearchIndexService(_Registry(active_modules=("search", "cases", "files", "mail")))
for module_id, resource_type, resource_id, title, allowed in (
("cases", "case", "case-1", "Permit A", True),
("files", "file", "file-1", "Permit B", True),
("mail", "message", "message-1", "Permit C", True),
("files", "folder", "folder-1", "Permit D", True),
("files", "file", "restricted", "Permit E", False),
):
service.upsert_document(self.session, self.principal, document=SearchDocument(
tenant_id="tenant-1", module_id=module_id, resource_type=resource_type,
resource_id=resource_id, title=title, url=f"/{module_id}/{resource_id}",
acl_tokens=("account:account-1" if allowed else "account:someone-else",),
))
self.session.flush()
results = service.search(self.session, self.principal, query=SearchQuery(
text="permit", tenant_id="tenant-1", module_ids=(), resource_types=(),
))
self.assertEqual(
{"case-1", "file-1", "message-1", "folder-1"},
{result.resource_id for result in results},
)
# The UI's explicit-none state must not be serialized as legacy empty
# tuples: empty tuples intentionally keep existing callers unrestricted.
narrowed = service.search(self.session, self.principal, query=SearchQuery(
text="permit", tenant_id="tenant-1",
module_ids=("cases", "files"), resource_types=("case", "file"),
))
self.assertEqual({"case-1", "file-1"}, {result.resource_id for result in narrowed})
before_limit = service.search(self.session, self.principal, query=SearchQuery(
text="permit", tenant_id="tenant-1", module_ids=("files",), resource_types=("file",), limit=1,
))
self.assertEqual(["file-1"], [result.resource_id for result in before_limit])
unknown = service.search(self.session, self.principal, query=SearchQuery(
text="permit", tenant_id="tenant-1", module_ids=("not-an-installed-module",),
))
self.assertEqual((), unknown)
def test_cursor_cannot_be_reused_after_filter_or_context_changes(self) -> None:
registry = _AggregateRegistry()
first = aggregate_search_page(registry, self.session, self.principal, query=SearchQuery(
text="permit", tenant_id="tenant-1", limit=2,
))
self.assertIsNotNone(first.next_cursor)
for changed in (
{"module_ids": ("cases",)},
{"resource_types": ("case",)},
{"context_kind": "module"},
{"context_id": "cases.selected-case"},
{"language": "german"},
):
with self.subTest(changed=changed), self.assertRaisesRegex(ValueError, "cursor"):
aggregate_search_page(registry, self.session, self.principal, query=SearchQuery(
text="permit", tenant_id="tenant-1", limit=2, cursor=first.next_cursor, **changed,
))
def test_upsert_replaces_acl_tokens_and_delete_is_idempotent(self) -> None:
document = SearchDocument(
tenant_id="tenant-1",
+3 -2
View File
@@ -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",
+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");
+3
View File
@@ -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", {
+29 -343
View File
@@ -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
@@ -403,7 +194,10 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
<Dialog
open={open && Boolean(layout)}
title="Search"
title="Search" titleHelp={<DocumentationHelpLink
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
label="Open search documentation"
/>}
helpContextId="search.results"
onClose={closeOverlay}
showCloseButton={false}
@@ -455,7 +249,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
aria-label="Clear search"
onClick={() => {
setQuery("");
setResponse(null);
setActiveIndex(-1);
overlayInputRef.current?.focus();
}}>
@@ -486,88 +279,11 @@ 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" }}
label="Open search documentation"
/>
<IconButton
label="Close search"
icon={<X size={17} />}
@@ -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());
}
+22
View File
@@ -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>;
}
+74
View File
@@ -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),
};
}
+76
View File
@@ -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 };
}
@@ -192,7 +192,10 @@ export default function SearchAdminPanel({ settings }: Props) {
return (
<AdminPageLayout
title="Search index"
title="Search index" titleHelp={<DocumentationHelpLink
reference={DOCUMENTATION}
label="Open Search administration documentation"
/>}
description="Inspect source coverage, process durable changes, and reconcile the tenant's derived index from authoritative modules."
loading={loading}
error={error}
@@ -225,10 +228,7 @@ export default function SearchAdminPanel({ settings }: Props) {
>
<RotateCw size={16} /> Reconcile modules
</Button>
<DocumentationHelpLink
reference={DOCUMENTATION}
label="Open Search administration documentation"
/>
</>
)}
>
+30 -298
View File
@@ -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, PageTitle,
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,28 +36,17 @@ 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 (
<main className="search-page">
<ActionToolbar className="search-page-toolbar">
<PageTitle titleHelp={<DocumentationHelpLink
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
label="Open search documentation"
/>}>Search</PageTitle>
<form className="search-page-form" onSubmit={submit}>
<Search size={18} aria-hidden="true" />
<input
@@ -222,113 +58,15 @@ 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",
},
};
+2
View File
@@ -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",
-140
View File
@@ -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;