From 6ad7c6e0ef5c8b8a747d1390407ffa5e3813b2d4 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:32:53 +0200 Subject: [PATCH] Release govoplan-search v0.1.20: centralize filtering and refresh behavior --- docs/INTERFACE_PATTERN_MIGRATION.md | 63 ++- package.json | 4 +- pyproject.toml | 4 +- src/govoplan_search/__init__.py | 2 +- src/govoplan_search/backend/manifest.py | 34 +- tests/test_documentation.py | 21 + tests/test_search_service.py | 57 +++ webui/package.json | 5 +- webui/scripts/test-interface-pattern.mjs | 8 + webui/scripts/test-search-filters.mjs | 102 +++++ .../scripts/test-search-overlay-structure.mjs | 5 +- webui/src/api/search.ts | 3 + webui/src/components/GlobalSearch.tsx | 362 ++---------------- webui/src/components/SearchFilters.tsx | 22 ++ webui/src/components/searchFilters.ts | 74 ++++ webui/src/components/useSearchResults.ts | 76 ++++ webui/src/features/search/SearchPage.tsx | 320 ++-------------- webui/src/i18n/searchFilterTranslations.ts | 14 + webui/src/module.ts | 2 + webui/src/styles/search.css | 140 ------- 20 files changed, 530 insertions(+), 788 deletions(-) create mode 100644 webui/scripts/test-search-filters.mjs create mode 100644 webui/src/components/SearchFilters.tsx create mode 100644 webui/src/components/searchFilters.ts create mode 100644 webui/src/components/useSearchResults.ts create mode 100644 webui/src/i18n/searchFilterTranslations.ts diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index 235071b..94f2a24 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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 diff --git a/package.json b/package.json index 850084a..722776a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pyproject.toml b/pyproject.toml index f7b35aa..c78a747 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/govoplan_search/__init__.py b/src/govoplan_search/__init__.py index feb2d72..f6c14ab 100644 --- a/src/govoplan_search/__init__.py +++ b/src/govoplan_search/__init__.py @@ -1,3 +1,3 @@ """GovOPlaN search module.""" -__version__ = "0.1.19" +__version__ = "0.1.20" diff --git a/src/govoplan_search/backend/manifest.py b/src/govoplan_search/backend/manifest.py index 5bc05b2..e0b7e87 100644 --- a/src/govoplan_search/backend/manifest.py +++ b/src/govoplan_search/backend/manifest.py @@ -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" @@ -310,7 +310,11 @@ manifest = ModuleManifest( "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 +343,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 +354,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={ @@ -372,7 +384,11 @@ manifest = ModuleManifest( "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 +409,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 +420,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.", ], } }, diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 0742822..b834846 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -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() diff --git a/tests/test_search_service.py b/tests/test_search_service.py index b0c4627..527d8ba 100644 --- a/tests/test_search_service.py +++ b/tests/test_search_service.py @@ -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", diff --git a/webui/package.json b/webui/package.json index 719010b..25ede4c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/scripts/test-interface-pattern.mjs b/webui/scripts/test-interface-pattern.mjs index f65751f..13fb4ad 100644 --- a/webui/scripts/test-interface-pattern.mjs +++ b/webui/scripts/test-interface-pattern.mjs @@ -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(" { + 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"]); +}); diff --git a/webui/scripts/test-search-overlay-structure.mjs b/webui/scripts/test-search-overlay-structure.mjs index 24f46cc..2b7153e 100644 --- a/webui/scripts/test-search-overlay-structure.mjs +++ b/webui/scripts/test-search-overlay-structure.mjs @@ -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("("search.contexts")'), "contextual Search contributions are consumed"); diff --git a/webui/src/api/search.ts b/webui/src/api/search.ts index e5fc529..c1eedb5 100644 --- a/webui/src/api/search.ts +++ b/webui/src/api/search.ts @@ -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 { + if (request.matchNone) return Promise.resolve({ query: request.query, results: [], diagnostics: [], next_cursor: null, has_more: false }); return apiFetch( settings, apiPath("/api/v1/search", { diff --git a/webui/src/components/GlobalSearch.tsx b/webui/src/components/GlobalSearch.tsx index 6b74240..85dcf96 100644 --- a/webui/src/components/GlobalSearch.tsx +++ b/webui/src/components/GlobalSearch.tsx @@ -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([]); - const [resourceTypes, setResourceTypes] = useState([]); + const [modules, setModules] = useState(null); + const [resourceTypes, setResourceTypes] = useState(null); const [scope, setScope] = useState(currentContext ? "context" : "global"); - const [response, setResponse] = useState(null); - const [resourceCatalogue, setResourceCatalogue] = useState([]); const [open, setOpen] = useState(false); const [layout, setLayout] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [filtersOpen, setFiltersOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const rootRef = useRef(null); const overlayInputRef = useRef(null); - const filtersRef = useRef(null); const resultsRef = useRef(null); - const requestSequenceRef = useRef(0); - const loadMoreControllerRef = useRef(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(); - 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 ( <> - {filtersOpen && -
-
- Filter results - } - variant="ghost" - onClick={() => setFiltersOpen(false)} - /> -
- {scope === "global" && -
- Modules -
- {moduleOptions.map((option) => - - )} - {moduleOptions.length === 0 && - No module filters available. - } -
-
- } -
- Result types -
- {resourceTypeOptions.map((option) => - - )} - {resourceTypeOptions.length === 0 && - No result type filters available. - } -
-
-
- -
-
- } - + {loading && } - {activeFilterCount > 0 && -
- {scope === "global" && modules.map((moduleId) => - - )} - {resourceTypes.map((resourceType) => - - )} -
- } {error && - setError("")}> + {error} } @@ -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()); -} diff --git a/webui/src/components/SearchFilters.tsx b/webui/src/components/SearchFilters.tsx new file mode 100644 index 0000000..9053dbf --- /dev/null +++ b/webui/src/components/SearchFilters.tsx @@ -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; + 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 + {!hideModules && } + + {active && } + ; +} diff --git a/webui/src/components/searchFilters.ts b/webui/src/components/searchFilters.ts new file mode 100644 index 0000000..d5edc18 --- /dev/null +++ b/webui/src/components/searchFilters.ts @@ -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 { + 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, + 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(); + 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), + }; +} diff --git a/webui/src/components/useSearchResults.ts b/webui/src/components/useSearchResults.ts new file mode 100644 index 0000000..ff9872b --- /dev/null +++ b/webui/src/components/useSearchResults.ts @@ -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(null); + const [state, setState] = useState(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 }; +} diff --git a/webui/src/features/search/SearchPage.tsx b/webui/src/features/search/SearchPage.tsx index cc6c3e3..e779349 100644 --- a/webui/src/features/search/SearchPage.tsx +++ b/webui/src/features/search/SearchPage.tsx @@ -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(null); - const [resourceCatalogue, setResourceCatalogue] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [filtersOpen, setFiltersOpen] = useState(false); - const filtersRef = useRef(null); - const requestKey = useMemo( - () => JSON.stringify([query, moduleKey, resourceTypeKey, contextId]), - [contextId, moduleKey, query, resourceTypeKey] - ); + 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(); - 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) { /> -
- - {filtersOpen && -
-
- Filter results - } - variant="ghost" - onClick={() => setFiltersOpen(false)} - /> -
-
- Modules -
- {moduleOptions.map((option) => - - )} - {moduleOptions.length === 0 && - No module filters available. - } -
-
-
- Result types -
- {resourceTypeOptions.map((option) => - - )} - {resourceTypeOptions.length === 0 && - No result type filters available. - } -
-
-
- -
-
- } -
+ setParams(writeSearchFilter(params, "module", value))} + onResourceTypesChange={(value) => setParams(writeSearchFilter(params, "resource_type", value))} + onClear={clearFilters} /> {loading && } - {activeFilterCount > 0 && -
- {modules.map((moduleId) => - - )} - {resourceTypes.map((resourceType) => - - )} -
- } {error && - setError("")}> + {error} } @@ -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()}> Load more @@ -380,9 +118,3 @@ export default function SearchPage({ settings }: PlatformRouteContext) { ); } - -function humanizeIdentifier(value: string): string { - return value. - replace(/[._:-]+/g, " "). - replace(/\b\w/g, (character) => character.toUpperCase()); -} diff --git a/webui/src/i18n/searchFilterTranslations.ts b/webui/src/i18n/searchFilterTranslations.ts new file mode 100644 index 0000000..e173619 --- /dev/null +++ b/webui/src/i18n/searchFilterTranslations.ts @@ -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", + }, +}; diff --git a/webui/src/module.ts b/webui/src/module.ts index 6b92abf..e343b20 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -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", diff --git a/webui/src/styles/search.css b/webui/src/styles/search.css index 4e72760..1e5f3e6 100644 --- a/webui/src/styles/search.css +++ b/webui/src/styles/search.css @@ -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;