Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965244fc67 | ||
|
|
6ad7c6e0ef | ||
|
|
6c5545af66 | ||
|
|
016136f56c | ||
|
|
cc13bcf45e | ||
|
|
1a042593d5 | ||
|
|
9826d098f6 | ||
|
|
279226594c | ||
|
|
0f9d760bb0 | ||
|
|
016869aba0 | ||
|
|
101dc3814c | ||
|
|
3442b5fa4e |
@@ -4,7 +4,14 @@
|
|||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
Permission-aware global and contextual search for GovOPlaN.
|
Permission-aware global and contextual search for GovOPlaN. Search is the first
|
||||||
|
command in the titlebar action group. Clicking its icon, pressing `F3`, or
|
||||||
|
pressing `Ctrl`/`Cmd`+`K` opens the same full query field and result overlay.
|
||||||
|
|
||||||
|
The titlebar command, result overlay, filters, and Search administration route
|
||||||
|
announce stable help contexts. Pressing `F1` while one of those controls is
|
||||||
|
focused opens its Search documentation, with the current page retained as a
|
||||||
|
fallback.
|
||||||
|
|
||||||
The route, overlay, state, accessibility, and consequence mapping is recorded in
|
The route, overlay, state, accessibility, and consequence mapping is recorded in
|
||||||
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
@@ -48,3 +55,17 @@ Files, Campaign, Calendar, Mail, IDM, and Postbox provide native source
|
|||||||
adapters. Mail indexes only its bounded read-only cache, and Postbox never
|
adapters. Mail indexes only its bounded read-only cache, and Postbox never
|
||||||
indexes ciphertext or key material. All six recheck current source-owned
|
indexes ciphertext or key material. All six recheck current source-owned
|
||||||
authorization when results are returned.
|
authorization when results are returned.
|
||||||
|
|
||||||
|
## Data-subject requests
|
||||||
|
|
||||||
|
Search publishes `privacy.dsar.search` for derived index documents, queued
|
||||||
|
changes, and minimized ACL projections. Exact Search or source-module
|
||||||
|
references locate derived copies without exporting indexed text, URLs,
|
||||||
|
metadata, token values, hashes, cursors, queued payloads, or errors. Account,
|
||||||
|
identity, and membership matches describe access projections only and do not
|
||||||
|
establish ownership of source content.
|
||||||
|
|
||||||
|
Derived documents and queued changes can be purged idempotently. ACL-only
|
||||||
|
matches require review at the source authority. The authoritative module must
|
||||||
|
be corrected or erased before a rebuild; otherwise its provider may republish
|
||||||
|
the derived Search row.
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ 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 and anchored overlay | Global or context-sensitive focused lookup | F3 and Ctrl/Cmd+K open the same focus-contained Core dialog. 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.15",
|
"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.15",
|
"@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.15"
|
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.15"]
|
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.15"
|
__version__ = "0.1.20"
|
||||||
|
|||||||
@@ -0,0 +1,576 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import tuple_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_search.backend.db.models import (
|
||||||
|
SearchIndexAclToken,
|
||||||
|
SearchIndexChangeQueue,
|
||||||
|
SearchIndexDocument,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SEARCH_DSAR_CAPABILITY = dsar_capability_name("search")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
_RESERVED_REFERENCE_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"search.account",
|
||||||
|
"search.identity",
|
||||||
|
"search.membership",
|
||||||
|
"access.account",
|
||||||
|
"identity.id",
|
||||||
|
"tenancy.membership",
|
||||||
|
"search.document",
|
||||||
|
"search.index_document",
|
||||||
|
"search.change",
|
||||||
|
"search.queued_change",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SourceReference:
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
account_id: str | None
|
||||||
|
identity_id: str | None
|
||||||
|
membership_id: str | None
|
||||||
|
document_id: str | None
|
||||||
|
change_id: str | None
|
||||||
|
source_references: tuple[_SourceReference, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def acl_tokens(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
token
|
||||||
|
for token in (
|
||||||
|
f"account:{self.account_id}" if self.account_id else None,
|
||||||
|
f"identity:{self.identity_id}" if self.identity_id else None,
|
||||||
|
f"membership:{self.membership_id}" if self.membership_id else None,
|
||||||
|
)
|
||||||
|
if token
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_direct_selector(self) -> bool:
|
||||||
|
return bool(self.document_id or self.change_id or self.source_references)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ResourceIdentity:
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SearchDsarProvider:
|
||||||
|
provider_id = "search"
|
||||||
|
module_id = "search"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None or not (
|
||||||
|
selectors.acl_tokens or selectors.has_direct_selector
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
direct = _direct_rows(db, tenant_id=tenant_id, selectors=selectors)
|
||||||
|
if direct is None:
|
||||||
|
return ()
|
||||||
|
documents, changes = direct
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
def append(record: DsarRecordRef) -> None:
|
||||||
|
key = (record.resource_type, record.resource_id)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Search DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
for row in documents:
|
||||||
|
append(_document_record(row))
|
||||||
|
for row in changes:
|
||||||
|
append(_change_record(row))
|
||||||
|
for row, document in _acl_rows(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
selectors=selectors,
|
||||||
|
direct_identities={_identity(item) for item in (*documents, *changes)},
|
||||||
|
):
|
||||||
|
append(_acl_record(row, document=document))
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Search DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
executable = record.resource_type in {
|
||||||
|
"search_index_document",
|
||||||
|
"search_index_change",
|
||||||
|
}
|
||||||
|
kind = "delete" if executable else "manual_review"
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"search:{kind}:{record.resource_type}:{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind=kind,
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=(
|
||||||
|
f"Delete {record.title}"
|
||||||
|
if executable
|
||||||
|
else f"Review {record.title}"
|
||||||
|
),
|
||||||
|
rationale=(
|
||||||
|
"The row is a derived Search cache or queued copy and can "
|
||||||
|
"be removed without changing the authoritative source."
|
||||||
|
if executable
|
||||||
|
else "The ACL token is a derived authorization projection; correct or revoke authority at the source before rebuilding Search."
|
||||||
|
),
|
||||||
|
executable=executable,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
db = _session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Search DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if not action.executable:
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Correct the source-owned authorization before the "
|
||||||
|
"next Search rebuild."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
model = {
|
||||||
|
"search_index_document": SearchIndexDocument,
|
||||||
|
"search_index_change": SearchIndexChangeQueue,
|
||||||
|
}.get(action.resource_type)
|
||||||
|
if model is None or action.kind != "delete":
|
||||||
|
raise ValueError("Search DSAR executable action is not supported.")
|
||||||
|
row = (
|
||||||
|
db.query(model)
|
||||||
|
.filter(
|
||||||
|
model.tenant_id == tenant_id,
|
||||||
|
model.id == action.resource_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
status = "unchanged"
|
||||||
|
summary = "Derived Search row was already absent."
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
status = "executed"
|
||||||
|
summary = (
|
||||||
|
"Derived Search row removed; the source owner remains "
|
||||||
|
"authoritative and must be corrected before any rebuild."
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_rows(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> tuple[list[SearchIndexDocument], list[SearchIndexChangeQueue]] | None:
|
||||||
|
documents: dict[str, SearchIndexDocument] = {}
|
||||||
|
changes: dict[str, SearchIndexChangeQueue] = {}
|
||||||
|
direct_identities: list[_ResourceIdentity] = []
|
||||||
|
source_identities: set[_ResourceIdentity] = set()
|
||||||
|
|
||||||
|
if selectors.document_id:
|
||||||
|
row = (
|
||||||
|
session.query(SearchIndexDocument)
|
||||||
|
.filter(
|
||||||
|
SearchIndexDocument.tenant_id == tenant_id,
|
||||||
|
SearchIndexDocument.id == selectors.document_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
documents[row.id] = row
|
||||||
|
direct_identities.append(_identity(row))
|
||||||
|
|
||||||
|
if selectors.change_id:
|
||||||
|
row = (
|
||||||
|
session.query(SearchIndexChangeQueue)
|
||||||
|
.filter(
|
||||||
|
SearchIndexChangeQueue.tenant_id == tenant_id,
|
||||||
|
SearchIndexChangeQueue.id == selectors.change_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
changes[row.id] = row
|
||||||
|
direct_identities.append(_identity(row))
|
||||||
|
|
||||||
|
if len(set(direct_identities)) > 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for reference in selectors.source_references:
|
||||||
|
document_rows = (
|
||||||
|
session.query(SearchIndexDocument)
|
||||||
|
.filter(
|
||||||
|
SearchIndexDocument.tenant_id == tenant_id,
|
||||||
|
SearchIndexDocument.module_id == reference.module_id,
|
||||||
|
SearchIndexDocument.resource_type == reference.resource_type,
|
||||||
|
SearchIndexDocument.resource_id == reference.resource_id,
|
||||||
|
)
|
||||||
|
.order_by(SearchIndexDocument.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
change_rows = (
|
||||||
|
session.query(SearchIndexChangeQueue)
|
||||||
|
.filter(
|
||||||
|
SearchIndexChangeQueue.tenant_id == tenant_id,
|
||||||
|
SearchIndexChangeQueue.module_id == reference.module_id,
|
||||||
|
SearchIndexChangeQueue.resource_type == reference.resource_type,
|
||||||
|
SearchIndexChangeQueue.resource_id == reference.resource_id,
|
||||||
|
)
|
||||||
|
.order_by(SearchIndexChangeQueue.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not document_rows and not change_rows:
|
||||||
|
return None
|
||||||
|
source_identities.update(
|
||||||
|
_identity(row) for row in (*document_rows, *change_rows)
|
||||||
|
)
|
||||||
|
documents.update((row.id, row) for row in document_rows)
|
||||||
|
changes.update((row.id, row) for row in change_rows)
|
||||||
|
|
||||||
|
if direct_identities and selectors.source_references:
|
||||||
|
if not set(direct_identities).issubset(source_identities):
|
||||||
|
return None
|
||||||
|
if len(documents) + len(changes) > _MAX_RECORDS:
|
||||||
|
raise ValueError("Search DSAR result limit exceeded; narrow the selectors.")
|
||||||
|
return (
|
||||||
|
sorted(documents.values(), key=lambda row: row.id),
|
||||||
|
sorted(changes.values(), key=lambda row: row.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _acl_rows(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
direct_identities: set[_ResourceIdentity],
|
||||||
|
) -> list[tuple[SearchIndexAclToken, SearchIndexDocument]]:
|
||||||
|
if not selectors.acl_tokens:
|
||||||
|
return []
|
||||||
|
query = (
|
||||||
|
session.query(SearchIndexAclToken, SearchIndexDocument)
|
||||||
|
.join(
|
||||||
|
SearchIndexDocument,
|
||||||
|
SearchIndexDocument.id == SearchIndexAclToken.document_id,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
SearchIndexDocument.tenant_id == tenant_id,
|
||||||
|
SearchIndexAclToken.token.in_(selectors.acl_tokens),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if direct_identities:
|
||||||
|
query = query.filter(
|
||||||
|
tuple_(
|
||||||
|
SearchIndexDocument.module_id,
|
||||||
|
SearchIndexDocument.resource_type,
|
||||||
|
SearchIndexDocument.resource_id,
|
||||||
|
).in_(
|
||||||
|
sorted(
|
||||||
|
(
|
||||||
|
identity.module_id,
|
||||||
|
identity.resource_type,
|
||||||
|
identity.resource_id,
|
||||||
|
)
|
||||||
|
for identity in direct_identities
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
query.order_by(SearchIndexDocument.id, SearchIndexAclToken.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError("Search DSAR result limit exceeded; narrow the selectors.")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(row: object) -> _ResourceIdentity:
|
||||||
|
return _ResourceIdentity(
|
||||||
|
module_id=str(getattr(row, "module_id")),
|
||||||
|
resource_type=str(getattr(row, "resource_type")),
|
||||||
|
resource_id=str(getattr(row, "resource_id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_record(row: SearchIndexDocument) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"search_index_document",
|
||||||
|
row.id,
|
||||||
|
"derived_search_document",
|
||||||
|
"Derived Search document",
|
||||||
|
{
|
||||||
|
"module_id": row.module_id,
|
||||||
|
"provider_id": row.provider_id,
|
||||||
|
"resource_type": row.resource_type,
|
||||||
|
"resource_id": row.resource_id,
|
||||||
|
"source_revision": row.source_revision,
|
||||||
|
"source_updated_at": _iso(row.source_updated_at),
|
||||||
|
"visibility": row.visibility,
|
||||||
|
"index_version": row.index_version,
|
||||||
|
"requires_authorization_recheck": row.requires_authorization_recheck,
|
||||||
|
"active": row.active,
|
||||||
|
"indexed_at": _iso(row.indexed_at),
|
||||||
|
},
|
||||||
|
observed_at=row.indexed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _change_record(row: SearchIndexChangeQueue) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"search_index_change",
|
||||||
|
row.id,
|
||||||
|
"derived_search_change",
|
||||||
|
"Queued Search change",
|
||||||
|
{
|
||||||
|
"module_id": row.module_id,
|
||||||
|
"provider_id": row.provider_id,
|
||||||
|
"resource_type": row.resource_type,
|
||||||
|
"resource_id": row.resource_id,
|
||||||
|
"kind": row.kind,
|
||||||
|
"source_revision": row.source_revision,
|
||||||
|
"occurred_at": _iso(row.occurred_at),
|
||||||
|
"status": row.status,
|
||||||
|
"attempts": row.attempts,
|
||||||
|
"available_at": _iso(row.available_at),
|
||||||
|
"processed_at": _iso(row.processed_at),
|
||||||
|
},
|
||||||
|
observed_at=row.occurred_at or row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _acl_record(
|
||||||
|
row: SearchIndexAclToken,
|
||||||
|
*,
|
||||||
|
document: SearchIndexDocument,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"search_acl_projection",
|
||||||
|
row.id,
|
||||||
|
"derived_access_projection",
|
||||||
|
"Search access projection",
|
||||||
|
{
|
||||||
|
"module_id": document.module_id,
|
||||||
|
"resource_type": document.resource_type,
|
||||||
|
"resource_id": document.resource_id,
|
||||||
|
"token_kind": row.token.partition(":")[0],
|
||||||
|
"active": document.active,
|
||||||
|
"indexed_at": _iso(document.indexed_at),
|
||||||
|
},
|
||||||
|
observed_at=document.indexed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
values = {
|
||||||
|
"account_id": _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("search.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
),
|
||||||
|
"identity_id": _coalesce(
|
||||||
|
subject.identity_id,
|
||||||
|
references.get("search.identity"),
|
||||||
|
references.get("identity.id"),
|
||||||
|
),
|
||||||
|
"membership_id": _coalesce(
|
||||||
|
subject.membership_id,
|
||||||
|
references.get("search.membership"),
|
||||||
|
references.get("tenancy.membership"),
|
||||||
|
),
|
||||||
|
"document_id": _coalesce(
|
||||||
|
references.get("search.document"),
|
||||||
|
references.get("search.index_document"),
|
||||||
|
),
|
||||||
|
"change_id": _coalesce(
|
||||||
|
references.get("search.change"),
|
||||||
|
references.get("search.queued_change"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if any(value is _CONFLICT for value in values.values()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
source_references: list[_SourceReference] = []
|
||||||
|
for key, raw_value in sorted(references.items()):
|
||||||
|
if key in _RESERVED_REFERENCE_NAMES:
|
||||||
|
continue
|
||||||
|
module_id, separator, resource_type = key.partition(".")
|
||||||
|
value = str(raw_value or "").strip()
|
||||||
|
if (
|
||||||
|
not separator
|
||||||
|
or not module_id
|
||||||
|
or module_id == "search"
|
||||||
|
or not resource_type
|
||||||
|
or not value
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
source_references.append(
|
||||||
|
_SourceReference(
|
||||||
|
module_id=module_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _SubjectSelectors(
|
||||||
|
account_id=_optional_string(values["account_id"]),
|
||||||
|
identity_id=_optional_string(values["identity_id"]),
|
||||||
|
membership_id=_optional_string(values["membership_id"]),
|
||||||
|
document_id=_optional_string(values["document_id"]),
|
||||||
|
change_id=_optional_string(values["change_id"]),
|
||||||
|
source_references=tuple(source_references),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
data: dict[str, object],
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="search",
|
||||||
|
module_id="search",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data={key: value for key, value in data.items() if value is not None},
|
||||||
|
observed_at=_aware(observed_at),
|
||||||
|
source_path="/admin/search",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Search DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "search" or record.module_id != "search":
|
||||||
|
raise ValueError("Search DSAR cannot plan a foreign provider record.")
|
||||||
|
if not record.resource_type or not record.resource_id:
|
||||||
|
raise ValueError("Search DSAR record identity is incomplete.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "search" or action.module_id != "search":
|
||||||
|
raise ValueError("Search DSAR cannot execute a foreign provider action.")
|
||||||
|
if not action.action_id.startswith("search:"):
|
||||||
|
raise ValueError("Search DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SEARCH_DSAR_CAPABILITY", "SearchDsarProvider"]
|
||||||
@@ -11,6 +11,8 @@ from govoplan_core.core.module_guards import (
|
|||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -30,11 +32,15 @@ from govoplan_core.core.search import (
|
|||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_search.backend.db import models as search_models
|
from govoplan_search.backend.db import models as search_models
|
||||||
|
from govoplan_search.backend.dsar_provider import (
|
||||||
|
SEARCH_DSAR_CAPABILITY,
|
||||||
|
SearchDsarProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "search"
|
MODULE_ID = "search"
|
||||||
MODULE_NAME = "Search"
|
MODULE_NAME = "Search"
|
||||||
MODULE_VERSION = "0.1.15"
|
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"
|
||||||
@@ -101,6 +107,11 @@ def _router(_context: ModuleContext):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> SearchDsarProvider:
|
||||||
|
del context
|
||||||
|
return SearchDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
@@ -122,6 +133,7 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="search.provider", version="1.0.0"),
|
ModuleInterfaceProvider(name="search.provider", version="1.0.0"),
|
||||||
ModuleInterfaceProvider(name="search.index_writer", version="1.1.0"),
|
ModuleInterfaceProvider(name="search.index_writer", version="1.1.0"),
|
||||||
ModuleInterfaceProvider(name="search.source", version="1.0.0"),
|
ModuleInterfaceProvider(name="search.source", version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(name=SEARCH_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
@@ -189,6 +201,14 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_SEARCH_INDEX_WRITER: _service,
|
CAPABILITY_SEARCH_INDEX_WRITER: _service,
|
||||||
|
SEARCH_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
SEARCH_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Search data-subject request provider",
|
||||||
|
summary="Finds and purges derived index copies while preserving source authority.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
search_providers=(
|
search_providers=(
|
||||||
SearchProviderRegistration(
|
SearchProviderRegistration(
|
||||||
@@ -198,6 +218,84 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="search.data-subject-requests",
|
||||||
|
title="Derived Search data-subject requests",
|
||||||
|
summary="Remove derived index copies without treating Search as the authoritative data owner.",
|
||||||
|
body=(
|
||||||
|
"Search correlates explicit index/change identifiers and provider-owned source references inside the exact tenant. Account, identity, and membership selectors expose only minimized ACL projections and never imply ownership of the indexed source object. Indexed title, summary, body, search text, URL, keywords, metadata, external-reference payloads, ACL token values, hashes, cursors, queued documents, and errors are excluded. "
|
||||||
|
"Derived document and queued-change rows may be deleted idempotently. ACL-only matches require source-authority review. Source correction or erasure must happen in the owner module before Search is rebuilt; otherwise the source can legitimately republish the derived entry."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "access"),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"search.data-subject-requests",
|
||||||
|
"search.admin.index",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"purge_derived_index": (
|
||||||
|
"Deletes matching derived documents and queued changes idempotently."
|
||||||
|
),
|
||||||
|
"preserve_authoritative_source": (
|
||||||
|
"Does not correct or erase the authoritative object owned by its source module."
|
||||||
|
),
|
||||||
|
"review_acl_only_matches": (
|
||||||
|
"Requires source-authority review when only an ACL projection identifies the subject."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen zum abgeleiteten Suchindex",
|
||||||
|
"summary": (
|
||||||
|
"Abgeleitete Indexkopien entfernen, ohne Search als führende Quelle "
|
||||||
|
"der indexierten Objekte zu behandeln."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Search gleicht innerhalb des exakten Mandanten nur ausdrückliche Index- "
|
||||||
|
"oder Änderungskennungen und quellanbietergeführte Referenzen ab. Selektoren "
|
||||||
|
"für Konten, Identitäten und Mitgliedschaften legen ausschließlich minimierte "
|
||||||
|
"Berechtigungsprojektionen offen und begründen niemals die Eigentümerschaft am "
|
||||||
|
"indexierten Quellobjekt. Indexierter Titel, Zusammenfassung, Inhalt, Suchtext, "
|
||||||
|
"URL, Schlüsselwörter, Metadaten, externe Referenzinhalte, Berechtigungswerte, "
|
||||||
|
"Prüfsummen, Cursor, eingereihte Dokumente und Fehler bleiben ausgeschlossen. "
|
||||||
|
"Abgeleitete Dokumente und eingereihte Änderungen können idempotent gelöscht "
|
||||||
|
"werden. Treffer allein über Berechtigungsprojektionen erfordern eine Prüfung "
|
||||||
|
"durch die führende Quelle. Berichtigung oder Löschung muss im Eigentümermodul "
|
||||||
|
"erfolgen, bevor Search neu aufgebaut wird; andernfalls darf die Quelle den "
|
||||||
|
"abgeleiteten Eintrag erneut veröffentlichen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"purge_derived_index": (
|
||||||
|
"Löscht passende abgeleitete Dokumente und eingereihte Änderungen idempotent."
|
||||||
|
),
|
||||||
|
"preserve_authoritative_source": (
|
||||||
|
"Berichtigt oder löscht nicht das führende Objekt des jeweiligen Quellmoduls."
|
||||||
|
),
|
||||||
|
"review_acl_only_matches": (
|
||||||
|
"Erfordert eine Prüfung durch die führende Quelle, wenn die betroffene Person nur über eine Berechtigungsprojektion erkannt wird."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Search index lifecycle",
|
||||||
|
href="govoplan-search/README.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
order=11,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="search.global-and-contextual",
|
id="search.global-and-contextual",
|
||||||
title="Global and contextual search",
|
title="Global and contextual search",
|
||||||
@@ -206,12 +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. F3 or the title-bar field opens the "
|
"remain optional adapters. The title-bar Search command, F3, "
|
||||||
|
"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 "
|
||||||
@@ -222,6 +328,122 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("administrator", "user"),
|
audience=("administrator", "user"),
|
||||||
related_modules=("connectors", "views"),
|
related_modules=("connectors", "views"),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"search.global",
|
||||||
|
"search.results",
|
||||||
|
"search.filters",
|
||||||
|
"search.admin.index",
|
||||||
|
],
|
||||||
|
"purpose": (
|
||||||
|
"Find currently authorized native and connected objects without broadening source permissions."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"The actor has Search read access and the source module authorizes each returned object now.",
|
||||||
|
"At least one enabled source provider has published or can supply a searchable projection.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Open Search from the title bar, F3, or Ctrl/Cmd+K and enter a precise term.",
|
||||||
|
"Use the shared Modules and Result types dropdowns on the overlay or full Search page. Multiple values in one group are alternatives; both groups must match.",
|
||||||
|
"Select all removes a group's restriction. Deselect all intentionally shows no results; select an option or use Clear filters to search again.",
|
||||||
|
"In the overlay, a module context bounds the available result types. An incompatible type selection shows no results rather than expanding to other types. Clear filters keeps the selected context and query.",
|
||||||
|
"Use Tab to reach filter controls and Space to toggle an option. Escape closes the open dropdown first and returns focus to its button; another Escape closes the Search overlay.",
|
||||||
|
"Review partial-provider and quarantine diagnostics before relying on completeness.",
|
||||||
|
"Open an authorized result; Search rechecks the provider-owned object permission before disclosure.",
|
||||||
|
"Administrators may process queued changes, reconcile providers, or start a bounded rebuild when diagnostics require it.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Search is a derived discovery layer and is never authoritative for source content or access decisions.",
|
||||||
|
"A provider failure can make results incomplete but never permits unsafe results to bypass ACL filtering.",
|
||||||
|
"Filter choices describe the available source catalogue and observed results; they are not permission-filtered result counts or a promise that a source has matching objects.",
|
||||||
|
"The full Search page preserves query and filter selections in its URL, including explicit no-selection states. The title-bar overlay keeps its selections locally while mounted.",
|
||||||
|
],
|
||||||
|
"operational_consequences": {
|
||||||
|
"process_queue": "Applies pending derived index changes under current tenant and provider boundaries.",
|
||||||
|
"reconcile": "Compares enabled source coverage and retains unresolved changes in quarantine.",
|
||||||
|
"rebuild": "Recreates bounded derived projections while source data remains authoritative.",
|
||||||
|
"change_filters": "Discards obsolete result pages when query, filters, context, account/tenant authority, or API identity changes, even if the API token is unchanged. Selecting no modules or no result types performs no provider search and does not change source data or permissions.",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"Every displayed result names its source and remains openable by the current principal.",
|
||||||
|
"Partial-provider failures and quarantined changes remain visible as diagnostics.",
|
||||||
|
"Administrative rebuild status can be reconciled against the enabled provider inventory.",
|
||||||
|
"Select all, a subset, and no options produce distinct states; a full-page URL reload preserves the chosen filters without discarding the query or context.",
|
||||||
|
"Changing filters during a pending request never appends the previous query's next page to the new result set.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Globale und kontextbezogene Suche",
|
||||||
|
"summary": (
|
||||||
|
"Berechtigte native und angebundene Objekte über eine gemeinsame, "
|
||||||
|
"berechtigungsbewusste Oberfläche finden."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||||
|
"Kontextbezeichnung für Suche und Suchindex, nicht zwischen ausführbaren "
|
||||||
|
"Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
|
||||||
|
"Search verwendet den eingebauten Datenbankindex und kann optionale Anbieter "
|
||||||
|
"zusammenführen. Quellmodule melden durchsuchbare Typen, Kontextbereiche und "
|
||||||
|
"berechtigungsgeprüfte Indexeinträge. Externe Suchmaschinen bleiben optionale "
|
||||||
|
"Adapter. Der Suchbefehl in der Titelleiste, F3 oder Strg/Cmd+K öffnet die per "
|
||||||
|
"Tastatur bedienbare Suchüberlagerung; Filter erweitern niemals die "
|
||||||
|
"Quellberechtigungen der aktuellen Person. Überlagerung und vollständige "
|
||||||
|
"Suchseite verwenden dieselben Auswahllisten für Module und Ergebnistypen. "
|
||||||
|
"Alle auswählen lässt eine Gruppe uneingeschränkt; alle Optionen abzuwählen "
|
||||||
|
"liefert bewusst keine Ergebnisse. Filter zurücksetzen behält Suchbegriff "
|
||||||
|
"und aktuellen Kontext bei. Anbieterausfälle werden als partielle "
|
||||||
|
"Diagnosen angezeigt, ohne sichere Ergebnisse zu verwerfen. Administratoren "
|
||||||
|
"können die Abdeckung nativer Quellen prüfen, eingereihte Änderungen verarbeiten, "
|
||||||
|
"aktivierte Module abgleichen und begrenzte Neuaufbauten aus der Administration "
|
||||||
|
"starten. Änderungen in Quarantäne bleiben sichtbar, bis sie repariert und "
|
||||||
|
"abgeglichen wurden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"purpose": (
|
||||||
|
"Aktuell berechtigte native und angebundene Objekte finden, ohne Quellberechtigungen zu erweitern."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"Die handelnde Person darf Search lesen und das Quellmodul autorisiert jedes zurückgegebene Objekt weiterhin.",
|
||||||
|
"Mindestens ein aktivierter Quellanbieter hat eine durchsuchbare Projektion veröffentlicht oder kann sie bereitstellen.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Search über die Titelleiste, F3 oder Strg/Cmd+K öffnen und einen präzisen Suchbegriff eingeben.",
|
||||||
|
"In der Überlagerung oder auf der vollständigen Suchseite die gemeinsamen Auswahllisten Module und Ergebnistypen verwenden. Mehrere Werte innerhalb einer Gruppe gelten als Alternativen; beide Gruppen müssen passen.",
|
||||||
|
"Alle auswählen hebt die Einschränkung einer Gruppe auf. Alle abwählen zeigt bewusst keine Ergebnisse; eine Option auswählen oder Filter zurücksetzen verwenden, um erneut zu suchen.",
|
||||||
|
"In der Überlagerung begrenzt ein Modulkontext die verfügbaren Ergebnistypen. Eine unvereinbare Typauswahl zeigt keine Ergebnisse, statt die Suche auf andere Typen auszuweiten. Filter zurücksetzen behält gewählten Kontext und Suchbegriff bei.",
|
||||||
|
"Mit Tab die Filtersteuerung erreichen und mit der Leertaste eine Option umschalten. Escape schließt zuerst die geöffnete Auswahlliste und setzt den Fokus auf ihre Schaltfläche zurück; ein weiteres Escape schließt die Suchüberlagerung.",
|
||||||
|
"Diagnosen zu partiellen Anbieterausfällen und Quarantäne prüfen, bevor Vollständigkeit angenommen wird.",
|
||||||
|
"Ein berechtigtes Ergebnis öffnen; Search prüft vor der Offenlegung erneut die Berechtigung am quellengeführten Objekt.",
|
||||||
|
"Administratoren können bei entsprechenden Diagnosen eingereihte Änderungen verarbeiten, Anbieter abgleichen oder einen begrenzten Neuaufbau starten.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Search ist eine abgeleitete Auffindbarkeitsschicht und niemals führend für Quellinhalte oder Zugriffsentscheidungen.",
|
||||||
|
"Ein Anbieterausfall kann Ergebnisse unvollständig machen, erlaubt aber niemals das Umgehen der Berechtigungsfilterung.",
|
||||||
|
"Filteroptionen beschreiben den verfügbaren Quellenkatalog und beobachtete Ergebnisse; sie sind weder berechtigungsgefilterte Trefferzahlen noch eine Zusage, dass eine Quelle passende Objekte enthält.",
|
||||||
|
"Die vollständige Suchseite erhält Suchbegriff und Filterauswahl in ihrer URL, auch ausdrücklich leere Auswahlen. Die Überlagerung der Titelleiste behält ihre Auswahl lokal, solange sie eingebunden bleibt.",
|
||||||
|
],
|
||||||
|
"operational_consequences": {
|
||||||
|
"process_queue": "Verarbeitet ausstehende abgeleitete Indexänderungen innerhalb der aktuellen Mandanten- und Anbietergrenzen.",
|
||||||
|
"reconcile": "Vergleicht die Abdeckung aktivierter Quellen und hält ungeklärte Änderungen in Quarantäne.",
|
||||||
|
"rebuild": "Erstellt begrenzte abgeleitete Projektionen neu, während die Quelldaten führend bleiben.",
|
||||||
|
"change_filters": "Verwirft veraltete Ergebnisseiten, wenn sich Suchbegriff, Filter, Kontext, Konto, Mandant, Berechtigungen oder API-Identität ändern, auch bei unverändertem API-Token. Eine leere Modul- oder Ergebnistypauswahl führt keine Anbietersuche aus und ändert weder Quelldaten noch Berechtigungen.",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"Jedes angezeigte Ergebnis nennt seine Quelle und kann von der aktuellen Person weiterhin geöffnet werden.",
|
||||||
|
"Partielle Anbieterausfälle und Änderungen in Quarantäne bleiben als Diagnosen sichtbar.",
|
||||||
|
"Der administrative Neuaufbaustatus lässt sich mit dem Inventar aktivierter Anbieter abgleichen.",
|
||||||
|
"Alle auswählen, eine Teilmenge und keine Optionen ergeben unterschiedliche Zustände; das Neuladen der vollständigen Suchseiten-URL erhält die gewählten Filter, ohne Suchbegriff oder Kontext zu verwerfen.",
|
||||||
|
"Ein Filterwechsel während einer laufenden Anfrage hängt niemals die nächste Seite des vorherigen Suchbegriffs an die neue Ergebnisliste an.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Search interface pattern audit",
|
label="Search interface pattern audit",
|
||||||
@@ -238,10 +460,20 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="README.md",
|
documentation_ref="README.md",
|
||||||
test_ref="tests/test_postgres_search.py",
|
test_ref="tests/test_postgres_search.py",
|
||||||
known_limits=("The built-in PostgreSQL index is implemented; optional OpenSearch target evidence is not.",),
|
known_limits=(
|
||||||
|
"The built-in PostgreSQL index is implemented; optional OpenSearch target evidence is not.",
|
||||||
|
),
|
||||||
supported_authority_modes=("external_mirror",),
|
supported_authority_modes=("external_mirror",),
|
||||||
owned_concepts=("derived search index", "search ACL projection", "index change queue"),
|
owned_concepts=(
|
||||||
non_owned_concepts=("source object", "source authorization", "external search engine"),
|
"derived search index",
|
||||||
|
"search ACL projection",
|
||||||
|
"index change queue",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"source object",
|
||||||
|
"source authorization",
|
||||||
|
"external search engine",
|
||||||
|
),
|
||||||
recovery_docs=("README.md",),
|
recovery_docs=("README.md",),
|
||||||
security_docs=("README.md",),
|
security_docs=("README.md",),
|
||||||
operations_docs=("README.md",),
|
operations_docs=("README.md",),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
|
)
|
||||||
|
from govoplan_search.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class SearchDocumentationTests(unittest.TestCase):
|
||||||
|
def test_public_topics_have_complete_german_reference_content(self) -> None:
|
||||||
|
self.assertEqual(2, len(manifest.documentation))
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
translation = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(translation.get(key) for key in ("title", "summary", "body"))
|
||||||
|
)
|
||||||
|
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||||
|
|
||||||
|
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
|
||||||
|
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
|
||||||
|
self.assertIn("workflow", kinds)
|
||||||
|
self.assertIn("reference", kinds)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
self.assertEqual((), user_workflow_scope_condition_issues(topic))
|
||||||
|
|
||||||
|
def test_filter_workflow_documents_tri_state_context_and_stale_reads(self) -> None:
|
||||||
|
topic = next(
|
||||||
|
item
|
||||||
|
for item in manifest.documentation
|
||||||
|
if item.id == "search.global-and-contextual"
|
||||||
|
)
|
||||||
|
steps = " ".join(topic.metadata["steps"])
|
||||||
|
german = topic.structured_translations["de"]
|
||||||
|
german_steps = " ".join(german["steps"])
|
||||||
|
self.assertIn("Modules and Result types dropdowns", steps)
|
||||||
|
self.assertIn("Deselect all intentionally shows no results", steps)
|
||||||
|
self.assertIn("Clear filters keeps the selected context and query", steps)
|
||||||
|
self.assertIn("Escape closes the open dropdown first", steps)
|
||||||
|
self.assertIn("Alle abwählen zeigt bewusst keine Ergebnisse", german_steps)
|
||||||
|
self.assertIn("gewählten Kontext und Suchbegriff", german_steps)
|
||||||
|
self.assertIn("Escape schließt zuerst", german_steps)
|
||||||
|
for metadata in (topic.metadata, german):
|
||||||
|
self.assertIn("change_filters", metadata["operational_consequences"])
|
||||||
|
self.assertEqual(4, len(metadata["limitations"]))
|
||||||
|
self.assertEqual(5, len(metadata["verification"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_search.backend.db.models import (
|
||||||
|
SearchIndexAclToken,
|
||||||
|
SearchIndexChangeQueue,
|
||||||
|
SearchIndexDocument,
|
||||||
|
)
|
||||||
|
from govoplan_search.backend.dsar_provider import (
|
||||||
|
SEARCH_DSAR_CAPABILITY,
|
||||||
|
SearchDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_search.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 17, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: SearchDsarProvider, *, active: bool = True) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (SEARCH_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "search"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("search",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "search"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != SEARCH_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = SearchDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _document(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
row_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
) -> SearchIndexDocument:
|
||||||
|
return SearchIndexDocument(
|
||||||
|
id=row_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id="cases",
|
||||||
|
provider_id="cases.search",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id=resource_id,
|
||||||
|
title="private-title-do-not-export",
|
||||||
|
summary="private-summary-do-not-export",
|
||||||
|
body="private-body-do-not-export",
|
||||||
|
keywords=["private-keyword-do-not-export"],
|
||||||
|
search_text="private-search-text-do-not-export",
|
||||||
|
url="/private-url-do-not-export",
|
||||||
|
visibility="restricted",
|
||||||
|
external_reference={"secret": "private-reference-do-not-export"},
|
||||||
|
metadata_={"secret": "private-metadata-do-not-export"},
|
||||||
|
content_hash="a" * 64,
|
||||||
|
source_revision="revision-1",
|
||||||
|
change_cursor="private-cursor-do-not-export",
|
||||||
|
source_updated_at=NOW,
|
||||||
|
language="simple",
|
||||||
|
index_version=1,
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
rebuild_id="private-rebuild-id-do-not-export",
|
||||||
|
active=True,
|
||||||
|
indexed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
document = self._document(
|
||||||
|
row_id="document-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource_id="case-1",
|
||||||
|
)
|
||||||
|
document.acl_tokens.extend(
|
||||||
|
(
|
||||||
|
SearchIndexAclToken(
|
||||||
|
id="acl-1",
|
||||||
|
token="account:account-1",
|
||||||
|
),
|
||||||
|
SearchIndexAclToken(
|
||||||
|
id="acl-2",
|
||||||
|
token="identity:identity-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unrelated = self._document(
|
||||||
|
row_id="document-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
resource_id="case-2",
|
||||||
|
)
|
||||||
|
unrelated.acl_tokens.append(
|
||||||
|
SearchIndexAclToken(
|
||||||
|
id="acl-3",
|
||||||
|
token="account:account-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
other_tenant = self._document(
|
||||||
|
row_id="document-3",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
resource_id="case-1",
|
||||||
|
)
|
||||||
|
other_tenant.acl_tokens.append(
|
||||||
|
SearchIndexAclToken(
|
||||||
|
id="acl-4",
|
||||||
|
token="account:account-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add_all((document, unrelated, other_tenant))
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
SearchIndexChangeQueue(
|
||||||
|
id="change-1",
|
||||||
|
change_id="change-public-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id="cases.search",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
kind="upsert",
|
||||||
|
source_revision="revision-1",
|
||||||
|
source_cursor="private-source-cursor-do-not-export",
|
||||||
|
document_={"secret": "private-queue-document-do-not-export"},
|
||||||
|
occurred_at=NOW,
|
||||||
|
status="queued",
|
||||||
|
attempts=1,
|
||||||
|
available_at=NOW,
|
||||||
|
error="private-error-do-not-export",
|
||||||
|
),
|
||||||
|
SearchIndexChangeQueue(
|
||||||
|
id="change-2",
|
||||||
|
change_id="change-public-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id="cases.search",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-2",
|
||||||
|
kind="upsert",
|
||||||
|
source_revision="revision-1",
|
||||||
|
source_cursor="unrelated-private-cursor",
|
||||||
|
document_={"secret": "unrelated-private-document"},
|
||||||
|
occurred_at=NOW + timedelta(minutes=1),
|
||||||
|
status="queued",
|
||||||
|
attempts=0,
|
||||||
|
available_at=NOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_reference_exports_only_minimized_derived_rows(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"search_index_document", "search_index_change"},
|
||||||
|
{record.resource_type for record in records},
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertIn("case-1", exported)
|
||||||
|
self.assertNotIn("case-2", exported)
|
||||||
|
for secret in (
|
||||||
|
"private-title-do-not-export",
|
||||||
|
"private-summary-do-not-export",
|
||||||
|
"private-body-do-not-export",
|
||||||
|
"private-keyword-do-not-export",
|
||||||
|
"private-search-text-do-not-export",
|
||||||
|
"private-url-do-not-export",
|
||||||
|
"private-reference-do-not-export",
|
||||||
|
"private-metadata-do-not-export",
|
||||||
|
"private-cursor-do-not-export",
|
||||||
|
"private-rebuild-id-do-not-export",
|
||||||
|
"private-source-cursor-do-not-export",
|
||||||
|
"private-queue-document-do-not-export",
|
||||||
|
"private-error-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(secret, exported)
|
||||||
|
|
||||||
|
def test_canonical_selectors_export_acl_projection_not_source_content(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"search_acl_projection"},
|
||||||
|
{record.resource_type for record in records},
|
||||||
|
)
|
||||||
|
self.assertEqual(3, len(records))
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertNotIn("account-1", exported)
|
||||||
|
self.assertNotIn("identity-1", exported)
|
||||||
|
self.assertNotIn("private-title-do-not-export", exported)
|
||||||
|
self.assertNotIn("document-3", exported)
|
||||||
|
|
||||||
|
def test_direct_references_and_conflicts_fail_closed(self) -> None:
|
||||||
|
direct = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"search.document": "document-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"search.document": "document-1",
|
||||||
|
"cases.case": "case-2",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
alias_conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"search.document": "document-1",
|
||||||
|
"search.index_document": "document-2",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrong_tenant = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"search.document": "document-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrong_resource_type = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(external_references={"cases.ticket": "case-1"}),
|
||||||
|
)
|
||||||
|
change_and_account = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"search.change": "change-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["document-1"], [record.resource_id for record in direct])
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), alias_conflict)
|
||||||
|
self.assertEqual((), wrong_tenant)
|
||||||
|
self.assertEqual((), wrong_resource_type)
|
||||||
|
self.assertEqual(
|
||||||
|
{"change-1", "acl-1"},
|
||||||
|
{record.resource_id for record in change_and_account},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_derived_deletion_is_idempotent_but_acl_review_is_blocked(self) -> None:
|
||||||
|
direct_subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
|
||||||
|
direct_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=direct_subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=direct_subject,
|
||||||
|
records=direct_records,
|
||||||
|
)
|
||||||
|
self.assertTrue(actions)
|
||||||
|
self.assertTrue(all(action.kind == "delete" for action in actions))
|
||||||
|
self.assertTrue(all(action.executable for action in actions))
|
||||||
|
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=direct_subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=direct_subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1-retry",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(result.status == "executed" for result in first))
|
||||||
|
self.assertTrue(all(result.status == "unchanged" for result in second))
|
||||||
|
self.assertIsNone(self.session.get(SearchIndexDocument, "document-1"))
|
||||||
|
self.assertIsNone(self.session.get(SearchIndexChangeQueue, "change-1"))
|
||||||
|
|
||||||
|
acl_subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
acl_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=acl_subject,
|
||||||
|
)
|
||||||
|
acl_actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=acl_subject,
|
||||||
|
records=acl_records,
|
||||||
|
)
|
||||||
|
self.assertTrue(all(action.kind == "manual_review" for action in acl_actions))
|
||||||
|
self.assertTrue(all(not action.executable for action in acl_actions))
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=acl_subject,
|
||||||
|
actions=acl_actions,
|
||||||
|
request_id="dsar-2",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(
|
||||||
|
DsarRecordRef(
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
category="case",
|
||||||
|
title="Case",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="cases:delete:case:case-1",
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Delete case",
|
||||||
|
rationale="Foreign",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-SEARCH-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 and 17 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[SEARCH_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
|
||||||
|
)
|
||||||
|
self.assertEqual(2, row.search_result["record_count"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-SEARCH-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[SEARCH_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, inactive.search_result["record_count"])
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||||
|
self.assertIn(SEARCH_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(SEARCH_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||||
|
self.assertIn(
|
||||||
|
SEARCH_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "search.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__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.15",
|
"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.15",
|
"@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,19 +8,24 @@ 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("onFocus={handleSourceFocus}"), "focusing the titlebar field opens Search");
|
assert(source.includes("titlebar-icon-link titlebar-search-button"), "Search uses the shared titlebar icon-button appearance");
|
||||||
assert(source.includes("suppressRestoredFocusRef.current"), "restored dialog focus does not immediately reopen Search");
|
assert(source.includes("onClick={openOverlay}"), "clicking the titlebar Search command opens Search");
|
||||||
|
assert(!source.includes("sourceInputRef"), "the titlebar no longer reserves a persistent Search field");
|
||||||
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 is anchored to the titlebar field");
|
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");
|
||||||
assert(!source.includes("navigate(`/search"), "normal Search interaction no longer opens a page route");
|
assert(!source.includes("navigate(`/search"), "normal Search interaction no longer opens a page route");
|
||||||
assert(layoutSource.includes("anchor.left - left"), "desktop input placement is derived from the original field");
|
assert(layoutSource.includes("const inputWidth = width"), "the opened query field spans the Search overlay");
|
||||||
assert(styles.includes(".global-search-source.is-overlay-open"), "the original field is hidden while its overlay counterpart is active");
|
assert(layoutSource.includes("(viewportWidth - width) / 2"), "the Search overlay is centered in the viewport");
|
||||||
|
assert(styles.includes("margin-top: 8px"), "results follow the opened query field without overlap");
|
||||||
assert(styles.includes(".search-overlay-results-panel"), "full Search results have a bounded overlay panel");
|
assert(styles.includes(".search-overlay-results-panel"), "full Search results have a bounded overlay panel");
|
||||||
|
|
||||||
console.log("Search overlay structure checks passed.");
|
console.log("Search overlay structure checks passed.");
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type KeyboardEvent as ReactKeyboardEvent
|
type KeyboardEvent as ReactKeyboardEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import {
|
import { ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
@@ -25,13 +25,10 @@ import {
|
|||||||
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,
|
||||||
@@ -42,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();
|
||||||
@@ -61,77 +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<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLButtonElement>(null);
|
||||||
const sourceInputRef = useRef<HTMLInputElement>(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 suppressRestoredFocusRef = useRef(false);
|
|
||||||
|
|
||||||
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();
|
||||||
@@ -151,10 +98,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeOverlay = useCallback(() => {
|
const closeOverlay = useCallback(() => {
|
||||||
loadMoreControllerRef.current?.abort();
|
|
||||||
suppressRestoredFocusRef.current = true;
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setFiltersOpen(false);
|
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -163,14 +107,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, [measureOverlay]);
|
}, [measureOverlay]);
|
||||||
|
|
||||||
const handleSourceFocus = useCallback(() => {
|
|
||||||
if (suppressRestoredFocusRef.current) {
|
|
||||||
suppressRestoredFocusRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openOverlay();
|
|
||||||
}, [openOverlay]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function focusSearch(event: KeyboardEvent) {
|
function focusSearch(event: KeyboardEvent) {
|
||||||
const commandSearch =
|
const commandSearch =
|
||||||
@@ -188,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();
|
||||||
@@ -212,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
|
||||||
@@ -304,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) {
|
||||||
@@ -344,92 +174,31 @@ 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 (
|
||||||
<>
|
<>
|
||||||
<div
|
<button
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={`global-search global-search-source${open ? " is-overlay-open" : ""}`}>
|
type="button"
|
||||||
<Search size={16} aria-hidden="true" />
|
data-help-context-id="search.global"
|
||||||
<input
|
data-help-module-id="search"
|
||||||
ref={sourceInputRef}
|
data-help-scope="action"
|
||||||
type="search"
|
className={`titlebar-icon-link titlebar-search-button${open ? " is-context-active" : ""}`}
|
||||||
value={query}
|
title="Search (F3 / Ctrl+K)"
|
||||||
readOnly
|
aria-label="Global search"
|
||||||
tabIndex={open ? -1 : 0}
|
aria-keyshortcuts="F3 Control+K Meta+K"
|
||||||
placeholder={currentContext?.placeholder ?? "Search"}
|
aria-haspopup="dialog"
|
||||||
aria-label="Global search"
|
aria-expanded={open}
|
||||||
aria-keyshortcuts="F3 Control+K Meta+K"
|
onClick={openOverlay}>
|
||||||
aria-expanded={open}
|
<Search size={18} aria-hidden="true" />
|
||||||
onFocus={handleSourceFocus}
|
</button>
|
||||||
onClick={openOverlay}
|
|
||||||
/>
|
|
||||||
{query &&
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="global-search-clear"
|
|
||||||
tabIndex={open ? -1 : 0}
|
|
||||||
aria-label="Clear search"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery("");
|
|
||||||
setResponse(null);
|
|
||||||
}}>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"
|
||||||
onClose={closeOverlay}
|
onClose={closeOverlay}
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
portal
|
portal
|
||||||
@@ -480,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();
|
||||||
}}>
|
}}>
|
||||||
@@ -493,7 +261,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
<section
|
<section
|
||||||
className="search-overlay-results-panel"
|
className="search-overlay-results-panel"
|
||||||
style={{ height: layout.resultsHeight }}>
|
style={{ height: layout.resultsHeight }}>
|
||||||
<div className="search-overlay-toolbar">
|
<ActionToolbar className="search-overlay-toolbar">
|
||||||
{currentContext &&
|
{currentContext &&
|
||||||
<SegmentedControl<SearchScope>
|
<SegmentedControl<SearchScope>
|
||||||
className="search-overlay-scope"
|
className="search-overlay-scope"
|
||||||
@@ -511,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 &&
|
|
||||||
<span className="search-filter-count" aria-label={`${activeFilterCount} active filters`}>
|
|
||||||
{activeFilterCount}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</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} />}
|
||||||
@@ -600,31 +291,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|||||||
className="search-overlay-close"
|
className="search-overlay-close"
|
||||||
onClick={closeOverlay}
|
onClick={closeOverlay}
|
||||||
/>
|
/>
|
||||||
{activeFilterCount > 0 &&
|
</ActionToolbar>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PageScrollViewport
|
<PageScrollViewport
|
||||||
id="global-search-overlay-results"
|
id="global-search-overlay-results"
|
||||||
@@ -633,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>
|
||||||
}
|
}
|
||||||
@@ -698,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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,16 +33,9 @@ export function calculateSearchOverlayLayout(
|
|||||||
const mobile = viewportWidth < MOBILE_BREAKPOINT;
|
const mobile = viewportWidth < MOBILE_BREAKPOINT;
|
||||||
const margin = mobile ? MOBILE_MARGIN : DESKTOP_MARGIN;
|
const margin = mobile ? MOBILE_MARGIN : DESKTOP_MARGIN;
|
||||||
const width = Math.max(1, Math.min(DESKTOP_PANEL_WIDTH, viewportWidth - margin * 2));
|
const width = Math.max(1, Math.min(DESKTOP_PANEL_WIDTH, viewportWidth - margin * 2));
|
||||||
const centeredLeft = anchor.left + anchor.width / 2 - width / 2;
|
const left = Math.max(margin, (viewportWidth - width) / 2);
|
||||||
const left = mobile
|
const inputWidth = width;
|
||||||
? margin
|
const inputOffset = 0;
|
||||||
: clamp(centeredLeft, margin, Math.max(margin, viewportWidth - width - margin));
|
|
||||||
const inputWidth = mobile
|
|
||||||
? width
|
|
||||||
: Math.min(Math.max(1, anchor.width), width);
|
|
||||||
const inputOffset = mobile
|
|
||||||
? 0
|
|
||||||
: clamp(anchor.left - left, 0, Math.max(0, width - inputWidth));
|
|
||||||
const top = Math.max(0, anchor.top);
|
const top = Math.max(0, anchor.top);
|
||||||
const inputHeight = Math.max(1, anchor.height);
|
const inputHeight = Math.max(1, anchor.height);
|
||||||
const availableResultsHeight = viewportHeight - top - inputHeight - RESULTS_GAP - margin;
|
const availableResultsHeight = viewportHeight - top - inputHeight - RESULTS_GAP - margin;
|
||||||
@@ -89,7 +82,3 @@ function normalizePath(value: string): string {
|
|||||||
const normalized = `/${String(value || "").trim().replace(/^\/+|\/+$/g, "")}`;
|
const normalized = `/${String(value || "").trim().replace(/^\/+|\/+$/g, "")}`;
|
||||||
return normalized === "/" ? normalized : normalized.replace(/\/+$/g, "");
|
return normalized === "/" ? normalized : normalized.replace(/\/+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number, minimum: number, maximum: number): number {
|
|
||||||
return Math.min(Math.max(value, minimum), maximum);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { listSearchProviders, search, type SearchRequest, type SearchResourceType, type SearchResponse } from "../api/search";
|
||||||
|
|
||||||
|
export function useSearchCatalogue(settings: ApiSettings, enabled = true, authority = ""): SearchResourceType[] {
|
||||||
|
const identity = useMemo(() => ({}), [settings, enabled, authority]);
|
||||||
|
const [state, setState] = useState<{ identity: object; resources: SearchResourceType[] } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setState(null);
|
||||||
|
listSearchProviders(settings, controller.signal)
|
||||||
|
.then((result) => { if (!controller.signal.aborted) setState({ identity, resources: result.resources ?? [] }); })
|
||||||
|
.catch(() => { if (!controller.signal.aborted) setState(null); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [settings, enabled, identity]);
|
||||||
|
return enabled && state?.identity === identity ? state.resources : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultState = { identity: object; response: SearchResponse | null; loading: boolean; error: string };
|
||||||
|
|
||||||
|
/** One request lifecycle for route and overlay, including cursor cancellation. */
|
||||||
|
export function useSearchResults(settings: ApiSettings, request: SearchRequest, enabled = true, delay = 0, authority = "") {
|
||||||
|
const requestKey = JSON.stringify(request);
|
||||||
|
const identity = useMemo(() => ({ settings, request: JSON.parse(requestKey) as SearchRequest, enabled, delay }), [settings, requestKey, enabled, delay, authority]);
|
||||||
|
const currentIdentity = useRef(identity);
|
||||||
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
|
const [state, setState] = useState<ResultState | null>(null);
|
||||||
|
// Gate rendering as well as promise completion: stale results cannot be opened
|
||||||
|
// with Enter during the debounce interval or while an effect is being cleaned up.
|
||||||
|
currentIdentity.current = identity;
|
||||||
|
const visible = state?.identity === identity ? state : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
if (!enabled) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
setState({ identity, response: null, loading: true, error: "" });
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
search(settings, identity.request, controller.signal)
|
||||||
|
.then((response) => {
|
||||||
|
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response, loading: false, error: "" });
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response: null, loading: false, error: reason instanceof Error ? reason.message : "Search failed." });
|
||||||
|
});
|
||||||
|
}, identity.request.matchNone ? 0 : delay);
|
||||||
|
return () => { window.clearTimeout(timer); controller.abort(); controllerRef.current?.abort(); };
|
||||||
|
}, [identity, settings, enabled, delay]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
const cursor = visible?.response?.next_cursor;
|
||||||
|
if (!cursor || visible.loading || currentIdentity.current !== identity) return;
|
||||||
|
// Also guards a second click before React has rendered the loading state.
|
||||||
|
if (controllerRef.current?.signal.aborted === false && controllerRef.current !== null) controllerRef.current.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
setState((current) => current?.identity === identity ? { ...current, loading: true, error: "" } : current);
|
||||||
|
try {
|
||||||
|
const next = await search(settings, { ...identity.request, cursor }, controller.signal);
|
||||||
|
if (controller.signal.aborted || currentIdentity.current !== identity) return;
|
||||||
|
setState((current) => current?.identity === identity && current.response ? {
|
||||||
|
identity, loading: false, error: "", response: {
|
||||||
|
...next,
|
||||||
|
results: [...current.response.results, ...next.results],
|
||||||
|
diagnostics: [...current.response.diagnostics, ...next.diagnostics.filter((item) => !current.response!.diagnostics.some((previous) => previous.provider_id === item.provider_id))],
|
||||||
|
},
|
||||||
|
} : current);
|
||||||
|
} catch (reason) {
|
||||||
|
if (!controller.signal.aborted && currentIdentity.current === identity) setState((current) => current?.identity === identity ? { ...current, loading: false, error: reason instanceof Error ? reason.message : "Search failed." } : current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { response: visible?.response ?? null, error: visible?.error ?? "", loading: enabled && (visible?.loading ?? true), loadMore, requestIdentity: identity };
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AdminPageLayout,
|
AdminPageLayout,
|
||||||
@@ -191,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}
|
||||||
@@ -224,19 +228,16 @@ 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"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="metric-grid">
|
<MetricGrid>
|
||||||
<MetricCard label="Search sources" value={rows.length} tone="neutral" />
|
<MetricCard label="Search sources" value={rows.length} tone="neutral" />
|
||||||
<MetricCard label="Pending changes" value={pending} tone={pending ? "warning" : "good"} />
|
<MetricCard label="Pending changes" value={pending} tone={pending ? "warning" : "good"} />
|
||||||
<MetricCard label="Quarantined" value={quarantined} tone={quarantined ? "danger" : "good"} />
|
<MetricCard label="Quarantined" value={quarantined} tone={quarantined ? "danger" : "good"} />
|
||||||
<MetricCard label="Backend" value={diagnostics?.backend ?? "-"} tone="neutral" />
|
<MetricCard label="Backend" value={diagnostics?.backend ?? "-"} tone="neutral" />
|
||||||
</div>
|
</MetricGrid>
|
||||||
|
|
||||||
{quarantined > 0 && (
|
{quarantined > 0 && (
|
||||||
<DismissibleAlert tone="warning" dismissible={false} compact>
|
<DismissibleAlert tone="warning" dismissible={false} compact>
|
||||||
|
|||||||
@@ -1,184 +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 {
|
import { ActionToolbar, Button, DocumentationHelpLink, DismissibleAlert, LoadingIndicator, PageTitle,
|
||||||
Button,
|
PageScrollViewport, useGuardedNavigate, usePlatformModules, type PlatformRouteContext
|
||||||
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();
|
||||||
@@ -188,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">
|
||||||
<div 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
|
||||||
@@ -221,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 &&
|
|
||||||
<span className="search-filter-count" aria-label={`${activeFilterCount} active filters`}>
|
|
||||||
{activeFilterCount}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</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
|
</ActionToolbar>
|
||||||
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>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
}
|
}
|
||||||
@@ -370,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>
|
||||||
@@ -379,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",
|
||||||
|
|||||||
+7
-179
@@ -7,7 +7,7 @@
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--control-border);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--control-bg);
|
background: var(--control-bg);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-tight);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -53,10 +53,6 @@
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-search-source.is-overlay-open {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay-backdrop {
|
.search-overlay-backdrop {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -110,10 +106,10 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-top: -44px;
|
margin-top: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: var(--border-line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow-strong);
|
box-shadow: var(--shadow-strong);
|
||||||
}
|
}
|
||||||
@@ -143,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;
|
||||||
@@ -189,7 +181,7 @@
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
flex: 1 1 520px;
|
flex: 1 1 520px;
|
||||||
border: 1px solid var(--control-border);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--control-bg);
|
background: var(--control-bg);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
@@ -228,157 +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-count {
|
|
||||||
min-width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
padding: 0 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 6px;
|
|
||||||
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: 4px;
|
|
||||||
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: 4px;
|
|
||||||
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;
|
||||||
@@ -439,7 +280,7 @@
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
border: 1px solid var(--control-border);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
var(--control-gradient-start),
|
var(--control-gradient-start),
|
||||||
var(--control-gradient-end)
|
var(--control-gradient-end)
|
||||||
@@ -464,21 +305,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.global-search-source {
|
|
||||||
width: 34px;
|
|
||||||
min-width: 34px;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.global-search-source input,
|
|
||||||
.global-search-source .global-search-clear {
|
|
||||||
width: 0;
|
|
||||||
padding: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-overlay-results-panel {
|
.search-overlay-results-panel {
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-overlay-toolbar {
|
.search-overlay-toolbar {
|
||||||
|
|||||||
Reference in New Issue
Block a user