Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965244fc67 | ||
|
|
6ad7c6e0ef |
@@ -7,8 +7,8 @@ authorization, and optional external engines remain provider capabilities.
|
|||||||
| Surface | Task and archetype | Consequence and state contract |
|
| 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. |
|
| 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. |
|
| 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. Loading, empty, provider-partial, failed, and paged states remain inside the result region. |
|
| `/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. |
|
| 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. |
|
| 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
|
layout collapses filters and results at narrow widths, and result activation is
|
||||||
available without pointer interaction.
|
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:
|
Verification:
|
||||||
|
|
||||||
|
- `npm run test:search-filters`
|
||||||
- `npm run test:search-overlay`
|
- `npm run test:search-overlay`
|
||||||
- `npm run test:interface-pattern`
|
- `npm run test:interface-pattern`
|
||||||
- the Core TypeScript graph, structural localization audit, theme check, module
|
- the Core TypeScript graph, structural localization audit, theme check, module
|
||||||
permutations, and full-product bundle budget
|
permutations, and full-product bundle budget
|
||||||
- Search backend and manifest tests
|
- Search backend compatibility, cursor binding, and bilingual manifest tests
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/search-webui",
|
"name": "@govoplan/search-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-search"
|
name = "govoplan-search"
|
||||||
version = "0.1.19"
|
version = "0.1.20"
|
||||||
description = "Permission-aware global and contextual search for GovOPlaN."
|
description = "Permission-aware global and contextual search for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = ["govoplan-core>=0.1.37"]
|
dependencies = ["govoplan-core>=0.1.45"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN search module."""
|
"""GovOPlaN search module."""
|
||||||
|
|
||||||
__version__ = "0.1.19"
|
__version__ = "0.1.20"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from govoplan_search.backend.dsar_provider import (
|
|||||||
|
|
||||||
MODULE_ID = "search"
|
MODULE_ID = "search"
|
||||||
MODULE_NAME = "Search"
|
MODULE_NAME = "Search"
|
||||||
MODULE_VERSION = "0.1.19"
|
MODULE_VERSION = "0.1.20"
|
||||||
READ_SCOPE = "search:result:read"
|
READ_SCOPE = "search:result:read"
|
||||||
INDEX_SCOPE = "search:index:write"
|
INDEX_SCOPE = "search:index:write"
|
||||||
ADMIN_SCOPE = "search:index:admin"
|
ADMIN_SCOPE = "search:index:admin"
|
||||||
@@ -304,13 +304,20 @@ manifest = ModuleManifest(
|
|||||||
"permission-aware interface."
|
"permission-aware interface."
|
||||||
),
|
),
|
||||||
body=(
|
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 "
|
"Search works with the built-in database index and can aggregate "
|
||||||
"optional providers. Source modules announce searchable types, "
|
"optional providers. Source modules announce searchable types, "
|
||||||
"context scopes, and ACL-aware index entries. External engines "
|
"context scopes, and ACL-aware index entries. External engines "
|
||||||
"remain optional adapters. The title-bar Search command, F3, "
|
"remain optional adapters. The title-bar Search command, F3, "
|
||||||
"or Ctrl/Cmd+K opens the "
|
"or Ctrl/Cmd+K opens the "
|
||||||
"keyboard-navigable search overlay; filters never broaden 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."
|
"shown as partial diagnostics without discarding safe results."
|
||||||
" Search administrators can inspect native source coverage, "
|
" Search administrators can inspect native source coverage, "
|
||||||
"process queued changes, reconcile enabled modules, and run "
|
"process queued changes, reconcile enabled modules, and run "
|
||||||
@@ -339,7 +346,10 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Open Search from the title bar, F3, or Ctrl/Cmd+K and enter a precise term.",
|
"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.",
|
"Review partial-provider and quarantine diagnostics before relying on completeness.",
|
||||||
"Open an authorized result; Search rechecks the provider-owned object permission before disclosure.",
|
"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.",
|
"Administrators may process queued changes, reconcile providers, or start a bounded rebuild when diagnostics require it.",
|
||||||
@@ -347,16 +357,21 @@ manifest = ModuleManifest(
|
|||||||
"limitations": [
|
"limitations": [
|
||||||
"Search is a derived discovery layer and is never authoritative for source content or access decisions.",
|
"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.",
|
"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": {
|
"operational_consequences": {
|
||||||
"process_queue": "Applies pending derived index changes under current tenant and provider boundaries.",
|
"process_queue": "Applies pending derived index changes under current tenant and provider boundaries.",
|
||||||
"reconcile": "Compares enabled source coverage and retains unresolved changes in quarantine.",
|
"reconcile": "Compares enabled source coverage and retains unresolved changes in quarantine.",
|
||||||
"rebuild": "Recreates bounded derived projections while source data remains authoritative.",
|
"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": [
|
"verification": [
|
||||||
"Every displayed result names its source and remains openable by the current principal.",
|
"Every displayed result names its source and remains openable by the current principal.",
|
||||||
"Partial-provider failures and quarantined changes remain visible as diagnostics.",
|
"Partial-provider failures and quarantined changes remain visible as diagnostics.",
|
||||||
"Administrative rebuild status can be reconciled against the enabled provider inventory.",
|
"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={
|
translations={
|
||||||
@@ -367,12 +382,19 @@ manifest = ModuleManifest(
|
|||||||
"berechtigungsbewusste Oberfläche finden."
|
"berechtigungsbewusste Oberfläche finden."
|
||||||
),
|
),
|
||||||
"body": (
|
"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 "
|
"Search verwendet den eingebauten Datenbankindex und kann optionale Anbieter "
|
||||||
"zusammenführen. Quellmodule melden durchsuchbare Typen, Kontextbereiche und "
|
"zusammenführen. Quellmodule melden durchsuchbare Typen, Kontextbereiche und "
|
||||||
"berechtigungsgeprüfte Indexeinträge. Externe Suchmaschinen bleiben optionale "
|
"berechtigungsgeprüfte Indexeinträge. Externe Suchmaschinen bleiben optionale "
|
||||||
"Adapter. Der Suchbefehl in der Titelleiste, F3 oder Strg/Cmd+K öffnet die per "
|
"Adapter. Der Suchbefehl in der Titelleiste, F3 oder Strg/Cmd+K öffnet die per "
|
||||||
"Tastatur bedienbare Suchüberlagerung; Filter erweitern niemals die "
|
"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 "
|
"Diagnosen angezeigt, ohne sichere Ergebnisse zu verwerfen. Administratoren "
|
||||||
"können die Abdeckung nativer Quellen prüfen, eingereihte Änderungen verarbeiten, "
|
"können die Abdeckung nativer Quellen prüfen, eingereihte Änderungen verarbeiten, "
|
||||||
"aktivierte Module abgleichen und begrenzte Neuaufbauten aus der Administration "
|
"aktivierte Module abgleichen und begrenzte Neuaufbauten aus der Administration "
|
||||||
@@ -393,7 +415,10 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Search über die Titelleiste, F3 oder Strg/Cmd+K öffnen und einen präzisen Suchbegriff eingeben.",
|
"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.",
|
"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.",
|
"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.",
|
"Administratoren können bei entsprechenden Diagnosen eingereihte Änderungen verarbeiten, Anbieter abgleichen oder einen begrenzten Neuaufbau starten.",
|
||||||
@@ -401,16 +426,21 @@ manifest = ModuleManifest(
|
|||||||
"limitations": [
|
"limitations": [
|
||||||
"Search ist eine abgeleitete Auffindbarkeitsschicht und niemals führend für Quellinhalte oder Zugriffsentscheidungen.",
|
"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.",
|
"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": {
|
"operational_consequences": {
|
||||||
"process_queue": "Verarbeitet ausstehende abgeleitete Indexänderungen innerhalb der aktuellen Mandanten- und Anbietergrenzen.",
|
"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.",
|
"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.",
|
"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": [
|
"verification": [
|
||||||
"Jedes angezeigte Ergebnis nennt seine Quelle und kann von der aktuellen Person weiterhin geöffnet werden.",
|
"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.",
|
"Partielle Anbieterausfälle und Änderungen in Quarantäne bleiben als Diagnosen sichtbar.",
|
||||||
"Der administrative Neuaufbaustatus lässt sich mit dem Inventar aktivierter Anbieter abgleichen.",
|
"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.",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ class SearchDocumentationTests(unittest.TestCase):
|
|||||||
for topic in manifest.documentation:
|
for topic in manifest.documentation:
|
||||||
self.assertEqual((), user_workflow_scope_condition_issues(topic))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -232,6 +232,63 @@ class SearchServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual("Cases", catalogue[0].label)
|
self.assertEqual("Cases", catalogue[0].label)
|
||||||
self.assertEqual(25, catalogue[0].order)
|
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:
|
def test_upsert_replaces_acl_tokens_and_delete_is_idempotent(self) -> None:
|
||||||
document = SearchDocument(
|
document = SearchDocument(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/search-webui",
|
"name": "@govoplan/search-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
"./styles/search.css": "./src/styles/search.css"
|
"./styles/search.css": "./src/styles/search.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:search-filters": "node --test scripts/test-search-filters.mjs",
|
||||||
"test:search-overlay": "node scripts/test-search-overlay-structure.mjs",
|
"test:search-overlay": "node scripts/test-search-overlay-structure.mjs",
|
||||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ const page = fs.readFileSync("src/features/search/SearchPage.tsx", "utf8");
|
|||||||
const overlay = fs.readFileSync("src/components/GlobalSearch.tsx", "utf8");
|
const overlay = fs.readFileSync("src/components/GlobalSearch.tsx", "utf8");
|
||||||
const admin = fs.readFileSync("src/features/search/SearchAdminPanel.tsx", "utf8");
|
const admin = fs.readFileSync("src/features/search/SearchAdminPanel.tsx", "utf8");
|
||||||
const styles = fs.readFileSync("src/styles/search.css", "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]) {
|
for (const source of [page, overlay]) {
|
||||||
assert.ok(source.includes("DocumentationHelpLink"), "Search surfaces expose configured-system help");
|
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("DismissibleAlert"), "Search failures use the shared alert contract");
|
||||||
assert.ok(!source.includes("window.alert("), "Search must not use browser alerts");
|
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(!/<(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(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");
|
assert.ok(overlay.includes("<Dialog"), "The title-bar search uses the shared focus-contained dialog");
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const requireCore = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||||
|
const ts = requireCore("typescript");
|
||||||
|
|
||||||
|
function loadTypeScript(relativePath, dependencies = {}) {
|
||||||
|
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||||
|
const { outputText } = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS,
|
||||||
|
} });
|
||||||
|
const module = { exports: {} };
|
||||||
|
new Function("require", "module", "exports", outputText)((id) => {
|
||||||
|
if (id in dependencies) return dependencies[id];
|
||||||
|
throw new Error(`Unexpected runtime dependency: ${id}`);
|
||||||
|
}, module, module.exports);
|
||||||
|
return module.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readSearchFilter, writeSearchFilter, effectiveSearchFilters, searchFilterOptions } = loadTypeScript("../src/components/searchFilters.ts");
|
||||||
|
|
||||||
|
test("legacy missing filters remain all; explicit none wins over repeated values", () => {
|
||||||
|
assert.equal(readSearchFilter(new URLSearchParams("q=permit"), "module"), null);
|
||||||
|
assert.deepEqual(readSearchFilter(new URLSearchParams("module=files&module=cases&module=files"), "module"), ["cases", "files"]);
|
||||||
|
assert.deepEqual(readSearchFilter(new URLSearchParams("module=cases&module_none=1"), "module"), []);
|
||||||
|
assert.deepEqual(readSearchFilter(new URLSearchParams("resource_type=case&resource_type_none=1"), "resource_type"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filter URL roundtrips preserve query, context, language and the other filter", () => {
|
||||||
|
const original = new URLSearchParams("q=permit&context=cases.current&language=german&resource_type=case&other=keep");
|
||||||
|
const selected = writeSearchFilter(original, "module", ["files", "cases", "files"]);
|
||||||
|
assert.deepEqual(selected.getAll("module"), ["cases", "files"]);
|
||||||
|
assert.equal(original.has("module"), false);
|
||||||
|
const none = writeSearchFilter(selected, "module", []);
|
||||||
|
assert.equal(none.get("module_none"), "1");
|
||||||
|
assert.equal(none.has("module"), false);
|
||||||
|
assert.deepEqual(readSearchFilter(none, "module"), []);
|
||||||
|
const all = writeSearchFilter(none, "module", null);
|
||||||
|
assert.equal(all.toString(), original.toString());
|
||||||
|
assert.equal(readSearchFilter(all, "module"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global all, none and subset selections keep distinct effective requests", () => {
|
||||||
|
assert.deepEqual(effectiveSearchFilters(null, null), { modules: undefined, resourceTypes: undefined, matchNone: false });
|
||||||
|
assert.equal(effectiveSearchFilters([], null).matchNone, true);
|
||||||
|
assert.equal(effectiveSearchFilters(null, []).matchNone, true);
|
||||||
|
assert.deepEqual(effectiveSearchFilters(["cases", "files"], ["case", "file"]), {
|
||||||
|
modules: ["cases", "files"], resourceTypes: ["case", "file"], matchNone: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context bounds never broaden an empty resource-type intersection", () => {
|
||||||
|
const context = { id: "files.context", moduleId: "files", label: "Files", pathPrefixes: ["/files"], resourceTypes: ["file", "folder"] };
|
||||||
|
assert.deepEqual(effectiveSearchFilters(null, null, context), { modules: ["files"], resourceTypes: ["file", "folder"], matchNone: false });
|
||||||
|
assert.deepEqual(effectiveSearchFilters(["cases"], ["case", "file"], context), { modules: ["files"], resourceTypes: ["file"], matchNone: false });
|
||||||
|
assert.deepEqual(effectiveSearchFilters(null, ["case"], context), { modules: ["files"], resourceTypes: [], matchNone: true });
|
||||||
|
assert.equal(effectiveSearchFilters(null, [], context).matchNone, true);
|
||||||
|
// The hidden global module selection must not override the current context.
|
||||||
|
assert.equal(effectiveSearchFilters([], null, context).matchNone, false);
|
||||||
|
assert.equal(effectiveSearchFilters(null, null, { ...context, resourceTypes: [] }).matchNone, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalogue = [
|
||||||
|
{ provider_id: "files.source", module_id: "files", resource_type: "file", label: "Files", order: 1 },
|
||||||
|
{ provider_id: "files.source", module_id: "files", resource_type: "folder", label: "Folders", order: 2 },
|
||||||
|
{ provider_id: "cases.source", module_id: "cases", resource_type: "case", label: "Cases", order: 3 },
|
||||||
|
];
|
||||||
|
const observed = [{ module_id: "mail", resource_type: "message" }];
|
||||||
|
const labels = new Map([["files", "Dateien"], ["cases", "Fälle"]]);
|
||||||
|
|
||||||
|
test("filter choices combine catalogue, observed values and removable stale selections", () => {
|
||||||
|
const all = searchFilterOptions(catalogue, observed, labels, ["files", "retired-source"], ["unknown-type"]);
|
||||||
|
assert.deepEqual(new Set(all.modules.map((item) => item.value)), new Set(["files", "cases", "mail", "retired-source"]));
|
||||||
|
assert.equal(all.modules.find((item) => item.value === "files").label, "Dateien");
|
||||||
|
assert.deepEqual(new Set(all.resourceTypes.map((item) => item.value)), new Set(["file", "folder", "unknown-type"]));
|
||||||
|
assert.deepEqual(searchFilterOptions(catalogue, observed, labels, [], null).resourceTypes, []);
|
||||||
|
const context = { id: "files.context", moduleId: "files", label: "Files", pathPrefixes: ["/files"], resourceTypes: ["file"] };
|
||||||
|
assert.deepEqual(searchFilterOptions(catalogue, observed, labels, null, null, context).resourceTypes, [{ value: "file", label: "Files" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit none is a frontend-only empty response; legacy API empty arrays still request all", async () => {
|
||||||
|
const paths = [], calls = [];
|
||||||
|
const response = { query: "permit", results: [], diagnostics: [], next_cursor: null, has_more: false };
|
||||||
|
const { search } = loadTypeScript("../src/api/search.ts", { "@govoplan/core-webui": {
|
||||||
|
apiPath: (path, parameters) => { paths.push({ path, parameters }); return path; },
|
||||||
|
apiFetch: async (...args) => { calls.push(args); return response; },
|
||||||
|
} });
|
||||||
|
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||||
|
assert.deepEqual(await search(settings, { query: "permit", matchNone: true, modules: [] }), response);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
assert.equal(paths.length, 0);
|
||||||
|
await search(settings, { query: "permit", modules: [], resourceTypes: [] });
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.deepEqual(paths[0].parameters.module, []);
|
||||||
|
assert.deepEqual(paths[0].parameters.resource_type, []);
|
||||||
|
assert.equal("matchNone" in paths[0].parameters, false);
|
||||||
|
await search(settings, { query: "permit", modules: ["cases", "files"], resourceTypes: ["case", "file"] });
|
||||||
|
assert.deepEqual(paths[1].parameters.module, ["cases", "files"]);
|
||||||
|
assert.deepEqual(paths[1].parameters.resource_type, ["case", "file"]);
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ function assert(condition, message) {
|
|||||||
const source = readFileSync("src/components/GlobalSearch.tsx", "utf8");
|
const source = readFileSync("src/components/GlobalSearch.tsx", "utf8");
|
||||||
const layoutSource = readFileSync("src/components/searchOverlayLayout.ts", "utf8");
|
const layoutSource = readFileSync("src/components/searchOverlayLayout.ts", "utf8");
|
||||||
const styles = readFileSync("src/styles/search.css", "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("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");
|
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("<Dialog"), "Search uses the shared Dialog component");
|
||||||
assert(source.includes("portal"), "the Search dialog portals above the complete shell");
|
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("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("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("response?.next_cursor"), "the overlay retains cursor pagination");
|
||||||
assert(source.includes('usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts")'), "contextual Search contributions are consumed");
|
assert(source.includes('usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts")'), "contextual Search contributions are consumed");
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export type SearchModuleReconcile = {
|
|||||||
|
|
||||||
export type SearchRequest = {
|
export type SearchRequest = {
|
||||||
query: string;
|
query: string;
|
||||||
|
/** UI-only explicit empty selection; never serialized to the Search API. */
|
||||||
|
matchNone?: boolean;
|
||||||
modules?: string[];
|
modules?: string[];
|
||||||
resourceTypes?: string[];
|
resourceTypes?: string[];
|
||||||
contextKind?: "global" | "module" | "resource";
|
contextKind?: "global" | "module" | "resource";
|
||||||
@@ -117,6 +119,7 @@ export function search(
|
|||||||
request: SearchRequest,
|
request: SearchRequest,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<SearchResponse> {
|
): Promise<SearchResponse> {
|
||||||
|
if (request.matchNone) return Promise.resolve({ query: request.query, results: [], diagnostics: [], next_cursor: null, has_more: false });
|
||||||
return apiFetch<SearchResponse>(
|
return apiFetch<SearchResponse>(
|
||||||
settings,
|
settings,
|
||||||
apiPath("/api/v1/search", {
|
apiPath("/api/v1/search", {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
|
import { ChevronDown, ExternalLink, Search, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { ActionToolbar,
|
import { ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
CountBadge,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
@@ -26,13 +25,10 @@ import { ActionToolbar,
|
|||||||
type GlobalSearchProps,
|
type GlobalSearchProps,
|
||||||
type SearchContextsUiCapability
|
type SearchContextsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import type { SearchResult } from "../api/search";
|
||||||
listSearchProviders,
|
import SearchFilters from "./SearchFilters";
|
||||||
search,
|
import { effectiveSearchFilters, humanizeIdentifier, searchAuthorityKey, searchFilterOptions, type SearchFilterSelection } from "./searchFilters";
|
||||||
type SearchResourceType,
|
import { useSearchCatalogue, useSearchResults } from "./useSearchResults";
|
||||||
type SearchResponse,
|
|
||||||
type SearchResult
|
|
||||||
} from "../api/search";
|
|
||||||
import {
|
import {
|
||||||
calculateSearchOverlayLayout,
|
calculateSearchOverlayLayout,
|
||||||
selectSearchContext,
|
selectSearchContext,
|
||||||
@@ -43,7 +39,7 @@ import {
|
|||||||
const MIN_QUERY_LENGTH = 2;
|
const MIN_QUERY_LENGTH = 2;
|
||||||
type SearchScope = "global" | "context";
|
type SearchScope = "global" | "context";
|
||||||
|
|
||||||
export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
export default function GlobalSearch({ settings, auth }: GlobalSearchProps) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const platformModules = usePlatformModules();
|
const platformModules = usePlatformModules();
|
||||||
@@ -62,75 +58,27 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [modules, setModules] = useState<string[]>([]);
|
const [modules, setModules] = useState<SearchFilterSelection>(null);
|
||||||
const [resourceTypes, setResourceTypes] = useState<string[]>([]);
|
const [resourceTypes, setResourceTypes] = useState<SearchFilterSelection>(null);
|
||||||
const [scope, setScope] = useState<SearchScope>(currentContext ? "context" : "global");
|
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 [open, setOpen] = useState(false);
|
||||||
const [layout, setLayout] = useState<SearchOverlayLayout | null>(null);
|
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 [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
|
||||||
const rootRef = useRef<HTMLButtonElement>(null);
|
const rootRef = useRef<HTMLButtonElement>(null);
|
||||||
const overlayInputRef = useRef<HTMLInputElement>(null);
|
const overlayInputRef = useRef<HTMLInputElement>(null);
|
||||||
const filtersRef = useRef<HTMLDivElement>(null);
|
|
||||||
const resultsRef = useRef<HTMLDivElement>(null);
|
const resultsRef = useRef<HTMLDivElement>(null);
|
||||||
const requestSequenceRef = useRef(0);
|
|
||||||
const loadMoreControllerRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const effectiveModules = useMemo(
|
const context = scope === "context" ? currentContext : null;
|
||||||
() => scope === "context" && currentContext
|
const authority = searchAuthorityKey(auth);
|
||||||
? [currentContext.moduleId]
|
const resourceCatalogue = useSearchCatalogue(settings, open, authority);
|
||||||
: modules,
|
const { response, loading, error, loadMore, requestIdentity } = useSearchResults(settings, {
|
||||||
[currentContext, modules, scope]
|
query: query.trim(), ...effectiveSearchFilters(modules, resourceTypes, context),
|
||||||
);
|
contextKind: context ? "module" : "global", contextId: context?.id, limit: 50,
|
||||||
const effectiveResourceTypes = useMemo(() => {
|
}, open && query.trim().length >= MIN_QUERY_LENGTH, 180, authority);
|
||||||
if (scope !== "context" || !currentContext?.resourceTypes?.length) return resourceTypes;
|
const options = searchFilterOptions(resourceCatalogue, response?.results ?? [], moduleLabels, modules, resourceTypes, context);
|
||||||
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 moduleOptions = useMemo(() => {
|
useEffect(() => { setActiveIndex(-1); }, [requestIdentity]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const measureOverlay = useCallback(() => {
|
const measureOverlay = useCallback(() => {
|
||||||
const rect = rootRef.current?.getBoundingClientRect();
|
const rect = rootRef.current?.getBoundingClientRect();
|
||||||
@@ -150,9 +98,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeOverlay = useCallback(() => {
|
const closeOverlay = useCallback(() => {
|
||||||
loadMoreControllerRef.current?.abort();
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setFiltersOpen(false);
|
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -178,23 +124,13 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
setScope(currentContext ? "context" : "global");
|
setScope(currentContext ? "context" : "global");
|
||||||
}, [currentContext, open]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
measureOverlay();
|
measureOverlay();
|
||||||
const observer = typeof ResizeObserver === "undefined" || !rootRef.current
|
const observer = typeof ResizeObserver === "undefined" || !rootRef.current
|
||||||
? null
|
? null
|
||||||
: new ResizeObserver(measureOverlay);
|
: new ResizeObserver(measureOverlay);
|
||||||
observer?.observe(rootRef.current);
|
if (rootRef.current) observer?.observe(rootRef.current);
|
||||||
window.addEventListener("resize", measureOverlay);
|
window.addEventListener("resize", measureOverlay);
|
||||||
return () => {
|
return () => {
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
@@ -202,86 +138,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
};
|
};
|
||||||
}, [measureOverlay, open]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (activeIndex < 0) return;
|
if (activeIndex < 0) return;
|
||||||
resultsRef.current
|
resultsRef.current
|
||||||
@@ -294,25 +150,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
navigate(result.url);
|
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() {
|
function clearFilters() {
|
||||||
setModules([]);
|
if (!context) setModules(null);
|
||||||
setResourceTypes([]);
|
setResourceTypes(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOverlaySubmit(event: FormEvent) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -403,7 +194,10 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open && Boolean(layout)}
|
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"
|
helpContextId="search.results"
|
||||||
onClose={closeOverlay}
|
onClose={closeOverlay}
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
@@ -455,7 +249,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setResponse(null);
|
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
overlayInputRef.current?.focus();
|
overlayInputRef.current?.focus();
|
||||||
}}>
|
}}>
|
||||||
@@ -486,88 +279,11 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<div className="search-filter-menu" ref={filtersRef}>
|
<SearchFilters modules={modules} resourceTypes={resourceTypes} options={options}
|
||||||
<Button
|
hideModules={Boolean(context)} onModulesChange={setModules}
|
||||||
type="button"
|
onResourceTypesChange={setResourceTypes} onClear={clearFilters} />
|
||||||
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>
|
|
||||||
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
||||||
<DocumentationHelpLink
|
|
||||||
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
|
|
||||||
label="Open search documentation"
|
|
||||||
/>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Close search"
|
label="Close search"
|
||||||
icon={<X size={17} />}
|
icon={<X size={17} />}
|
||||||
@@ -575,30 +291,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
className="search-overlay-close"
|
className="search-overlay-close"
|
||||||
onClick={closeOverlay}
|
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>
|
</ActionToolbar>
|
||||||
|
|
||||||
<PageScrollViewport
|
<PageScrollViewport
|
||||||
@@ -608,7 +300,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Search results">
|
aria-label="Search results">
|
||||||
{error &&
|
{error &&
|
||||||
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
}
|
}
|
||||||
@@ -673,9 +365,3 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanizeIdentifier(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/[._:-]+/g, " ")
|
|
||||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Button, MultiSelectFilter, ToolbarGroup } from "@govoplan/core-webui";
|
||||||
|
import { searchFilterOptions, type SearchFilterSelection } from "./searchFilters";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
modules: SearchFilterSelection;
|
||||||
|
resourceTypes: SearchFilterSelection;
|
||||||
|
options: ReturnType<typeof searchFilterOptions>;
|
||||||
|
hideModules?: boolean;
|
||||||
|
onModulesChange: (value: SearchFilterSelection) => void;
|
||||||
|
onResourceTypesChange: (value: SearchFilterSelection) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Search owns the facets; Core owns dropdown, selection, focus, and layout. */
|
||||||
|
export default function SearchFilters({ modules, resourceTypes, options, hideModules = false, onModulesChange, onResourceTypesChange, onClear }: Props) {
|
||||||
|
const active = (!hideModules && modules !== null) || resourceTypes !== null;
|
||||||
|
return <ToolbarGroup data-help-context-id="search.filters" data-help-module-id="search">
|
||||||
|
{!hideModules && <MultiSelectFilter label="i18n:govoplan-search.filter_modules" options={options.modules} value={modules} onChange={onModulesChange} />}
|
||||||
|
<MultiSelectFilter label="i18n:govoplan-search.filter_types" options={options.resourceTypes} value={resourceTypes} onChange={onResourceTypesChange} />
|
||||||
|
{active && <Button type="button" variant="ghost" onClick={onClear}>i18n:govoplan-search.clear_filters</Button>}
|
||||||
|
</ToolbarGroup>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { AuthInfo, SearchContextContribution } from "@govoplan/core-webui";
|
||||||
|
import type { SearchRequest, SearchResourceType, SearchResult } from "../api/search";
|
||||||
|
|
||||||
|
/** Same selection contract as Core's list filter: null = all, [] = none. */
|
||||||
|
export type SearchFilterSelection = string[] | null;
|
||||||
|
export type SearchFilterName = "module" | "resource_type";
|
||||||
|
|
||||||
|
/** Cookie-backed tenant switches need not change the API settings or token. */
|
||||||
|
export function searchAuthorityKey(auth: AuthInfo | null): string {
|
||||||
|
return JSON.stringify([auth?.user.id, auth?.user.account_id, auth?.principal?.membership_id,
|
||||||
|
auth?.active_tenant?.id ?? auth?.tenant.id, [...(auth?.scopes ?? [])].sort()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSearchFilter(params: URLSearchParams, name: SearchFilterName): SearchFilterSelection {
|
||||||
|
if (params.get(`${name}_none`) === "1") return [];
|
||||||
|
const values = [...new Set(params.getAll(name).filter(Boolean))].sort();
|
||||||
|
return values.length ? values : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSearchFilter(params: URLSearchParams, name: SearchFilterName, value: SearchFilterSelection): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.delete(name);
|
||||||
|
next.delete(`${name}_none`);
|
||||||
|
if (value?.length === 0) next.set(`${name}_none`, "1");
|
||||||
|
else for (const item of [...new Set(value ?? [])].sort()) next.append(name, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveSearchFilters(
|
||||||
|
modules: SearchFilterSelection,
|
||||||
|
resourceTypes: SearchFilterSelection,
|
||||||
|
context?: SearchContextContribution | null
|
||||||
|
): Pick<SearchRequest, "modules" | "resourceTypes" | "matchNone"> {
|
||||||
|
const effectiveModules = context ? [context.moduleId] : modules;
|
||||||
|
const allowedTypes = context?.resourceTypes;
|
||||||
|
const effectiveTypes = allowedTypes?.length
|
||||||
|
? resourceTypes === null ? allowedTypes : resourceTypes.filter((value) => allowedTypes.includes(value))
|
||||||
|
: resourceTypes;
|
||||||
|
return {
|
||||||
|
modules: effectiveModules ?? undefined,
|
||||||
|
resourceTypes: effectiveTypes ?? undefined,
|
||||||
|
// An empty context intersection must never turn into an unrestricted API query.
|
||||||
|
matchNone: effectiveModules?.length === 0 || effectiveTypes?.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function humanizeIdentifier(value: string): string {
|
||||||
|
return value.replace(/[._:-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchFilterOptions(
|
||||||
|
catalogue: SearchResourceType[],
|
||||||
|
results: SearchResult[],
|
||||||
|
moduleLabels: ReadonlyMap<string, string>,
|
||||||
|
modules: SearchFilterSelection,
|
||||||
|
resourceTypes: SearchFilterSelection,
|
||||||
|
context?: SearchContextContribution | null
|
||||||
|
) {
|
||||||
|
const moduleIds = new Set([...catalogue.map((item) => item.module_id), ...results.map((item) => item.module_id), ...(modules ?? [])]);
|
||||||
|
const effectiveModules = context ? [context.moduleId] : modules;
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
for (const item of [...catalogue, ...results]) {
|
||||||
|
if (effectiveModules !== null && !effectiveModules.includes(item.module_id)) continue;
|
||||||
|
if (context?.resourceTypes?.length && !context.resourceTypes.includes(item.resource_type)) continue;
|
||||||
|
if (!labels.has(item.resource_type)) labels.set(item.resource_type, "label" in item ? item.label : humanizeIdentifier(item.resource_type));
|
||||||
|
}
|
||||||
|
// Keep stale or unknown selections removable; do not silently broaden the query.
|
||||||
|
for (const value of resourceTypes ?? []) if (!labels.has(value)) labels.set(value, humanizeIdentifier(value));
|
||||||
|
const byLabel = (left: { label: string }, right: { label: string }) => left.label.localeCompare(right.label);
|
||||||
|
return {
|
||||||
|
modules: [...moduleIds].map((value) => ({ value, label: moduleLabels.get(value) ?? humanizeIdentifier(value) })).sort(byLabel),
|
||||||
|
resourceTypes: [...labels].map(([value, label]) => ({ value, label })).sort(byLabel),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { listSearchProviders, search, type SearchRequest, type SearchResourceType, type SearchResponse } from "../api/search";
|
||||||
|
|
||||||
|
export function useSearchCatalogue(settings: ApiSettings, enabled = true, authority = ""): SearchResourceType[] {
|
||||||
|
const identity = useMemo(() => ({}), [settings, enabled, authority]);
|
||||||
|
const [state, setState] = useState<{ identity: object; resources: SearchResourceType[] } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setState(null);
|
||||||
|
listSearchProviders(settings, controller.signal)
|
||||||
|
.then((result) => { if (!controller.signal.aborted) setState({ identity, resources: result.resources ?? [] }); })
|
||||||
|
.catch(() => { if (!controller.signal.aborted) setState(null); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [settings, enabled, identity]);
|
||||||
|
return enabled && state?.identity === identity ? state.resources : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultState = { identity: object; response: SearchResponse | null; loading: boolean; error: string };
|
||||||
|
|
||||||
|
/** One request lifecycle for route and overlay, including cursor cancellation. */
|
||||||
|
export function useSearchResults(settings: ApiSettings, request: SearchRequest, enabled = true, delay = 0, authority = "") {
|
||||||
|
const requestKey = JSON.stringify(request);
|
||||||
|
const identity = useMemo(() => ({ settings, request: JSON.parse(requestKey) as SearchRequest, enabled, delay }), [settings, requestKey, enabled, delay, authority]);
|
||||||
|
const currentIdentity = useRef(identity);
|
||||||
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
|
const [state, setState] = useState<ResultState | null>(null);
|
||||||
|
// Gate rendering as well as promise completion: stale results cannot be opened
|
||||||
|
// with Enter during the debounce interval or while an effect is being cleaned up.
|
||||||
|
currentIdentity.current = identity;
|
||||||
|
const visible = state?.identity === identity ? state : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
if (!enabled) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
setState({ identity, response: null, loading: true, error: "" });
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
search(settings, identity.request, controller.signal)
|
||||||
|
.then((response) => {
|
||||||
|
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response, loading: false, error: "" });
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response: null, loading: false, error: reason instanceof Error ? reason.message : "Search failed." });
|
||||||
|
});
|
||||||
|
}, identity.request.matchNone ? 0 : delay);
|
||||||
|
return () => { window.clearTimeout(timer); controller.abort(); controllerRef.current?.abort(); };
|
||||||
|
}, [identity, settings, enabled, delay]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
const cursor = visible?.response?.next_cursor;
|
||||||
|
if (!cursor || visible.loading || currentIdentity.current !== identity) return;
|
||||||
|
// Also guards a second click before React has rendered the loading state.
|
||||||
|
if (controllerRef.current?.signal.aborted === false && controllerRef.current !== null) controllerRef.current.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
setState((current) => current?.identity === identity ? { ...current, loading: true, error: "" } : current);
|
||||||
|
try {
|
||||||
|
const next = await search(settings, { ...identity.request, cursor }, controller.signal);
|
||||||
|
if (controller.signal.aborted || currentIdentity.current !== identity) return;
|
||||||
|
setState((current) => current?.identity === identity && current.response ? {
|
||||||
|
identity, loading: false, error: "", response: {
|
||||||
|
...next,
|
||||||
|
results: [...current.response.results, ...next.results],
|
||||||
|
diagnostics: [...current.response.diagnostics, ...next.diagnostics.filter((item) => !current.response!.diagnostics.some((previous) => previous.provider_id === item.provider_id))],
|
||||||
|
},
|
||||||
|
} : current);
|
||||||
|
} catch (reason) {
|
||||||
|
if (!controller.signal.aborted && currentIdentity.current === identity) setState((current) => current?.identity === identity ? { ...current, loading: false, error: reason instanceof Error ? reason.message : "Search failed." } : current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { response: visible?.response ?? null, error: visible?.error ?? "", loading: enabled && (visible?.loading ?? true), loadMore, requestIdentity: identity };
|
||||||
|
}
|
||||||
@@ -192,7 +192,10 @@ export default function SearchAdminPanel({ settings }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageLayout
|
<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."
|
description="Inspect source coverage, process durable changes, and reconcile the tenant's derived index from authoritative modules."
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
@@ -225,10 +228,7 @@ export default function SearchAdminPanel({ settings }: Props) {
|
|||||||
>
|
>
|
||||||
<RotateCw size={16} /> Reconcile modules
|
<RotateCw size={16} /> Reconcile modules
|
||||||
</Button>
|
</Button>
|
||||||
<DocumentationHelpLink
|
|
||||||
reference={DOCUMENTATION}
|
|
||||||
label="Open Search administration documentation"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,185 +1,32 @@
|
|||||||
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
|
import { ChevronDown, ExternalLink, Search } from "lucide-react";
|
||||||
import {
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type FormEvent
|
|
||||||
} from "react";
|
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { ActionToolbar,
|
import { ActionToolbar, Button, DocumentationHelpLink, DismissibleAlert, LoadingIndicator, PageTitle,
|
||||||
Button,
|
PageScrollViewport, useGuardedNavigate, usePlatformModules, type PlatformRouteContext
|
||||||
CountBadge,
|
|
||||||
DocumentationHelpLink,
|
|
||||||
DismissibleAlert,
|
|
||||||
IconButton,
|
|
||||||
LoadingIndicator,
|
|
||||||
PageScrollViewport,
|
|
||||||
useGuardedNavigate,
|
|
||||||
usePlatformModules,
|
|
||||||
type PlatformRouteContext
|
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import SearchFilters from "../../components/SearchFilters";
|
||||||
listSearchProviders,
|
import { effectiveSearchFilters, readSearchFilter, searchAuthorityKey, searchFilterOptions, writeSearchFilter } from "../../components/searchFilters";
|
||||||
search,
|
import { useSearchCatalogue, useSearchResults } from "../../components/useSearchResults";
|
||||||
type SearchResourceType,
|
|
||||||
type SearchResponse
|
|
||||||
} from "../../api/search";
|
|
||||||
|
|
||||||
|
export default function SearchPage({ settings, auth }: PlatformRouteContext) {
|
||||||
export default function SearchPage({ settings }: PlatformRouteContext) {
|
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const platformModules = usePlatformModules();
|
const platformModules = usePlatformModules();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const query = params.get("q") ?? "";
|
const query = params.get("q") ?? "";
|
||||||
const moduleKey = params.getAll("module").join("\u001f");
|
const modules = readSearchFilter(params, "module");
|
||||||
const resourceTypeKey = params.getAll("resource_type").join("\u001f");
|
const resourceTypes = readSearchFilter(params, "resource_type");
|
||||||
const contextId = params.get("context") ?? undefined;
|
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 [draft, setDraft] = useState(query);
|
||||||
const [response, setResponse] = useState<SearchResponse | null>(null);
|
const authority = searchAuthorityKey(auth);
|
||||||
const [resourceCatalogue, setResourceCatalogue] = useState<SearchResourceType[]>([]);
|
const resourceCatalogue = useSearchCatalogue(settings, true, authority);
|
||||||
const [loading, setLoading] = useState(false);
|
const { response, loading, error, loadMore } = useSearchResults(settings, {
|
||||||
const [error, setError] = useState("");
|
query, ...effectiveSearchFilters(modules, resourceTypes),
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
contextKind: modules?.length ? "module" : "global", contextId, limit: 50,
|
||||||
const filtersRef = useRef<HTMLDivElement>(null);
|
}, Boolean(query.trim()), 0, authority);
|
||||||
const requestKey = useMemo(
|
const moduleLabels = useMemo(() => new Map(platformModules.map((module) => [module.id, module.label])), [platformModules]);
|
||||||
() => JSON.stringify([query, moduleKey, resourceTypeKey, contextId]),
|
const options = searchFilterOptions(resourceCatalogue, response?.results ?? [], moduleLabels, modules, resourceTypes);
|
||||||
[contextId, moduleKey, query, resourceTypeKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { setDraft(query); }, [query]);
|
||||||
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]);
|
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -189,28 +36,17 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
|||||||
setParams(next);
|
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() {
|
function clearFilters() {
|
||||||
const next = new URLSearchParams(params);
|
setParams(writeSearchFilter(writeSearchFilter(params, "module", null), "resource_type", null));
|
||||||
next.delete("module");
|
|
||||||
next.delete("resource_type");
|
|
||||||
next.delete("context");
|
|
||||||
setParams(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="search-page">
|
<main className="search-page">
|
||||||
<ActionToolbar className="search-page-toolbar">
|
<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}>
|
<form className="search-page-form" onSubmit={submit}>
|
||||||
<Search size={18} aria-hidden="true" />
|
<Search size={18} aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
@@ -222,113 +58,15 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
|||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary">Search</Button>
|
<Button type="submit" variant="primary">Search</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="search-filter-menu" ref={filtersRef}>
|
<SearchFilters modules={modules} resourceTypes={resourceTypes} options={options}
|
||||||
<Button
|
onModulesChange={(value) => setParams(writeSearchFilter(params, "module", value))}
|
||||||
type="button"
|
onResourceTypesChange={(value) => setParams(writeSearchFilter(params, "resource_type", value))}
|
||||||
className={`search-filter-trigger${activeFilterCount ? " is-active" : ""}`}
|
onClear={clearFilters} />
|
||||||
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>
|
|
||||||
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
{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>
|
</ActionToolbar>
|
||||||
<PageScrollViewport className="search-results-viewport">
|
<PageScrollViewport className="search-results-viewport">
|
||||||
{error &&
|
{error &&
|
||||||
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
}
|
}
|
||||||
@@ -371,7 +109,7 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="search-load-more"
|
className="search-load-more"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => loadResults(response.next_cursor ?? undefined)}>
|
onClick={() => void loadMore()}>
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
<span>Load more</span>
|
<span>Load more</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -380,9 +118,3 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanizeIdentifier(value: string): string {
|
|
||||||
return value.
|
|
||||||
replace(/[._:-]+/g, " ").
|
|
||||||
replace(/\b\w/g, (character) => character.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const searchFilterTranslations: PlatformTranslations = {
|
||||||
|
en: {
|
||||||
|
"i18n:govoplan-search.filter_modules": "Modules",
|
||||||
|
"i18n:govoplan-search.filter_types": "Result types",
|
||||||
|
"i18n:govoplan-search.clear_filters": "Clear filters",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
"i18n:govoplan-search.filter_modules": "Module",
|
||||||
|
"i18n:govoplan-search.filter_types": "Ergebnistypen",
|
||||||
|
"i18n:govoplan-search.clear_filters": "Filter zurücksetzen",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
SearchRuntimeUiCapability
|
SearchRuntimeUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import GlobalSearch from "./components/GlobalSearch";
|
import GlobalSearch from "./components/GlobalSearch";
|
||||||
|
import { searchFilterTranslations } from "./i18n/searchFilterTranslations";
|
||||||
import "./styles/search.css";
|
import "./styles/search.css";
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export const searchModule: PlatformWebModule = {
|
|||||||
id: "search",
|
id: "search",
|
||||||
label: "Search",
|
label: "Search",
|
||||||
version: "0.1.14",
|
version: "0.1.14",
|
||||||
|
translations: searchFilterTranslations,
|
||||||
optionalDependencies: [
|
optionalDependencies: [
|
||||||
"access",
|
"access",
|
||||||
"views",
|
"views",
|
||||||
|
|||||||
@@ -139,10 +139,6 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-overlay-toolbar .search-active-filters {
|
|
||||||
flex-basis: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay-results-viewport {
|
.search-overlay-results-viewport {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -224,142 +220,6 @@
|
|||||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
|
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 {
|
.search-results-viewport {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user