Compare commits
15 Commits
b89a2d15f1
...
v0.1.12
| Author | SHA1 | Date | |
|---|---|---|---|
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 | |||
| 987ca894ed | |||
| 4caa326878 | |||
| 8c4c4456c6 | |||
| 6abe292ac8 | |||
| fea2807754 | |||
| 22646c614c | |||
| 17376332a2 | |||
| 0946bc84a9 | |||
| a18499cbb5 | |||
| 36d7b73bb5 |
@@ -233,6 +233,15 @@ instead of reproducing their behavior.
|
|||||||
`disabledReason` for row state; omit an action only when that action does not
|
`disabledReason` for row state; omit an action only when that action does not
|
||||||
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||||
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||||
|
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
|
||||||
|
complete logical row set and applies filtering and sorting before slicing a
|
||||||
|
page. Server mode receives only the loaded page, requires `onQueryChange`,
|
||||||
|
and the backend applies every emitted filter/sort before pagination while
|
||||||
|
returning `totalRows` for the filtered result. Server list filters declare
|
||||||
|
their complete option domain instead of deriving it from the loaded page.
|
||||||
|
External filter affordances such as summary-count shortcuts update the
|
||||||
|
grid's `query` contract; the grid header controls and backend query therefore
|
||||||
|
always display and execute the same filter state.
|
||||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||||
|
|
||||||
|
|||||||
@@ -81,8 +81,8 @@
|
|||||||
],
|
],
|
||||||
"recorded_at": "2026-07-11T01:39:45Z",
|
"recorded_at": "2026-07-11T01:39:45Z",
|
||||||
"release": "0.1.7",
|
"release": "0.1.7",
|
||||||
"track": "release",
|
"squash_policy": "reviewed-manual",
|
||||||
"squash_policy": "reviewed-manual"
|
"track": "release"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heads": [
|
"heads": [
|
||||||
@@ -165,8 +165,256 @@
|
|||||||
],
|
],
|
||||||
"recorded_at": "2026-07-20T02:45:41Z",
|
"recorded_at": "2026-07-20T02:45:41Z",
|
||||||
"release": "0.1.8",
|
"release": "0.1.8",
|
||||||
"track": "release",
|
"squash_policy": "reviewed-manual",
|
||||||
"squash_policy": "reviewed-manual"
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "4d5e6f7a9203"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"4d5e6f7a9203"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T02:42:27Z",
|
||||||
|
"release": "0.1.11",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T07:06:21Z",
|
||||||
|
"release": "0.1.12",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 1
|
"version": 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.10"
|
version = "0.1.12"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ CELERY_ENABLED=true
|
|||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
# Deployment-wide connector egress policy. Enable private networks only when
|
# Deployment-wide connector egress policy. Enable private networks only when
|
||||||
# this installation intentionally integrates with internal services.
|
# this installation intentionally integrates with internal services.
|
||||||
@@ -405,6 +406,7 @@ REDIS_URL=redis://127.0.0.1:56379/0
|
|||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ class FrontendRoute:
|
|||||||
order: int = 100
|
order: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PublicFrontendRoute:
|
||||||
|
"""Explicitly allowlisted route that can render without authentication."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
component: str
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FrontendModule:
|
class FrontendModule:
|
||||||
module_id: str
|
module_id: str
|
||||||
@@ -80,6 +89,7 @@ class FrontendModule:
|
|||||||
asset_manifest_integrity: str | None = None
|
asset_manifest_integrity: str | None = None
|
||||||
asset_manifest_contract_version: str = "1"
|
asset_manifest_contract_version: str = "1"
|
||||||
routes: tuple[FrontendRoute, ...] = ()
|
routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
|
||||||
@@ -237,6 +247,35 @@ class DocumentationTopic:
|
|||||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||||
|
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||||
|
|
||||||
|
Topic conditions are alternatives. Every alternative therefore needs an
|
||||||
|
explicit scope constraint; otherwise one unscoped alternative would make
|
||||||
|
the workflow visible regardless of the actor's task authority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw_kind = topic.metadata.get("kind")
|
||||||
|
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||||
|
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||||
|
return ()
|
||||||
|
if not topic.conditions:
|
||||||
|
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||||
|
|
||||||
|
unscoped_alternatives = tuple(
|
||||||
|
index
|
||||||
|
for index, condition in enumerate(topic.conditions, start=1)
|
||||||
|
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||||
|
)
|
||||||
|
if not unscoped_alternatives:
|
||||||
|
return ()
|
||||||
|
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
|
||||||
|
return (
|
||||||
|
"every user workflow condition alternative must declare required_scopes or any_scopes; "
|
||||||
|
f"unscoped alternative(s): {alternatives}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DocumentationContext:
|
class DocumentationContext:
|
||||||
registry: object
|
registry: object
|
||||||
|
|||||||
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
|
||||||
|
|
||||||
|
# Identity search is deliberately absent here. ``identity.search`` is an
|
||||||
|
# instance-wide canonical-identity directory and has no tenant/principal
|
||||||
|
# visibility contract. Ordinary task pickers must use principal-aware search
|
||||||
|
# providers instead.
|
||||||
|
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleSearchError(ValueError):
|
||||||
|
"""Stable, non-diagnostic error raised by people-search providers."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PersonSearchCandidate:
|
||||||
|
"""A policy-filtered selection target returned by a directory provider."""
|
||||||
|
|
||||||
|
selection_key: str
|
||||||
|
kind: str
|
||||||
|
reference_id: str
|
||||||
|
display_name: str
|
||||||
|
email: str | None = None
|
||||||
|
source_module: str | None = None
|
||||||
|
source_label: str | None = None
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PeopleSearchGroup:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
candidates: tuple[PersonSearchCandidate, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PeopleSearchProvider(Protocol):
|
||||||
|
"""Server-side, principal-aware people/delivery-target search.
|
||||||
|
|
||||||
|
Implementations must derive visibility from ``principal`` and may only
|
||||||
|
return records the principal can discover in its active tenant and policy
|
||||||
|
context. Callers remain responsible for authorizing the task for which the
|
||||||
|
picker is used (for example, creating a scheduling request).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> Sequence[PeopleSearchGroup]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def people_search_providers(
|
||||||
|
registry: object | None,
|
||||||
|
*,
|
||||||
|
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||||
|
) -> tuple[PeopleSearchProvider, ...]:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
providers: list[PeopleSearchProvider] = []
|
||||||
|
for capability_name in capability_names:
|
||||||
|
if not registry.has_capability(capability_name):
|
||||||
|
continue
|
||||||
|
capability = registry.capability(capability_name)
|
||||||
|
if isinstance(capability, PeopleSearchProvider):
|
||||||
|
providers.append(capability)
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
|
||||||
|
def search_visible_people(
|
||||||
|
registry: object | None,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
"""Collect grouped results without importing optional feature modules.
|
||||||
|
|
||||||
|
``limit`` is enforced per provider so one directory cannot starve another
|
||||||
|
result group. Providers are expected to return at most that many candidates
|
||||||
|
in total. Duplicate group keys are merged and candidate selection keys are
|
||||||
|
de-duplicated while preserving provider order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_query = str(query or "").strip()
|
||||||
|
normalized_limit = max(1, min(int(limit), 100))
|
||||||
|
ordered_group_keys: list[str] = []
|
||||||
|
labels: dict[str, str] = {}
|
||||||
|
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
|
||||||
|
candidate_keys_by_group: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
for provider in people_search_providers(registry, capability_names=capability_names):
|
||||||
|
groups = provider.search_people(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=normalized_query,
|
||||||
|
limit=normalized_limit,
|
||||||
|
)
|
||||||
|
provider_candidate_count = 0
|
||||||
|
for group in groups:
|
||||||
|
if not group.key:
|
||||||
|
continue
|
||||||
|
if group.key not in candidates_by_group:
|
||||||
|
ordered_group_keys.append(group.key)
|
||||||
|
labels[group.key] = group.label
|
||||||
|
candidates_by_group[group.key] = []
|
||||||
|
candidate_keys_by_group[group.key] = set()
|
||||||
|
for candidate in group.candidates:
|
||||||
|
if provider_candidate_count >= normalized_limit:
|
||||||
|
break
|
||||||
|
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
|
||||||
|
continue
|
||||||
|
candidate_keys_by_group[group.key].add(candidate.selection_key)
|
||||||
|
candidates_by_group[group.key].append(candidate)
|
||||||
|
provider_candidate_count += 1
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key=group_key,
|
||||||
|
label=labels[group_key],
|
||||||
|
candidates=tuple(candidates_by_group[group_key]),
|
||||||
|
)
|
||||||
|
for group_key in ordered_group_keys
|
||||||
|
if candidates_by_group[group_key]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
|
||||||
|
normalized_kind = str(kind).strip().casefold()
|
||||||
|
normalized_reference = str(reference_id).strip()
|
||||||
|
normalized_email = str(email or "").strip().casefold()
|
||||||
|
if not normalized_kind or not normalized_reference:
|
||||||
|
raise ValueError("Person selection keys require a kind and reference id.")
|
||||||
|
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
|
||||||
|
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
|
||||||
|
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
|
||||||
|
"PeopleSearchError",
|
||||||
|
"PeopleSearchGroup",
|
||||||
|
"PeopleSearchProvider",
|
||||||
|
"PersonSearchCandidate",
|
||||||
|
"people_search_providers",
|
||||||
|
"person_selection_key",
|
||||||
|
"search_visible_people",
|
||||||
|
]
|
||||||
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionOrderCommand:
|
||||||
|
"""Complete active option order supplied by an owning module.
|
||||||
|
|
||||||
|
Providers must reject partial, duplicate, or unknown option collections.
|
||||||
|
An exact repeat is an idempotent no-op. Reordering changes positions only;
|
||||||
|
option identities and their existing answers remain valid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
option_ids: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollInvitationCommand:
|
class PollInvitationCommand:
|
||||||
respondent_id: str | None = None
|
respondent_id: str | None = None
|
||||||
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementCommand:
|
||||||
|
"""Retire live responses while preserving their auditable history.
|
||||||
|
|
||||||
|
Owning modules provide every server-trusted identity that can refer to the
|
||||||
|
participant. Providers must soft-delete matching live responses, retain
|
||||||
|
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
respondent_ids: tuple[str, ...] = ()
|
||||||
|
invitation_id: str | None = None
|
||||||
|
reason: str = "participant_removed"
|
||||||
|
idempotency_key: str = ""
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollOptionRef:
|
class PollOptionRef:
|
||||||
id: str
|
id: str
|
||||||
@@ -138,6 +166,16 @@ class PollResponseRef:
|
|||||||
answers: tuple[PollAnswerRef, ...] = ()
|
answers: tuple[PollAnswerRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementRef:
|
||||||
|
"""Outcome of an auditable response-retirement request."""
|
||||||
|
|
||||||
|
response_ids: tuple[str, ...] = ()
|
||||||
|
retired_at: datetime | None = None
|
||||||
|
newly_retired_count: int = 0
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class PollSchedulingProvider(Protocol):
|
class PollSchedulingProvider(Protocol):
|
||||||
def create_poll(
|
def create_poll(
|
||||||
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
|
|||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def reorder_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollOptionOrderCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
"""Atomically replace the complete active option order."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseRetirementProvider(Protocol):
|
||||||
|
"""Optional extension for owning modules that remove participants."""
|
||||||
|
|
||||||
|
def retire_responses(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollResponseRetirementCommand,
|
||||||
|
) -> PollResponseRetirementRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||||
if registry is None or not hasattr(registry, "has_capability"):
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
return None
|
return None
|
||||||
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
|
|||||||
) -> PollResponseSubmissionProvider | None:
|
) -> PollResponseSubmissionProvider | None:
|
||||||
provider = poll_scheduling_provider(registry)
|
provider = poll_scheduling_provider(registry)
|
||||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_retirement_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollResponseRetirementProvider | None:
|
||||||
|
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, PollResponseRetirementProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
272
src/govoplan_core/core/poll_participation.py
Normal file
272
src/govoplan_core/core/poll_participation.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.poll import (
|
||||||
|
PollAnswerRequest,
|
||||||
|
PollInvitationRef,
|
||||||
|
PollOptionRequest,
|
||||||
|
PollResponseRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
|
||||||
|
# Policy-attestation identifier, not a credential or credential default.
|
||||||
|
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
|
||||||
|
PARTICIPATION_POLICY_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def participation_token_fingerprint(token: str) -> str:
|
||||||
|
"""Return a non-reversible identifier suitable for audit/throttle keys."""
|
||||||
|
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseGatewayRef:
|
||||||
|
"""Stable identity of the module resource governing a participation link."""
|
||||||
|
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollParticipationPolicy:
|
||||||
|
"""Generic response rules snapshotted onto one signed invitation.
|
||||||
|
|
||||||
|
``single_choice`` treats every non-``unavailable`` availability answer as
|
||||||
|
a selection. Capacity is reserved only by ``available`` answers (and by
|
||||||
|
selected answers for non-availability polls), so ``maybe`` never consumes
|
||||||
|
a place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version: int = PARTICIPATION_POLICY_VERSION
|
||||||
|
single_choice: bool = False
|
||||||
|
allow_maybe: bool = True
|
||||||
|
max_participants_per_option: int | None = None
|
||||||
|
allow_comments: bool = False
|
||||||
|
participant_email_required: bool = False
|
||||||
|
anonymous_password_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollGovernedInvitationCommand:
|
||||||
|
gateway: PollResponseGatewayRef
|
||||||
|
policy: PollParticipationPolicy
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollGovernedResponseCommand:
|
||||||
|
"""Submission already authorized by the named in-process gateway.
|
||||||
|
|
||||||
|
Passwords never cross this boundary. A gateway that owns a password
|
||||||
|
verifier reports the completed check through ``verified_requirements``.
|
||||||
|
Poll independently re-enforces the remaining snapshotted rules while
|
||||||
|
holding its Poll-row lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
participant_email: str | None = None
|
||||||
|
participant_is_authenticated: bool = False
|
||||||
|
answers: tuple[PollAnswerRequest, ...] = ()
|
||||||
|
comment: str | None = None
|
||||||
|
verified_requirements: frozenset[str] = frozenset()
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollGovernedResponseRef:
|
||||||
|
response: PollResponseRef
|
||||||
|
participant_email: str | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionMutationRef:
|
||||||
|
id: str
|
||||||
|
position: int
|
||||||
|
replayed: bool = False
|
||||||
|
invalidated_response_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationRevocationRef:
|
||||||
|
id: str
|
||||||
|
revoked_at: datetime
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationExpiryRef:
|
||||||
|
id: str
|
||||||
|
expires_at: datetime | None
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollParticipationContextRef:
|
||||||
|
invitation_id: str
|
||||||
|
tenant_id: str
|
||||||
|
poll_id: str
|
||||||
|
gateway: PollResponseGatewayRef
|
||||||
|
policy: PollParticipationPolicy
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
response: PollGovernedResponseRef | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollParticipationGatewayProvider(Protocol):
|
||||||
|
def create_governed_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollGovernedInvitationCommand,
|
||||||
|
) -> PollInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_participation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
respondent_id: str | None = None,
|
||||||
|
participant_email: str | None = None,
|
||||||
|
participant_is_authenticated: bool = False,
|
||||||
|
verified_requirements: frozenset[str] = frozenset(),
|
||||||
|
) -> PollParticipationContextRef:
|
||||||
|
"""Resolve and prefill one valid invitation for the exact gateway."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def submit_governed_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
command: PollGovernedResponseCommand,
|
||||||
|
) -> PollGovernedResponseRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_authenticated_participation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
respondent_id: str,
|
||||||
|
) -> PollParticipationContextRef:
|
||||||
|
"""Resolve one governed invitation without retaining its public token."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def submit_authenticated_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
respondent_id: str,
|
||||||
|
command: PollGovernedResponseCommand,
|
||||||
|
) -> PollGovernedResponseRef:
|
||||||
|
"""Submit atomically for the exact authenticated invitation identity."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def add_option(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollOptionRequest,
|
||||||
|
) -> PollOptionMutationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def remove_option(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str,
|
||||||
|
) -> PollOptionMutationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def revoke_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
) -> PollInvitationRevocationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_invitation_expiry(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
expires_at: datetime | None,
|
||||||
|
) -> PollInvitationExpiryRef:
|
||||||
|
"""Expire or extend a non-revoked governed invitation in place."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def poll_participation_gateway_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollParticipationGatewayProvider | None:
|
||||||
|
"""Resolve the governed Poll gateway without importing its implementation."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||||
|
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
||||||
|
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
||||||
|
"PARTICIPATION_POLICY_VERSION",
|
||||||
|
"PollGovernedInvitationCommand",
|
||||||
|
"PollGovernedResponseCommand",
|
||||||
|
"PollGovernedResponseRef",
|
||||||
|
"PollInvitationExpiryRef",
|
||||||
|
"PollInvitationRevocationRef",
|
||||||
|
"PollOptionMutationRef",
|
||||||
|
"PollParticipationContextRef",
|
||||||
|
"PollParticipationGatewayProvider",
|
||||||
|
"PollParticipationPolicy",
|
||||||
|
"PollResponseGatewayRef",
|
||||||
|
"participation_token_fingerprint",
|
||||||
|
"poll_participation_gateway_provider",
|
||||||
|
]
|
||||||
@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
PublicFrontendRoute,
|
||||||
ResourceAclProvider,
|
ResourceAclProvider,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
|
|
||||||
@@ -194,6 +196,7 @@ class PlatformRegistry:
|
|||||||
available_capabilities=available_capabilities,
|
available_capabilities=available_capabilities,
|
||||||
)
|
)
|
||||||
permissions = _collect_manifest_permissions(ordered)
|
permissions = _collect_manifest_permissions(ordered)
|
||||||
|
_validate_public_frontend_route_uniqueness(ordered)
|
||||||
_validate_interface_closure(ordered)
|
_validate_interface_closure(ordered)
|
||||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||||
|
|
||||||
@@ -369,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
_validate_manifest_frontend(manifest)
|
_validate_manifest_frontend(manifest)
|
||||||
for item in manifest.nav_items:
|
for item in manifest.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
for issue in user_workflow_scope_condition_issues(topic):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||||
@@ -428,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
|||||||
)
|
)
|
||||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||||
for route in (*frontend.routes, *frontend.settings_routes):
|
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
@@ -441,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
|||||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_public_frontend_route_uniqueness(
|
||||||
|
manifests: tuple[ModuleManifest, ...],
|
||||||
|
) -> None:
|
||||||
|
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
if manifest.frontend is None:
|
||||||
|
continue
|
||||||
|
for route in manifest.frontend.public_routes:
|
||||||
|
previous = owners.get(route.path)
|
||||||
|
if previous is not None:
|
||||||
|
previous_module, _previous_route = previous
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate public frontend route {route.path!r} in modules "
|
||||||
|
f"{previous_module!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
owners[route.path] = (manifest.id, route)
|
||||||
|
|
||||||
|
|
||||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.i18n import system_i18n_payload
|
from govoplan_core.i18n import system_i18n_payload
|
||||||
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"path": route.path,
|
||||||
|
"component": route.component,
|
||||||
|
"order": route.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||||
if frontend is None:
|
if frontend is None:
|
||||||
return None
|
return None
|
||||||
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
|||||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||||
|
"public_routes": [
|
||||||
|
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||||
|
],
|
||||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
|
||||||
|
"""Return only assets and routes explicitly approved for signed-out use."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"module_id": frontend.module_id,
|
||||||
|
"package_name": frontend.package_name,
|
||||||
|
"asset_manifest": frontend.asset_manifest,
|
||||||
|
"asset_manifest_signature": frontend.asset_manifest_signature,
|
||||||
|
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||||
|
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||||
|
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||||
|
"public_routes": [
|
||||||
|
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||||
capabilities: list[str] = []
|
capabilities: list[str] = []
|
||||||
if manifest_id == "mail":
|
if manifest_id == "mail":
|
||||||
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/public-modules")
|
||||||
|
def public_modules(request: Request):
|
||||||
|
registry = _registry(request)
|
||||||
|
return {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": manifest.id,
|
||||||
|
"name": manifest.name,
|
||||||
|
"version": manifest.version,
|
||||||
|
"frontend": _public_frontend_payload(manifest.frontend),
|
||||||
|
}
|
||||||
|
for manifest in registry.manifests()
|
||||||
|
if manifest.frontend is not None
|
||||||
|
and manifest.frontend.public_routes
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/permissions")
|
@router.get("/permissions")
|
||||||
def permissions(request: Request):
|
def permissions(request: Request):
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ class Settings(BaseSettings):
|
|||||||
ge=0,
|
ge=0,
|
||||||
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||||
)
|
)
|
||||||
|
scheduling_cancellation_notice_days: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
le=90,
|
||||||
|
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
|
||||||
|
)
|
||||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||||
|
|
||||||
# Development bootstrap only. Do not use this in production.
|
# Development bootstrap only. Do not use this in production.
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
|
for path in (
|
||||||
|
"/api/v1/platform/status",
|
||||||
|
"/api/v1/platform/modules",
|
||||||
|
"/api/v1/platform/public-modules",
|
||||||
|
):
|
||||||
first = client.get(path)
|
first = client.get(path)
|
||||||
self.assertEqual(200, first.status_code, first.text)
|
self.assertEqual(200, first.status_code, first.text)
|
||||||
etag = first.headers.get("etag")
|
etag = first.headers.get("etag")
|
||||||
|
|||||||
85
tests/test_documentation_topic_contract.py
Normal file
85
tests/test_documentation_topic_contract.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationTopic,
|
||||||
|
ModuleManifest,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_topic(
|
||||||
|
*,
|
||||||
|
conditions: tuple[DocumentationCondition, ...],
|
||||||
|
documentation_types: tuple[str, ...] = ("user",),
|
||||||
|
kind: str = "workflow",
|
||||||
|
) -> DocumentationTopic:
|
||||||
|
return DocumentationTopic(
|
||||||
|
id="example.workflow.task",
|
||||||
|
title="Complete a task",
|
||||||
|
summary="Complete the example task.",
|
||||||
|
documentation_types=documentation_types, # type: ignore[arg-type]
|
||||||
|
conditions=conditions,
|
||||||
|
metadata={"kind": kind},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentationTopicContractTests(unittest.TestCase):
|
||||||
|
def test_user_workflow_requires_scope_conditions(self) -> None:
|
||||||
|
topic = workflow_topic(conditions=())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
user_workflow_scope_condition_issues(topic),
|
||||||
|
("user workflow topics must declare at least one scope-conditioned alternative",),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RegistryError, "scope-conditioned alternative"):
|
||||||
|
registry_for(topic).validate()
|
||||||
|
|
||||||
|
def test_every_condition_alternative_must_be_scope_conditioned(self) -> None:
|
||||||
|
topic = workflow_topic(
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||||
|
DocumentationCondition(required_modules=("example",)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, r"unscoped alternative\(s\): 2"):
|
||||||
|
registry_for(topic).validate()
|
||||||
|
|
||||||
|
def test_scoped_user_workflow_and_non_user_topics_are_accepted(self) -> None:
|
||||||
|
scoped = workflow_topic(
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||||
|
DocumentationCondition(any_scopes=("example:item:write", "example:item:admin")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
admin_workflow = workflow_topic(
|
||||||
|
conditions=(),
|
||||||
|
documentation_types=("admin",),
|
||||||
|
)
|
||||||
|
user_reference = workflow_topic(conditions=(), kind="reference")
|
||||||
|
|
||||||
|
self.assertEqual(user_workflow_scope_condition_issues(scoped), ())
|
||||||
|
self.assertEqual(user_workflow_scope_condition_issues(admin_workflow), ())
|
||||||
|
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||||
|
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0.0",
|
||||||
|
documentation=topics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -21,6 +21,18 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||||
|
|
||||||
|
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
|
||||||
|
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
|
||||||
|
self.assertEqual(
|
||||||
|
Settings(
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS="14"
|
||||||
|
).scheduling_cancellation_notice_days,
|
||||||
|
14,
|
||||||
|
)
|
||||||
|
for invalid in ("0", "91"):
|
||||||
|
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||||
|
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
|
||||||
|
|
||||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||||
|
|
||||||
@@ -113,6 +125,7 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||||
|
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||||
@@ -122,6 +135,7 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||||
|
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'
|
|||||||
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
|
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
|
||||||
os.environ.setdefault("CELERY_ENABLED", "false")
|
os.environ.setdefault("CELERY_ENABLED", "false")
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
@@ -99,7 +99,7 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
sign_module_package_catalog,
|
sign_module_package_catalog,
|
||||||
validate_module_package_catalog,
|
validate_module_package_catalog,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
||||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
@@ -112,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
|
|||||||
from govoplan_core.security.permissions import scope_grants
|
from govoplan_core.security.permissions import scope_grants
|
||||||
from govoplan_core.server.app import create_app
|
from govoplan_core.server.app import create_app
|
||||||
from govoplan_core.server.config import GovoplanServerConfig
|
from govoplan_core.server.config import GovoplanServerConfig
|
||||||
|
from govoplan_core.server.platform import create_platform_router
|
||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.server.route_validation import RouteCollisionError
|
from govoplan_core.server.route_validation import RouteCollisionError
|
||||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||||
@@ -338,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
||||||
if manifest.frontend.package_name is not None:
|
if manifest.frontend.package_name is not None:
|
||||||
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
||||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes):
|
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
|
||||||
self.assertTrue(route.path.startswith("/"))
|
self.assertTrue(route.path.startswith("/"))
|
||||||
self.assertTrue(route.component.strip())
|
self.assertTrue(route.component.strip())
|
||||||
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
||||||
@@ -457,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
||||||
registry.validate()
|
registry.validate()
|
||||||
|
|
||||||
|
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
package_name="@govoplan/example-webui",
|
||||||
|
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/example/public/:token",
|
||||||
|
component="ExamplePublicPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="private",
|
||||||
|
name="Private",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="private",
|
||||||
|
package_name="@govoplan/private-webui",
|
||||||
|
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/v1/platform/public-modules")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
|
||||||
|
public_module = response.json()["modules"][0]
|
||||||
|
self.assertNotIn("dependencies", public_module)
|
||||||
|
self.assertNotIn("nav", public_module["frontend"])
|
||||||
|
self.assertNotIn("routes", public_module["frontend"])
|
||||||
|
self.assertEqual(
|
||||||
|
["/example/public/:token"],
|
||||||
|
[item["path"] for item in public_module["frontend"]["public_routes"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
for module_id in ("first", "second"):
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id=module_id,
|
||||||
|
name=module_id.title(),
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=module_id,
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/shared/public/:token",
|
||||||
|
component=f"{module_id.title()}PublicPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
||||||
first = APIRouter()
|
first = APIRouter()
|
||||||
second = APIRouter()
|
second = APIRouter()
|
||||||
|
|||||||
99
tests/test_people_search_contract.py
Normal file
99
tests/test_people_search_contract.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.people import (
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||||
|
PeopleSearchGroup,
|
||||||
|
PersonSearchCandidate,
|
||||||
|
people_search_providers,
|
||||||
|
person_selection_key,
|
||||||
|
search_visible_people,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class FakePeopleSearchProvider:
|
||||||
|
def __init__(self, *groups: PeopleSearchGroup) -> None:
|
||||||
|
self.groups = groups
|
||||||
|
self.calls: list[tuple[object, object, str, int]] = []
|
||||||
|
|
||||||
|
def search_people(self, session: object, principal: object, *, query: str, limit: int = 25):
|
||||||
|
self.calls.append((session, principal, query, limit))
|
||||||
|
return self.groups
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleSearchContractTests(unittest.TestCase):
|
||||||
|
def test_collects_optional_providers_in_stable_group_order(self) -> None:
|
||||||
|
duplicate = PersonSearchCandidate(
|
||||||
|
selection_key="account:1",
|
||||||
|
kind="account",
|
||||||
|
reference_id="1",
|
||||||
|
display_name="Ada Lovelace",
|
||||||
|
)
|
||||||
|
access = FakePeopleSearchProvider(PeopleSearchGroup(key="accounts", label="Accounts", candidates=(duplicate, duplicate)))
|
||||||
|
addresses = FakePeopleSearchProvider(
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key="contacts",
|
||||||
|
label="Contacts",
|
||||||
|
candidates=(
|
||||||
|
PersonSearchCandidate(
|
||||||
|
selection_key="contact:2:ada@example.test",
|
||||||
|
kind="contact",
|
||||||
|
reference_id="2",
|
||||||
|
display_name="Ada Lovelace",
|
||||||
|
email="ada@example.test",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="1.0.0",
|
||||||
|
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: access},
|
||||||
|
))
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="addresses",
|
||||||
|
name="Addresses",
|
||||||
|
version="1.0.0",
|
||||||
|
capability_factories={CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda _context: addresses},
|
||||||
|
))
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
|
||||||
|
session = object()
|
||||||
|
principal = object()
|
||||||
|
groups = search_visible_people(registry, session, principal, query=" ada ", limit=200)
|
||||||
|
|
||||||
|
self.assertEqual([group.key for group in groups], ["accounts", "contacts"])
|
||||||
|
self.assertEqual([len(group.candidates) for group in groups], [1, 1])
|
||||||
|
self.assertEqual(access.calls, [(session, principal, "ada", 100)])
|
||||||
|
self.assertEqual(addresses.calls, [(session, principal, "ada", 100)])
|
||||||
|
self.assertEqual(people_search_providers(None), ())
|
||||||
|
|
||||||
|
def test_omits_empty_groups_and_ignores_unrelated_capabilities(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="1.0.0",
|
||||||
|
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: object()},
|
||||||
|
))
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
|
||||||
|
self.assertEqual(search_visible_people(registry, object(), object(), query="ada"), ())
|
||||||
|
|
||||||
|
def test_selection_key_is_stable_and_validated(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
person_selection_key("Account", "account-1", email=" Ada@Example.Test "),
|
||||||
|
"account:account-1:ada@example.test",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
person_selection_key("", "account-1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,7 +3,36 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
|
from govoplan_core.core.poll import (
|
||||||
|
CAPABILITY_POLL_SCHEDULING,
|
||||||
|
PollAnswerRef,
|
||||||
|
PollOptionUpdateCommand,
|
||||||
|
PollResponseRef,
|
||||||
|
PollResponseRetirementCommand,
|
||||||
|
PollResponseRetirementProvider,
|
||||||
|
poll_response_retirement_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RetirementProvider:
|
||||||
|
def retire_responses(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, capability: object | None) -> None:
|
||||||
|
self.capability_value = capability
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLL_SCHEDULING
|
||||||
|
and self.capability_value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.capability_value
|
||||||
|
|
||||||
|
|
||||||
class PollContractTests(unittest.TestCase):
|
class PollContractTests(unittest.TestCase):
|
||||||
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(command.label, "Tuesday 10:00")
|
self.assertEqual(command.label, "Tuesday 10:00")
|
||||||
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
||||||
|
|
||||||
|
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
|
||||||
|
command = PollResponseRetirementCommand(
|
||||||
|
respondent_ids=("account-1", "alice@example.test"),
|
||||||
|
invitation_id="invitation-1",
|
||||||
|
reason="scheduling_participant_removed",
|
||||||
|
idempotency_key="scheduling:request-1:participant-1:removed",
|
||||||
|
metadata={"source_module": "scheduling"},
|
||||||
|
)
|
||||||
|
provider = _RetirementProvider()
|
||||||
|
|
||||||
|
self.assertEqual(command.respondent_ids[0], "account-1")
|
||||||
|
self.assertIsInstance(provider, PollResponseRetirementProvider)
|
||||||
|
self.assertIs(
|
||||||
|
poll_response_retirement_provider(_Registry(provider)),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
|
||||||
|
self.assertIsNone(poll_response_retirement_provider(None))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
84
tests/test_poll_participation_contract.py
Normal file
84
tests/test_poll_participation_contract.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.poll_participation import (
|
||||||
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||||
|
PollParticipationGatewayProvider,
|
||||||
|
PollParticipationPolicy,
|
||||||
|
participation_token_fingerprint,
|
||||||
|
poll_participation_gateway_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _CompleteGateway:
|
||||||
|
def create_governed_invitation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def resolve_participation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def submit_governed_response(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def resolve_authenticated_participation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def submit_authenticated_response(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def add_option(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def remove_option(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def revoke_invitation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update_invitation_expiry(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, capability: object | None) -> None:
|
||||||
|
self.capability_value = capability
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||||
|
and self.capability_value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.capability_value
|
||||||
|
|
||||||
|
|
||||||
|
class PollParticipationContractTests(unittest.TestCase):
|
||||||
|
def test_capability_name_and_policy_defaults_remain_stable(self) -> None:
|
||||||
|
policy = PollParticipationPolicy()
|
||||||
|
|
||||||
|
self.assertEqual("poll.participation_gateway", CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||||
|
self.assertEqual(1, policy.version)
|
||||||
|
self.assertTrue(policy.allow_maybe)
|
||||||
|
|
||||||
|
def test_token_fingerprint_is_deterministic_and_non_reversible(self) -> None:
|
||||||
|
fingerprint = participation_token_fingerprint("secret-token")
|
||||||
|
|
||||||
|
self.assertEqual(fingerprint, participation_token_fingerprint("secret-token"))
|
||||||
|
self.assertEqual(64, len(fingerprint))
|
||||||
|
self.assertNotIn("secret-token", fingerprint)
|
||||||
|
|
||||||
|
def test_registry_lookup_accepts_only_the_complete_structural_contract(self) -> None:
|
||||||
|
provider = _CompleteGateway()
|
||||||
|
|
||||||
|
self.assertIsInstance(provider, PollParticipationGatewayProvider)
|
||||||
|
self.assertIs(provider, poll_participation_gateway_provider(_Registry(provider)))
|
||||||
|
self.assertIsNone(poll_participation_gateway_provider(_Registry(object())))
|
||||||
|
self.assertIsNone(poll_participation_gateway_provider(None))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||||
|
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
|
||||||
|
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
|
||||||
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
||||||
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
import LoginModal from "./features/auth/LoginModal";
|
import LoginModal from "./features/auth/LoginModal";
|
||||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||||
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||||
@@ -30,7 +30,9 @@ export default function App() {
|
|||||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||||
|
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
|
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||||
const [backendReachable, setBackendReachable] = useState(true);
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||||
@@ -38,9 +40,13 @@ export default function App() {
|
|||||||
|
|
||||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||||
|
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||||
|
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||||
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
|
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||||
|
const contextModules = auth ? webModules : publicWebModules;
|
||||||
|
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||||
|
|
||||||
function updateSettings(next: ApiSettings) {
|
function updateSettings(next: ApiSettings) {
|
||||||
@@ -119,6 +125,14 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [settings.apiBaseUrl]);
|
}, [settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchPlatformPublicModules(settings).
|
||||||
|
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
|
||||||
|
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setCheckingSession(true);
|
setCheckingSession(true);
|
||||||
@@ -233,6 +247,18 @@ export default function App() {
|
|||||||
return () => {cancelled = true;};
|
return () => {cancelled = true;};
|
||||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!platformPublicModules?.length) {
|
||||||
|
setRemotePublicWebModules([]);
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}
|
||||||
|
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
|
||||||
|
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
|
||||||
|
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [platformPublicModules, localPublicWebModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
|
|
||||||
@@ -284,7 +310,7 @@ export default function App() {
|
|||||||
if (checkingSession) {
|
if (checkingSession) {
|
||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<div className="public-landing">
|
<div className="public-landing">
|
||||||
@@ -304,10 +330,21 @@ export default function App() {
|
|||||||
if (!auth) {
|
if (!auth) {
|
||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||||
|
<Routes>
|
||||||
|
{publicRoutes.map((route) =>
|
||||||
|
<Route
|
||||||
|
key={`public:${route.path}`}
|
||||||
|
path={route.path}
|
||||||
|
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
@@ -342,6 +379,13 @@ export default function App() {
|
|||||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||||
|
{publicRoutes.map((route) =>
|
||||||
|
<Route
|
||||||
|
key={`public:${route.path}`}
|
||||||
|
path={route.path}
|
||||||
|
element={route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{moduleRoutes.map((route) =>
|
{moduleRoutes.map((route) =>
|
||||||
<Route
|
<Route
|
||||||
key={route.path}
|
key={route.path}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ApiSettings } from "../types";
|
import type { ApiSettings } from "../types";
|
||||||
import type { PlatformModuleInfo } from "../types";
|
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||||
|
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
|
||||||
|
|
||||||
export type PlatformStatusResponse = {
|
export type PlatformStatusResponse = {
|
||||||
maintenance_mode: {
|
maintenance_mode: {
|
||||||
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
|
|||||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
|
||||||
|
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
||||||
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
|
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const renderedValue = typeof value === "string" ? translateText(value) : value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`metric-card metric-${tone}`}>
|
<div className={`metric-card metric-${tone}`}>
|
||||||
<div className="metric-label">{label}</div>
|
<div className="metric-label">{translateText(label)}</div>
|
||||||
<div className="metric-value">{value}</div>
|
<div className="metric-value">{renderedValue}</div>
|
||||||
{detail && <div className="metric-detail">{detail}</div>}
|
{detail && <div className="metric-detail">{translateText(detail)}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
517
webui/src/components/people/PeoplePicker.tsx
Normal file
517
webui/src/components/people/PeoplePicker.tsx
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
type FocusEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type ReactNode
|
||||||
|
} from "react";
|
||||||
|
import { Plus, Search, Trash2, UserPlus } from "lucide-react";
|
||||||
|
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
import { isValidEmailAddress } from "../../utils/emailAddresses";
|
||||||
|
import Button from "../Button";
|
||||||
|
import FieldLabel from "../help/FieldLabel";
|
||||||
|
import DataGrid, { DataGridEmptyAction, type DataGridColumn } from "../table/DataGrid";
|
||||||
|
import TableActionGroup from "../table/TableActionGroup";
|
||||||
|
import {
|
||||||
|
dedupePeoplePickerItems,
|
||||||
|
externalPeoplePickerItem,
|
||||||
|
peoplePickerDedupeKey,
|
||||||
|
selectionFromPeoplePickerCandidate,
|
||||||
|
type PeoplePickerItem,
|
||||||
|
type PeoplePickerSearch,
|
||||||
|
type PeoplePickerSearchCandidate,
|
||||||
|
type PeoplePickerSearchGroup
|
||||||
|
} from "./peoplePickerTypes";
|
||||||
|
|
||||||
|
export type PeoplePickerProps = {
|
||||||
|
id?: string;
|
||||||
|
label?: ReactNode;
|
||||||
|
help?: ReactNode;
|
||||||
|
selectedLabel?: ReactNode;
|
||||||
|
value: readonly PeoplePickerItem[];
|
||||||
|
onChange: (value: PeoplePickerItem[]) => void;
|
||||||
|
search: PeoplePickerSearch;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
allowManualExternal?: boolean;
|
||||||
|
manualEmailRequired?: boolean;
|
||||||
|
minQueryLength?: number;
|
||||||
|
searchLimit?: number;
|
||||||
|
debounceMs?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IndexedCandidate = {
|
||||||
|
candidate: PeoplePickerSearchCandidate;
|
||||||
|
group: PeoplePickerSearchGroup;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
people: "i18n:govoplan-core.people.b37554f6",
|
||||||
|
searchPeople: "i18n:govoplan-core.search_people_and_contacts.8e028a78",
|
||||||
|
searchPlaceholder: "i18n:govoplan-core.search_by_name_or_email_address.6283d84d",
|
||||||
|
searching: "i18n:govoplan-core.searching.1a6a5ba8",
|
||||||
|
noMatches: "i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688",
|
||||||
|
addExternal: "i18n:govoplan-core.add_external_person.b19c7abc",
|
||||||
|
externalPerson: "i18n:govoplan-core.external_person.35115d8e",
|
||||||
|
selectedPeople: "i18n:govoplan-core.selected_people.49a953a1",
|
||||||
|
noSelection: "i18n:govoplan-core.no_people_selected_yet.9383cc74",
|
||||||
|
source: "i18n:govoplan-core.source.6da13add",
|
||||||
|
addPerson: "i18n:govoplan-core.add_person.3b10561b",
|
||||||
|
removePerson: "i18n:govoplan-core.remove_person.2ea46758",
|
||||||
|
alreadySelected: "i18n:govoplan-core.this_person_is_already_selected.d337154c",
|
||||||
|
enterName: "i18n:govoplan-core.enter_a_name.86c810d9",
|
||||||
|
searchFailed: "i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7",
|
||||||
|
addSomeone: "i18n:govoplan-core.add_someone_to_the_selection.c617dd69"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function translatedNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
|
||||||
|
return typeof node === "string" ? translateText(node) : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenGroups(groups: readonly PeoplePickerSearchGroup[]): IndexedCandidate[] {
|
||||||
|
const flattened: IndexedCandidate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const candidate of group.candidates) {
|
||||||
|
if (!candidate.selection_key || seen.has(candidate.selection_key)) continue;
|
||||||
|
seen.add(candidate.selection_key);
|
||||||
|
flattened.push({ candidate, group, index: flattened.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeoplePicker({
|
||||||
|
id,
|
||||||
|
label = I18N.people,
|
||||||
|
help,
|
||||||
|
selectedLabel = I18N.selectedPeople,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
search,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
allowManualExternal = true,
|
||||||
|
manualEmailRequired = true,
|
||||||
|
minQueryLength = 2,
|
||||||
|
searchLimit = 25,
|
||||||
|
debounceMs = 250,
|
||||||
|
className = ""
|
||||||
|
}: PeoplePickerProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||||
|
const pickerId = id || `people-picker-${generatedId}`;
|
||||||
|
const labelId = `${pickerId}-label`;
|
||||||
|
const inputId = `${pickerId}-search`;
|
||||||
|
const listboxId = `${pickerId}-results`;
|
||||||
|
const statusId = `${pickerId}-status`;
|
||||||
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const manualNameRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const manualEmailRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]);
|
||||||
|
const selectedKeys = useMemo(
|
||||||
|
() => new Set(selectedItems.map(peoplePickerDedupeKey)),
|
||||||
|
[selectedItems]
|
||||||
|
);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [groups, setGroups] = useState<readonly PeoplePickerSearchGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
|
const [manualName, setManualName] = useState("");
|
||||||
|
const [manualEmail, setManualEmail] = useState("");
|
||||||
|
const [manualError, setManualError] = useState("");
|
||||||
|
const normalizedMinQueryLength = Math.max(0, Math.floor(minQueryLength));
|
||||||
|
const normalizedSearchLimit = Math.max(1, Math.min(Math.floor(searchLimit), 100));
|
||||||
|
const flattened = useMemo(() => flattenGroups(groups), [groups]);
|
||||||
|
|
||||||
|
function candidateUnavailable(candidate: PeoplePickerSearchCandidate): boolean {
|
||||||
|
return Boolean(candidate.disabled || selectedKeys.has(peoplePickerDedupeKey(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizedQuery = query.trim();
|
||||||
|
if (disabled || normalizedQuery.length < normalizedMinQueryLength) {
|
||||||
|
setGroups([]);
|
||||||
|
setLoading(false);
|
||||||
|
setHasSearched(false);
|
||||||
|
setSearchError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setSearchError("");
|
||||||
|
void search(normalizedQuery, { limit: normalizedSearchLimit, signal: controller.signal })
|
||||||
|
.then((nextGroups) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setGroups(nextGroups);
|
||||||
|
setHasSearched(true);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||||
|
setGroups([]);
|
||||||
|
setHasSearched(true);
|
||||||
|
setSearchError(I18N.searchFailed);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
}, Math.max(0, Math.floor(debounceMs)));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [debounceMs, disabled, normalizedMinQueryLength, normalizedSearchLimit, query, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstAvailable = flattened.find((item) => !candidateUnavailable(item.candidate));
|
||||||
|
setActiveIndex(firstAvailable?.index ?? -1);
|
||||||
|
}, [flattened, selectedKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (manualOpen) manualNameRef.current?.focus();
|
||||||
|
}, [manualOpen]);
|
||||||
|
|
||||||
|
function focusSearch() {
|
||||||
|
if (disabled) return;
|
||||||
|
setOpen(true);
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCandidate(candidate: PeoplePickerSearchCandidate) {
|
||||||
|
if (disabled || candidateUnavailable(candidate)) return;
|
||||||
|
onChange(dedupePeoplePickerItems([...selectedItems, selectionFromPeoplePickerCandidate(candidate)]));
|
||||||
|
setQuery("");
|
||||||
|
setGroups([]);
|
||||||
|
setHasSearched(false);
|
||||||
|
setOpen(false);
|
||||||
|
window.requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(item: PeoplePickerItem) {
|
||||||
|
if (disabled) return;
|
||||||
|
const removedKey = peoplePickerDedupeKey(item);
|
||||||
|
onChange(selectedItems.filter((candidate) => peoplePickerDedupeKey(candidate) !== removedKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActive(delta: 1 | -1) {
|
||||||
|
const available = flattened.filter((item) => !candidateUnavailable(item.candidate));
|
||||||
|
if (available.length === 0) {
|
||||||
|
setActiveIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentPosition = available.findIndex((item) => item.index === activeIndex);
|
||||||
|
const nextPosition = currentPosition < 0
|
||||||
|
? (delta > 0 ? 0 : available.length - 1)
|
||||||
|
: (currentPosition + delta + available.length) % available.length;
|
||||||
|
setActiveIndex(available[nextPosition].index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
moveActive(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
moveActive(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && activeIndex >= 0 && open) {
|
||||||
|
event.preventDefault();
|
||||||
|
const active = flattened.find((item) => item.index === activeIndex);
|
||||||
|
if (active) addCandidate(active.candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addManualPerson(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = manualName.trim();
|
||||||
|
const email = manualEmail.trim().toLowerCase();
|
||||||
|
if (!name) {
|
||||||
|
setManualError(I18N.enterName);
|
||||||
|
manualNameRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((manualEmailRequired && !email) || (email && !isValidEmailAddress(email))) {
|
||||||
|
setManualError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
|
||||||
|
manualEmailRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = externalPeoplePickerItem(name, email || null);
|
||||||
|
if (selectedKeys.has(peoplePickerDedupeKey(item))) {
|
||||||
|
setManualError(I18N.alreadySelected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(dedupePeoplePickerItems([...selectedItems, item]));
|
||||||
|
setManualName("");
|
||||||
|
setManualEmail("");
|
||||||
|
setManualError("");
|
||||||
|
setManualOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeResultsOnFocusLeave(event: FocusEvent<HTMLElement>) {
|
||||||
|
const nextTarget = event.relatedTarget;
|
||||||
|
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMessage = loading
|
||||||
|
? I18N.searching
|
||||||
|
: searchError
|
||||||
|
? searchError
|
||||||
|
: query.trim() && query.trim().length < normalizedMinQueryLength
|
||||||
|
? i18nMessage("i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad", {
|
||||||
|
value0: normalizedMinQueryLength
|
||||||
|
})
|
||||||
|
: hasSearched && flattened.length === 0
|
||||||
|
? I18N.noMatches
|
||||||
|
: "";
|
||||||
|
const resultsVisible = open && !disabled && query.trim().length >= normalizedMinQueryLength;
|
||||||
|
const columns: DataGridColumn<PeoplePickerItem>[] = [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "i18n:govoplan-core.name.709a2322",
|
||||||
|
minWidth: 180,
|
||||||
|
resizable: true,
|
||||||
|
render: (item) => (
|
||||||
|
<div className="people-picker-selected-person">
|
||||||
|
<strong>{item.display_name}</strong>
|
||||||
|
{item.description && <small>{item.description}</small>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
header: "i18n:govoplan-core.email_address.c94d3175",
|
||||||
|
minWidth: 220,
|
||||||
|
resizable: true,
|
||||||
|
value: (item) => item.email || "—"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
header: I18N.source,
|
||||||
|
minWidth: 130,
|
||||||
|
resizable: true,
|
||||||
|
value: (item) => translateText(item.source_label || kindLabel(item.kind))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "i18n:govoplan-core.actions.c3cd636a",
|
||||||
|
width: 104,
|
||||||
|
sticky: "end",
|
||||||
|
align: "right",
|
||||||
|
render: (item) => (
|
||||||
|
<TableActionGroup
|
||||||
|
minimumSlots={2}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: "add",
|
||||||
|
label: I18N.addPerson,
|
||||||
|
icon: <Plus size={16} aria-hidden="true" />,
|
||||||
|
disabled,
|
||||||
|
onClick: focusSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remove",
|
||||||
|
label: I18N.removePerson,
|
||||||
|
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||||
|
variant: "danger",
|
||||||
|
disabled,
|
||||||
|
onClick: () => removeItem(item)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`people-picker ${className}`.trim()} aria-labelledby={labelId} onBlur={closeResultsOnFocusLeave}>
|
||||||
|
<div className="people-picker-search-field">
|
||||||
|
<label htmlFor={inputId} id={labelId}>
|
||||||
|
<FieldLabel help={help}>{translatedNode(label, translateText)}</FieldLabel>
|
||||||
|
</label>
|
||||||
|
<div className="people-picker-search-control">
|
||||||
|
<Search size={17} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
id={inputId}
|
||||||
|
type="search"
|
||||||
|
role="combobox"
|
||||||
|
value={query}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required && selectedItems.length === 0}
|
||||||
|
placeholder={translateText(I18N.searchPlaceholder)}
|
||||||
|
aria-label={translateText(I18N.searchPeople)}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={resultsVisible}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeIndex >= 0 ? `${pickerId}-option-${activeIndex}` : undefined}
|
||||||
|
aria-describedby={statusId}
|
||||||
|
aria-busy={loading}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
setSearchError("");
|
||||||
|
}}
|
||||||
|
onKeyDown={onSearchKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resultsVisible && (
|
||||||
|
<div id={listboxId} className="people-picker-results" role="listbox" aria-label={translateText(I18N.searchPeople)}>
|
||||||
|
{groups.map((group) => {
|
||||||
|
const candidates = flattened.filter((item) => item.group.key === group.key);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const groupLabelId = `${pickerId}-group-${group.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="people-picker-result-group" role="group" aria-labelledby={groupLabelId}>
|
||||||
|
<div id={groupLabelId} className="people-picker-result-group-label">{translateText(group.label)}</div>
|
||||||
|
{candidates.map(({ candidate, index }) => {
|
||||||
|
const selected = selectedKeys.has(peoplePickerDedupeKey(candidate));
|
||||||
|
const unavailable = candidateUnavailable(candidate);
|
||||||
|
const disabledReason = selected ? I18N.alreadySelected : candidate.disabled_reason;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={`${pickerId}-option-${index}`}
|
||||||
|
key={candidate.selection_key}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
className={`people-picker-result ${activeIndex === index ? "is-active" : ""}`.trim()}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-disabled={unavailable}
|
||||||
|
disabled={unavailable}
|
||||||
|
title={disabledReason ? translateText(disabledReason) : undefined}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onMouseEnter={() => !unavailable && setActiveIndex(index)}
|
||||||
|
onFocus={() => !unavailable && setActiveIndex(index)}
|
||||||
|
onClick={() => addCandidate(candidate)}
|
||||||
|
>
|
||||||
|
<span className="people-picker-result-main">
|
||||||
|
<strong>{candidate.display_name}</strong>
|
||||||
|
{candidate.email && <small>{candidate.email}</small>}
|
||||||
|
</span>
|
||||||
|
<span className="people-picker-result-context">
|
||||||
|
{candidate.description && <small>{candidate.description}</small>}
|
||||||
|
<small>{translateText(candidate.source_label || group.label)}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div id={statusId} className={`people-picker-status ${searchError ? "danger-text" : ""}`.trim()} role={searchError ? "alert" : "status"} aria-live="polite">
|
||||||
|
{statusMessage && translateText(statusMessage)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowManualExternal && (
|
||||||
|
<div className="people-picker-manual">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-expanded={manualOpen}
|
||||||
|
onClick={() => {
|
||||||
|
setManualOpen((current) => !current);
|
||||||
|
setManualError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus size={16} aria-hidden="true" />
|
||||||
|
{translateText(I18N.addExternal)}
|
||||||
|
</Button>
|
||||||
|
{manualOpen && (
|
||||||
|
<form className="people-picker-manual-form" onSubmit={addManualPerson}>
|
||||||
|
<label>
|
||||||
|
<FieldLabel>{translateText("i18n:govoplan-core.name.709a2322")}</FieldLabel>
|
||||||
|
<input
|
||||||
|
ref={manualNameRef}
|
||||||
|
value={manualName}
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualName(event.target.value);
|
||||||
|
setManualError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<FieldLabel>{translateText("i18n:govoplan-core.email_address.c94d3175")}</FieldLabel>
|
||||||
|
<input
|
||||||
|
ref={manualEmailRef}
|
||||||
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
|
value={manualEmail}
|
||||||
|
disabled={disabled}
|
||||||
|
required={manualEmailRequired}
|
||||||
|
autoComplete="email"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualEmail(event.target.value);
|
||||||
|
setManualError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="people-picker-manual-actions">
|
||||||
|
<Button type="button" disabled={disabled} onClick={() => setManualOpen(false)}>
|
||||||
|
{translateText("i18n:govoplan-core.cancel.77dfd213")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={disabled}>
|
||||||
|
{translateText(I18N.addPerson)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{manualError && <p className="form-help danger-text" role="alert">{translateText(manualError)}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="people-picker-selection" aria-labelledby={`${pickerId}-selection-label`}>
|
||||||
|
<span id={`${pickerId}-selection-label`}><FieldLabel>{translatedNode(selectedLabel, translateText)}</FieldLabel></span>
|
||||||
|
<DataGrid
|
||||||
|
id={`${pickerId}-selection`}
|
||||||
|
rows={selectedItems}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={peoplePickerDedupeKey}
|
||||||
|
emptyText={I18N.noSelection}
|
||||||
|
emptyActionColumnId="actions"
|
||||||
|
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={focusSearch} label={I18N.addSomeone} />}
|
||||||
|
initialFit="container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(kind: string): string {
|
||||||
|
if (kind === "account") return "i18n:govoplan-core.accounts.36bae316";
|
||||||
|
if (kind === "contact") return "i18n:govoplan-core.contacts.b0dd615c";
|
||||||
|
if (kind === "external") return I18N.externalPerson;
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type PeoplePickerItemKind = "account" | "contact" | "external" | "function" | (string & {});
|
||||||
|
|
||||||
|
export type PeoplePickerItem = {
|
||||||
|
selection_key: string;
|
||||||
|
kind: PeoplePickerItemKind;
|
||||||
|
reference_id?: string | null;
|
||||||
|
display_name: string;
|
||||||
|
email?: string | null;
|
||||||
|
source_module?: string | null;
|
||||||
|
source_label?: string | null;
|
||||||
|
source_ref?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchCandidate = PeoplePickerItem & {
|
||||||
|
disabled?: boolean;
|
||||||
|
disabled_reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
candidates: readonly PeoplePickerSearchCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchOptions = {
|
||||||
|
limit: number;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearch = (
|
||||||
|
query: string,
|
||||||
|
options: PeoplePickerSearchOptions
|
||||||
|
) => Promise<readonly PeoplePickerSearchGroup[]>;
|
||||||
|
|
||||||
|
export function peoplePickerDedupeKey(item: Pick<PeoplePickerItem, "selection_key" | "email">): string {
|
||||||
|
const email = item.email?.trim().toLowerCase();
|
||||||
|
return email ? `email:${email}` : `selection:${item.selection_key.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupePeoplePickerItems(items: readonly PeoplePickerItem[]): PeoplePickerItem[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: PeoplePickerItem[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const key = peoplePickerDedupeKey(item);
|
||||||
|
if (!item.selection_key.trim() || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function externalPeoplePickerItem(displayName: string, email?: string | null): PeoplePickerItem {
|
||||||
|
const normalizedName = displayName.trim();
|
||||||
|
const normalizedEmail = email?.trim().toLowerCase() || null;
|
||||||
|
const identityPart = normalizedEmail || normalizedName.toLowerCase();
|
||||||
|
return {
|
||||||
|
selection_key: `external:${identityPart}`,
|
||||||
|
kind: "external",
|
||||||
|
reference_id: null,
|
||||||
|
display_name: normalizedName,
|
||||||
|
email: normalizedEmail,
|
||||||
|
source_module: null,
|
||||||
|
source_label: "i18n:govoplan-core.external_person.35115d8e",
|
||||||
|
source_ref: null,
|
||||||
|
source_revision: null,
|
||||||
|
provenance: {},
|
||||||
|
metadata: { manual: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromPeoplePickerCandidate(candidate: PeoplePickerSearchCandidate): PeoplePickerItem {
|
||||||
|
const { disabled: _disabled, disabled_reason: _disabledReason, ...selection } = candidate;
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
@@ -30,18 +30,36 @@ export type DataGridQueryState = {
|
|||||||
filters: Record<string, string>;
|
filters: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DataGridPagination = {
|
type DataGridPaginationBase = {
|
||||||
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
|
|
||||||
mode?: "client" | "server";
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
totalRows?: number;
|
|
||||||
pageSizeOptions?: number[];
|
pageSizeOptions?: number[];
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataGridClientPagination = DataGridPaginationBase & {
|
||||||
|
/**
|
||||||
|
* Client mode filters and sorts before slicing. `rows` must therefore contain
|
||||||
|
* the complete logical result set, never an already paginated backend page.
|
||||||
|
*/
|
||||||
|
mode?: "client";
|
||||||
|
totalRows?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridServerPagination = DataGridPaginationBase & {
|
||||||
|
/**
|
||||||
|
* Server mode treats `rows` as the current backend page. The query callback
|
||||||
|
* must apply every emitted sort/filter to the backend before pagination.
|
||||||
|
*/
|
||||||
|
mode: "server";
|
||||||
|
/** Total rows after the backend has applied the current filters. */
|
||||||
|
totalRows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridPagination = DataGridClientPagination | DataGridServerPagination;
|
||||||
|
|
||||||
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
||||||
|
|
||||||
export type DataGridColumn<T> = {
|
export type DataGridColumn<T> = {
|
||||||
@@ -78,7 +96,7 @@ type DataGridState = {
|
|||||||
bufferWidth?: number;
|
bufferWidth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DataGridProps<T> = {
|
type DataGridBaseProps<T> = {
|
||||||
id: string;
|
id: string;
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: DataGridColumn<T>[];
|
columns: DataGridColumn<T>[];
|
||||||
@@ -98,11 +116,24 @@ type DataGridProps<T> = {
|
|||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
initialFilters?: Record<string, string | string[]>;
|
initialFilters?: Record<string, string | string[]>;
|
||||||
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
||||||
pagination?: DataGridPagination;
|
/**
|
||||||
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
|
* Synchronize a query selected outside the grid, for example by a summary
|
||||||
onQueryChange?: (query: DataGridQueryState) => void;
|
* count shortcut. Server grids should feed `onQueryChange` back into this
|
||||||
|
* value so the header controls and backend query remain in agreement.
|
||||||
|
*/
|
||||||
|
query?: DataGridQueryState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query ownership is deliberately explicit: client grids receive all rows and
|
||||||
|
* DataGrid queries them locally; server grids receive one page and must handle
|
||||||
|
* the complete query through `onQueryChange`.
|
||||||
|
*/
|
||||||
|
export type DataGridProps<T> = DataGridBaseProps<T> & (
|
||||||
|
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
|
||||||
|
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
|
||||||
|
);
|
||||||
|
|
||||||
export type DataGridRowActionsProps = {
|
export type DataGridRowActionsProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
removeDisabled?: boolean;
|
removeDisabled?: boolean;
|
||||||
@@ -179,6 +210,7 @@ export default function DataGrid<T>({
|
|||||||
storageKey,
|
storageKey,
|
||||||
initialFilters = {},
|
initialFilters = {},
|
||||||
initialSort,
|
initialSort,
|
||||||
|
query,
|
||||||
pagination,
|
pagination,
|
||||||
onQueryChange
|
onQueryChange
|
||||||
}: DataGridProps<T>) {
|
}: DataGridProps<T>) {
|
||||||
@@ -205,7 +237,15 @@ export default function DataGrid<T>({
|
|||||||
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
||||||
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
||||||
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
|
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
|
||||||
const [state, setState] = useState<DataGridState>(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort));
|
const externalQueryKey = query ? JSON.stringify(query) : "";
|
||||||
|
const normalizedExternalQuery = useMemo(
|
||||||
|
() => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined,
|
||||||
|
[externalQueryKey]
|
||||||
|
);
|
||||||
|
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
|
||||||
|
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
|
||||||
|
normalizedExternalQuery
|
||||||
|
));
|
||||||
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
||||||
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
||||||
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
||||||
@@ -213,14 +253,21 @@ export default function DataGrid<T>({
|
|||||||
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const serverQueryMode = pagination?.mode === "server";
|
||||||
const onQueryChangeRef = useRef(onQueryChange);
|
const onQueryChangeRef = useRef(onQueryChange);
|
||||||
|
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
|
||||||
|
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
|
||||||
const lastLayoutSignatureRef = useRef<string | null>(null);
|
const lastLayoutSignatureRef = useRef<string | null>(null);
|
||||||
const lastContainerWidthRef = useRef<number | undefined>(undefined);
|
const lastContainerWidthRef = useRef<number | undefined>(undefined);
|
||||||
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||||
const serverQueryMode = pagination?.mode === "server";
|
|
||||||
|
|
||||||
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
|
||||||
|
if (!serverQueryMode) lastServerQueryRef.current = null;
|
||||||
|
}, [pagination, serverQueryMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
||||||
}, [columns, effectiveResizeBehavior]);
|
}, [columns, effectiveResizeBehavior]);
|
||||||
@@ -233,6 +280,10 @@ export default function DataGrid<T>({
|
|||||||
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
||||||
}, [normalizedInitialSort]);
|
}, [normalizedInitialSort]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
|
||||||
|
}, [normalizedExternalQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
||||||
@@ -460,10 +511,17 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverQueryMode) return;
|
if (!serverQueryMode) return;
|
||||||
onQueryChangeRef.current?.({
|
const query = {
|
||||||
sort: state.sort ?? null,
|
sort: state.sort ?? null,
|
||||||
filters: { ...(state.filters ?? {}) }
|
filters: { ...(state.filters ?? {}) }
|
||||||
});
|
};
|
||||||
|
const previousQuery = lastServerQueryRef.current;
|
||||||
|
lastServerQueryRef.current = query;
|
||||||
|
const serverPagination = serverPaginationRef.current;
|
||||||
|
if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) {
|
||||||
|
serverPagination.onPageChange(1);
|
||||||
|
}
|
||||||
|
onQueryChangeRef.current?.(query);
|
||||||
}, [serverQueryMode, state.sort, state.filters]);
|
}, [serverQueryMode, state.sort, state.filters]);
|
||||||
|
|
||||||
const filterTypes = useMemo(() => {
|
const filterTypes = useMemo(() => {
|
||||||
@@ -1202,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
|
|||||||
return { ...state, sort: initialSort };
|
return { ...state, sort: initialSort };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState {
|
||||||
|
if (!query) return state;
|
||||||
|
const current = {
|
||||||
|
sort: state.sort ?? null,
|
||||||
|
filters: state.filters ?? {}
|
||||||
|
};
|
||||||
|
if (dataGridQueriesEqual(current, query)) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sort: query.sort ? { ...query.sort } : null,
|
||||||
|
filters: { ...query.filters }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatListFilter(values: string[]): string {
|
function formatListFilter(values: string[]): string {
|
||||||
return `list:${JSON.stringify([...new Set(values)])}`;
|
return `list:${JSON.stringify([...new Set(values)])}`;
|
||||||
}
|
}
|
||||||
@@ -1668,6 +1740,16 @@ function compareValues(a: unknown, b: unknown): number {
|
|||||||
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
|
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
|
||||||
|
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||||
|
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||||
|
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
|
||||||
|
for (const key of keys) {
|
||||||
|
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyCell(value: unknown): string {
|
function stringifyCell(value: unknown): string {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
|||||||
@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||||
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
||||||
|
"i18n:govoplan-core.accounts.36bae316": "Accounts",
|
||||||
|
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
|
||||||
|
"i18n:govoplan-core.people.b37554f6": "People",
|
||||||
|
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
|
||||||
|
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
|
||||||
|
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
|
||||||
|
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
|
||||||
|
"i18n:govoplan-core.external_person.35115d8e": "External person",
|
||||||
|
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
|
||||||
|
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
|
||||||
|
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
|
||||||
|
"i18n:govoplan-core.source.6da13add": "Source",
|
||||||
|
"i18n:govoplan-core.add_person.3b10561b": "Add person",
|
||||||
|
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
|
||||||
|
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
|
||||||
|
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
|
||||||
|
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
|
||||||
|
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
|
||||||
|
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
|
||||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||||
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||||
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
||||||
|
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
|
||||||
|
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
|
||||||
|
"i18n:govoplan-core.people.b37554f6": "Personen",
|
||||||
|
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
|
||||||
|
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
|
||||||
|
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
|
||||||
|
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
|
||||||
|
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
|
||||||
|
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
|
||||||
|
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
|
||||||
|
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
|
||||||
|
"i18n:govoplan-core.source.6da13add": "Quelle",
|
||||||
|
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
|
||||||
|
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
|
||||||
|
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
|
||||||
|
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
|
||||||
|
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
|
||||||
|
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
|
||||||
|
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
|
||||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
|
|||||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||||
export { default as PageTitle } from "./components/PageTitle";
|
export { default as PageTitle } from "./components/PageTitle";
|
||||||
export { default as PasswordField } from "./components/PasswordField";
|
export { default as PasswordField } from "./components/PasswordField";
|
||||||
|
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
||||||
|
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
||||||
|
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
|
||||||
|
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
|
||||||
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
||||||
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
||||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||||
@@ -110,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail
|
|||||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||||
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
|
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
|
||||||
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
|
export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
|
||||||
export { default as TableActionGroup } from "./components/table/TableActionGroup";
|
export { default as TableActionGroup } from "./components/table/TableActionGroup";
|
||||||
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||||
|
|
||||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
|||||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
||||||
|
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
||||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
|
||||||
import {
|
import {
|
||||||
hasUiCapability as hasUiCapabilityForModules,
|
hasUiCapability as hasUiCapabilityForModules,
|
||||||
moduleInstalled as moduleInstalledForModules,
|
moduleInstalled as moduleInstalledForModules,
|
||||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||||
|
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
|
||||||
routeContributionsForModules as routeContributionsForModuleList,
|
routeContributionsForModules as routeContributionsForModuleList,
|
||||||
uiCapabilities as uiCapabilitiesForModules,
|
uiCapabilities as uiCapabilitiesForModules,
|
||||||
uiCapability as uiCapabilityForModules } from
|
uiCapability as uiCapabilityForModules } from
|
||||||
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||||
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
...(module.uiCapabilities ?? {}),
|
...(module.uiCapabilities ?? {}),
|
||||||
...runtimeUiCapabilitiesForModule(module, info)
|
...runtimeUiCapabilitiesForModule(module, info)
|
||||||
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterPublicRoutes(
|
||||||
|
module: PlatformWebModule,
|
||||||
|
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
|
||||||
|
) {
|
||||||
|
if (!routes?.length) return [];
|
||||||
|
const allowedPaths = new Set(routes.map((route) => route.path));
|
||||||
|
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||||
|
return {
|
||||||
|
id: info.id,
|
||||||
|
name: info.name,
|
||||||
|
version: info.version,
|
||||||
|
dependencies: [],
|
||||||
|
optional_dependencies: [],
|
||||||
|
enabled: true,
|
||||||
|
runtime_ui_capabilities: [],
|
||||||
|
nav: [],
|
||||||
|
frontend: {
|
||||||
|
...info.frontend,
|
||||||
|
routes: [],
|
||||||
|
nav: [],
|
||||||
|
settings_routes: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInstalledPublicWebModules(
|
||||||
|
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
||||||
|
): PlatformWebModule[] {
|
||||||
|
if (!platformModules?.length) return [];
|
||||||
|
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||||
|
return platformModules.flatMap((info) => {
|
||||||
|
const local = localById.get(info.id);
|
||||||
|
if (!local) return [];
|
||||||
|
const resolved = applyServerMetadata(local, publicModuleInfo(info));
|
||||||
|
return resolved.publicRoutes?.length ? [resolved] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRemotePublicWebModules(
|
||||||
|
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
||||||
|
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
|
||||||
|
): Promise<PlatformWebModule[]> {
|
||||||
|
if (!platformModules?.length) return [];
|
||||||
|
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
||||||
|
return loaded.filter((module) => module.publicRoutes?.length);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||||
if (!platformModules?.length) return localModules;
|
if (!platformModules?.length) return localModules;
|
||||||
|
|
||||||
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
|||||||
return routeContributionsForModuleList(modules);
|
return routeContributionsForModuleList(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
|
||||||
|
return publicRouteContributionsForModuleList(modules);
|
||||||
|
}
|
||||||
|
|
||||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||||
flatMap((capability) => capability.widgets ?? []).
|
flatMap((capability) => capability.widgets ?? []).
|
||||||
|
|||||||
@@ -1516,6 +1516,166 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Principal-aware account/contact picker with structured external entry. */
|
||||||
|
.people-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-search-field,
|
||||||
|
.people-picker-selection,
|
||||||
|
.people-picker-manual {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-search-control {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.people-picker-search-control > svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.people-picker-search-control input {
|
||||||
|
padding-left: 38px;
|
||||||
|
}
|
||||||
|
.people-picker-search-control:focus-within > svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.people-picker-results {
|
||||||
|
display: grid;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border: var(--border-line-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group + .people-picker-result-group {
|
||||||
|
margin-top: 5px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group-label {
|
||||||
|
color: var(--text-label);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
padding: 5px 9px 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.people-picker-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.people-picker-result:hover:not(:disabled),
|
||||||
|
.people-picker-result.is-active:not(:disabled),
|
||||||
|
.people-picker-result:focus-visible:not(:disabled) {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
box-shadow: var(--primary-inset-ring);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.people-picker-result:disabled {
|
||||||
|
background: var(--control-disabled-bg);
|
||||||
|
color: var(--control-disabled-text);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.people-picker-result-main,
|
||||||
|
.people-picker-result-context,
|
||||||
|
.people-picker-selected-person {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-result-main strong,
|
||||||
|
.people-picker-result-main small,
|
||||||
|
.people-picker-result-context small,
|
||||||
|
.people-picker-selected-person strong,
|
||||||
|
.people-picker-selected-person small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.people-picker-result-main small,
|
||||||
|
.people-picker-result-context,
|
||||||
|
.people-picker-selected-person small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-result-context {
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.people-picker-status {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-manual {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form > label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-manual-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form > .form-help {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.people-picker-selection > .field-label,
|
||||||
|
.people-picker-selection > span > .field-label {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.people-picker-result {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.people-picker-result-context {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Bootstrap-like switch controls without importing Bootstrap. */
|
/* Bootstrap-like switch controls without importing Bootstrap. */
|
||||||
.toggle-switch-row {
|
.toggle-switch-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -94,7 +94,8 @@
|
|||||||
padding: 18px 34px 16px;
|
padding: 18px 34px 16px;
|
||||||
}
|
}
|
||||||
.workspace-heading .mono-small { margin-top: 8px; }
|
.workspace-heading .mono-small { margin-top: 8px; }
|
||||||
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||||
|
.panel { overflow: hidden; }
|
||||||
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
||||||
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
||||||
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
|
|||||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformPublicRouteContext = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo | null;
|
||||||
|
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
||||||
|
|
||||||
export type AdminSectionRenderContext = PlatformRouteContext & {
|
export type AdminSectionRenderContext = PlatformRouteContext & {
|
||||||
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
|
|||||||
render: (context: PlatformRouteContext) => ReactNode;
|
render: (context: PlatformRouteContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformPublicRouteContribution = {
|
||||||
|
path: string;
|
||||||
|
order?: number;
|
||||||
|
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||||
|
|
||||||
export type PlatformTranslationDictionary = Record<string, string>;
|
export type PlatformTranslationDictionary = Record<string, string>;
|
||||||
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
|
|||||||
remoteAssetContractVersion?: string | null;
|
remoteAssetContractVersion?: string | null;
|
||||||
navItems?: PlatformNavItem[];
|
navItems?: PlatformNavItem[];
|
||||||
routes?: PlatformRouteContribution[];
|
routes?: PlatformRouteContribution[];
|
||||||
|
publicRoutes?: PlatformPublicRouteContribution[];
|
||||||
translations?: PlatformTranslations;
|
translations?: PlatformTranslations;
|
||||||
uiCapabilities?: PlatformUiCapabilities;
|
uiCapabilities?: PlatformUiCapabilities;
|
||||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||||
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
asset_manifest_integrity?: string | null;
|
asset_manifest_integrity?: string | null;
|
||||||
asset_manifest_contract_version?: string | null;
|
asset_manifest_contract_version?: string | null;
|
||||||
routes: PlatformFrontendRouteInfo[];
|
routes: PlatformFrontendRouteInfo[];
|
||||||
|
public_routes: Array<{
|
||||||
|
path: string;
|
||||||
|
component: string;
|
||||||
|
order: number;
|
||||||
|
}>;
|
||||||
nav: Array<{
|
nav: Array<{
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
settings_routes: PlatformFrontendRouteInfo[];
|
settings_routes: PlatformFrontendRouteInfo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformPublicModuleInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
frontend: Pick<
|
||||||
|
PlatformFrontendModuleInfo,
|
||||||
|
| "module_id"
|
||||||
|
| "package_name"
|
||||||
|
| "asset_manifest"
|
||||||
|
| "asset_manifest_signature"
|
||||||
|
| "asset_manifest_public_key_id"
|
||||||
|
| "asset_manifest_integrity"
|
||||||
|
| "asset_manifest_contract_version"
|
||||||
|
| "public_routes"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformModuleInfo = {
|
export type PlatformModuleInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
|
|||||||
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import Button from "../src/components/Button";
|
import Button from "../src/components/Button";
|
||||||
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid";
|
||||||
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
@@ -77,3 +77,55 @@ const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission d
|
|||||||
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
||||||
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||||
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
|
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
|
||||||
|
|
||||||
|
type FilterRow = { id: string; name: string };
|
||||||
|
const filterRows: FilterRow[] = [
|
||||||
|
{ id: "first-non-match", name: "Ordinary row" },
|
||||||
|
{ id: "second-non-match", name: "Another row" },
|
||||||
|
{ id: "first-match", name: "Target alpha" },
|
||||||
|
{ id: "third-non-match", name: "Unrelated row" },
|
||||||
|
{ id: "second-match", name: "Target beta" },
|
||||||
|
{ id: "third-match", name: "Target gamma" }
|
||||||
|
];
|
||||||
|
const filterColumns: DataGridColumn<FilterRow>[] = [
|
||||||
|
{ id: "name", header: "Name", filterable: true, value: (row) => row.name }
|
||||||
|
];
|
||||||
|
const fullResultFilterMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="full-result-filter-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
initialFilters={{ name: "target" }}
|
||||||
|
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target alpha"), true, "client filtering finds matches beyond the unfiltered first page");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target beta"), true, "client filtering happens before the first page is sliced");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target gamma"), false, "client pagination still limits the filtered page size");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("1\u20132"), true, "pagination counts describe the filtered result set");
|
||||||
|
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?3/.test(fullResultFilterMarkup), true, "the filtered total includes matches from every source row");
|
||||||
|
|
||||||
|
const clearedFilterMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="cleared-full-result-filter-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(clearedFilterMarkup.includes("Ordinary row"), true, "clearing filters restores the first unfiltered row");
|
||||||
|
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?6/.test(clearedFilterMarkup), true, "clearing filters restores the full pagination total");
|
||||||
|
|
||||||
|
const externalShortcutMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="external-query-shortcut-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
query={{ sort: null, filters: { name: "target" } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query");
|
||||||
|
assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");
|
||||||
|
|||||||
26
webui/tests/metric-card.test.tsx
Normal file
26
webui/tests/metric-card.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import MetricCard from "../src/components/MetricCard";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const translatedMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<MetricCard
|
||||||
|
label="i18n:govoplan-core.installed_modules.32b3e799"
|
||||||
|
value="i18n:govoplan-core.core_only.d46ce7d9"
|
||||||
|
detail="i18n:govoplan-core.install_the_campaign_module_to_show_campaign_act.3085f23e"
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(translatedMarkup.includes('class="metric-card metric-info"'), "the visual tone is preserved");
|
||||||
|
assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated");
|
||||||
|
assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated");
|
||||||
|
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
|
||||||
|
|
||||||
|
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
|
||||||
|
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");
|
||||||
68
webui/tests/people-picker.test.tsx
Normal file
68
webui/tests/people-picker.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import PeoplePicker from "../src/components/people/PeoplePicker";
|
||||||
|
import {
|
||||||
|
dedupePeoplePickerItems,
|
||||||
|
externalPeoplePickerItem,
|
||||||
|
selectionFromPeoplePickerCandidate,
|
||||||
|
type PeoplePickerSearch
|
||||||
|
} from "../src/components/people/peoplePickerTypes";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
selection_key: "account:account-1",
|
||||||
|
kind: "account" as const,
|
||||||
|
reference_id: "account-1",
|
||||||
|
display_name: "Ada Lovelace",
|
||||||
|
email: "Ada@Example.Test",
|
||||||
|
source_module: "access",
|
||||||
|
source_label: "Accounts"
|
||||||
|
};
|
||||||
|
const contact = {
|
||||||
|
selection_key: "contact:contact-1:ada@example.test",
|
||||||
|
kind: "contact" as const,
|
||||||
|
reference_id: "contact-1",
|
||||||
|
display_name: "Ada Contact",
|
||||||
|
email: "ada@example.test",
|
||||||
|
source_module: "addresses",
|
||||||
|
source_label: "Contacts"
|
||||||
|
};
|
||||||
|
|
||||||
|
const deduped = dedupePeoplePickerItems([account, contact]);
|
||||||
|
assert(deduped.length === 1, "the same email is selected only once across directory sources");
|
||||||
|
assert(deduped[0].reference_id === "account-1", "selection order decides which discoverable reference is retained");
|
||||||
|
|
||||||
|
const external = externalPeoplePickerItem(" Grace Hopper ", " GRACE@EXAMPLE.TEST ");
|
||||||
|
assert(external.display_name === "Grace Hopper", "manual names are normalized");
|
||||||
|
assert(external.email === "grace@example.test", "manual email addresses are normalized");
|
||||||
|
assert(external.selection_key === "external:grace@example.test", "manual entries receive stable opaque selection keys");
|
||||||
|
|
||||||
|
const selected = selectionFromPeoplePickerCandidate({ ...contact, disabled: true, disabled_reason: "Policy" });
|
||||||
|
assert(!("disabled" in selected), "result-only disabled state is not persisted in selections");
|
||||||
|
assert(!("disabled_reason" in selected), "result-only policy wording is not persisted in selections");
|
||||||
|
|
||||||
|
let searchCalled = false;
|
||||||
|
const search: PeoplePickerSearch = async () => {
|
||||||
|
searchCalled = true;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<PeoplePicker id="reviewers" value={[account]} onChange={() => undefined} search={search} help="Choose a reviewer." />
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(!searchCalled, "directory search is server-driven after user input, not during static rendering");
|
||||||
|
assert(markup.includes('role="combobox"'), "search exposes combobox semantics");
|
||||||
|
assert(markup.includes('aria-autocomplete="list"'), "search declares list autocomplete behavior");
|
||||||
|
assert(markup.includes('aria-controls="reviewers-results"'), "search and grouped result list are associated");
|
||||||
|
assert(markup.includes("inline-help"), "non-obvious picker fields use central inline help");
|
||||||
|
assert(markup.includes('role="table"'), "selected people are rendered through the central DataGrid");
|
||||||
|
assert(markup.includes("Ada Lovelace"), "selected structured rows retain the display name");
|
||||||
|
assert(markup.includes("Ada@Example.Test"), "selected structured rows retain the email address");
|
||||||
|
assert(markup.includes('aria-label="Add person"'), "row add uses an accessible central table action");
|
||||||
|
assert(markup.includes('aria-label="Remove person"'), "row removal uses an accessible central table action");
|
||||||
|
assert(markup.includes("Add external person"), "manual external entry remains a separate structured action");
|
||||||
@@ -23,12 +23,17 @@
|
|||||||
"tests/explorer-tree.test.tsx",
|
"tests/explorer-tree.test.tsx",
|
||||||
"tests/icon-button.test.tsx",
|
"tests/icon-button.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
|
"tests/metric-card.test.tsx",
|
||||||
|
"tests/people-picker.test.tsx",
|
||||||
"tests/resource-access-explanation.test.tsx",
|
"tests/resource-access-explanation.test.tsx",
|
||||||
"tests/selection-list.test.tsx",
|
"tests/selection-list.test.tsx",
|
||||||
"src/components/CredentialPanel.tsx",
|
"src/components/CredentialPanel.tsx",
|
||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
"src/components/PasswordField.tsx",
|
"src/components/PasswordField.tsx",
|
||||||
"src/components/MessageDisplayPanel.tsx",
|
"src/components/MessageDisplayPanel.tsx",
|
||||||
|
"src/components/MetricCard.tsx",
|
||||||
|
"src/components/people/PeoplePicker.tsx",
|
||||||
|
"src/components/people/peoplePickerTypes.ts",
|
||||||
"src/components/ResourceAccessExplanation.tsx",
|
"src/components/ResourceAccessExplanation.tsx",
|
||||||
"src/components/SelectionList.tsx",
|
"src/components/SelectionList.tsx",
|
||||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||||
@@ -42,6 +47,7 @@
|
|||||||
"src/components/IconButton.tsx",
|
"src/components/IconButton.tsx",
|
||||||
"src/components/admin/AdminIconButton.tsx",
|
"src/components/admin/AdminIconButton.tsx",
|
||||||
"src/components/ToggleSwitch.tsx",
|
"src/components/ToggleSwitch.tsx",
|
||||||
|
"src/components/table/DataGrid.tsx",
|
||||||
"src/components/table/TableActionGroup.tsx"
|
"src/components/table/TableActionGroup.tsx"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user