18 Commits

Author SHA1 Message Date
d487726f4d chore(release): bump Core to 0.1.13 2026-07-22 10:40:27 +02:00
e6fc07da37 chore(release): bundle Campaign 0.1.10 2026-07-22 10:38:37 +02:00
e6d589eb07 fix(release): package Core migration runtime 2026-07-22 10:34:34 +02:00
59610e21d2 chore(release): record reviewed 0.1.12 migration heads 2026-07-22 09:06:56 +02:00
cece71d945 feat(webui): synchronize external DataGrid queries 2026-07-22 09:03:11 +02:00
22e8183846 fix(webui): translate MetricCard content 2026-07-22 08:48:42 +02:00
aa111a5fe1 chore(release): bump Core to 0.1.12 2026-07-22 08:41:54 +02:00
e6062fe9e4 fix(webui): enforce full-result DataGrid queries 2026-07-22 08:05:11 +02:00
987ca894ed chore(release): record reviewed 0.1.11 migration heads 2026-07-22 04:42:30 +02:00
4caa326878 chore(core): bump version to 0.1.11 2026-07-22 03:41:24 +02:00
8c4c4456c6 feat(core): define auditable poll response retirement 2026-07-22 03:31:05 +02:00
6abe292ac8 feat(core): define governed poll participation contract 2026-07-22 03:21:03 +02:00
fea2807754 feat(core): define atomic poll option ordering 2026-07-22 03:20:28 +02:00
22646c614c feat(core): add bounded people picker foundation 2026-07-22 03:01:56 +02:00
17376332a2 feat(core): configure bounded scheduling cancellation notices 2026-07-22 02:58:09 +02:00
0946bc84a9 feat(core): support explicit public module routes 2026-07-22 02:58:03 +02:00
a18499cbb5 feat(core): require scoped user workflows 2026-07-22 01:55:24 +02:00
36d7b73bb5 fix(webui): allow card content to overflow 2026-07-22 01:49:24 +02:00
42 changed files with 2816 additions and 52 deletions

View File

@@ -5,7 +5,11 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan

View File

@@ -233,6 +233,15 @@ instead of reproducing their behavior.
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
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
`DismissibleAlert`. They never fall back to `window.alert`.

View File

@@ -81,8 +81,8 @@
],
"recorded_at": "2026-07-11T01:39:45Z",
"release": "0.1.7",
"track": "release",
"squash_policy": "reviewed-manual"
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
@@ -165,8 +165,380 @@
],
"recorded_at": "2026-07-20T02:45:41Z",
"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"
},
{
"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-22T08:39:02Z",
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.10"
version = "0.1.13"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -27,6 +27,12 @@ where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main"

View File

@@ -340,6 +340,7 @@ CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
@@ -405,6 +406,7 @@ REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216

View File

@@ -70,6 +70,15 @@ class FrontendRoute:
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)
class FrontendModule:
module_id: str
@@ -80,6 +89,7 @@ class FrontendModule:
asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = ()
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
@@ -237,6 +247,35 @@ class DocumentationTopic:
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)
class DocumentationContext:
registry: object

View 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",
]

View File

@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
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)
class PollInvitationCommand:
respondent_id: str | None = None
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
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)
class PollOptionRef:
id: str
@@ -138,6 +166,16 @@ class PollResponseRef:
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
class PollSchedulingProvider(Protocol):
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:
...
@@ -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:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
) -> PollResponseSubmissionProvider | None:
provider = poll_scheduling_provider(registry)
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
)

View 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",
]

View File

@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
PublicFrontendRoute,
ResourceAclProvider,
RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryProvider,
user_workflow_scope_condition_issues,
)
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,
)
permissions = _collect_manifest_permissions(ordered)
_validate_public_frontend_route_uniqueness(ordered)
_validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
@@ -369,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_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:
@@ -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):
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)
for item in frontend.nav_items:
_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")
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:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests:

View File

@@ -7,6 +7,7 @@ import logging
import os
from pathlib import Path
import re
import sysconfig
from typing import Any
from alembic import command
@@ -462,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
installed_runtime_root,
]
for candidate in candidates:
if candidate is None:

View File

@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
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.db.session import get_database
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:
if frontend is 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_contract_version": frontend.asset_manifest_contract_version,
"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],
"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]:
capabilities: list[str] = []
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")
def permissions(request: Request):
registry = _registry(request)

View File

@@ -76,6 +76,12 @@ class Settings(BaseSettings):
ge=0,
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")
# Development bootstrap only. Do not use this in production.

View File

@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
)
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)
self.assertEqual(200, first.status_code, first.text)
etag = first.headers.get("etag")

View 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()

View File

@@ -21,6 +21,18 @@ class InstallConfigTests(unittest.TestCase):
with self.assertRaises(ValidationError):
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:
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("MASTER_KEY_B64=<generate", 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_SECRET_ENV_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.assertNotIn("MASTER_KEY_B64=<generate", 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_SECRET_ENV_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)

View File

@@ -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("CELERY_ENABLED", "false")
from fastapi import APIRouter
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
@@ -99,7 +99,7 @@ from govoplan_core.core.module_package_catalog import (
sign_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.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
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.server.app import create_app
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.route_validation import RouteCollisionError
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)
if manifest.frontend.package_name is not None:
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.component.strip())
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
@@ -457,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "Frontend route"):
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:
first = APIRouter()
second = APIRouter()

View 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()

View File

@@ -3,7 +3,36 @@ from __future__ import annotations
import unittest
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):
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
self.assertEqual(command.label, "Tuesday 10:00")
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__":
unittest.main()

View 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()

143
tests/test_wheel_runtime.py Normal file
View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import sysconfig
import tempfile
import unittest
class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
access_repository = repository_root.parent / "govoplan-access"
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels"
wheel_directory.mkdir()
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(repository_root),
cwd=temporary_root,
)
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(access_repository),
cwd=temporary_root,
)
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
self.assertEqual(1, len(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
venv_python = virtual_environment / "bin" / "python"
self._run(
str(venv_python),
"-m",
"pip",
"install",
"--no-deps",
str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root,
)
database_path = temporary_root / "wheel-runtime.db"
probe = self._run(
str(venv_python),
"-c",
self._probe_script(),
str(database_path),
cwd=temporary_root,
env={
**os.environ,
# Reuse only third-party test dependencies from the parent
# environment. Editable .pth files are not processed from
# PYTHONPATH, so Core itself can only come from the wheel.
"PYTHONPATH": sysconfig.get_path("purelib"),
"GOVOPLAN_CORE_SOURCE_ROOT": "",
"GOVOPLAN_ENABLED_MODULES": "",
},
)
result = json.loads(probe.stdout)
installed_package = Path(result["package_file"])
runtime_root = Path(result["runtime_root"])
self.assertTrue(installed_package.is_relative_to(virtual_environment))
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertIn("4f2a9c8e7b6d", result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])
@staticmethod
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
env=env,
check=False,
capture_output=True,
text=True,
)
if result.returncode:
raise AssertionError(
f"Command failed ({result.returncode}): {' '.join(command)}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result
@staticmethod
def _probe_script() -> str:
return """
import json
from pathlib import Path
import sys
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect
import govoplan_core
from govoplan_core.db.migrations import _repo_root, alembic_config
database_url = f"sqlite:///{Path(sys.argv[1])}"
config = alembic_config(database_url=database_url, enabled_modules=())
command.upgrade(config, "heads")
script = ScriptDirectory.from_config(config)
engine = create_engine(database_url)
try:
with engine.connect() as connection:
tables = sorted(inspect(connection).get_table_names())
finally:
engine.dispose()
print(json.dumps({
"package_file": govoplan_core.__file__,
"runtime_root": str(_repo_root()),
"heads": sorted(script.get_heads()),
"tables": tables,
}))
"""
if __name__ == "__main__":
unittest.main()

View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.10",
"version": "0.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.10",
"version": "0.1.13",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",

View File

@@ -1,18 +1,18 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.10",
"version": "0.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.10",
"version": "0.1.13",
"dependencies": {
"@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/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
@@ -787,13 +787,13 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
"version": "0.1.10",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#735e874bd03c55c626347f5356301fe221145b98",
"dependencies": {
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.10",
"version": "0.1.13",
"private": true,
"type": "module",
"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-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: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: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"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.10",
"version": "0.1.13",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -31,7 +31,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"

View File

@@ -1,14 +1,14 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
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 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 PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
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 { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
@@ -30,7 +30,9 @@ export default function App() {
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true);
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true);
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 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 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]);
function updateSettings(next: ApiSettings) {
@@ -119,6 +125,14 @@ export default function App() {
};
}, [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(() => {
let cancelled = false;
setCheckingSession(true);
@@ -233,6 +247,18 @@ export default function App() {
return () => {cancelled = true;};
}, [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(() => {
if (!auth) return;
@@ -284,7 +310,7 @@ export default function App() {
if (checkingSession) {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing">
@@ -304,10 +330,21 @@ export default function App() {
if (!auth) {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider>
<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>
</UnsavedChangesProvider>
</PlatformModulesProvider>
@@ -342,6 +379,13 @@ export default function App() {
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!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) =>
<Route
key={route.path}

View File

@@ -1,8 +1,9 @@
import type { ApiSettings } from "../types";
import type { PlatformModuleInfo } from "../types";
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
export type PlatformStatusResponse = {
maintenance_mode: {
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
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> {
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
}

View File

@@ -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 }) {
const { translateText } = usePlatformLanguage();
const renderedValue = typeof value === "string" ? translateText(value) : value;
return (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
{detail && <div className="metric-detail">{detail}</div>}
<div className="metric-label">{translateText(label)}</div>
<div className="metric-value">{renderedValue}</div>
{detail && <div className="metric-detail">{translateText(detail)}</div>}
</div>
);
}

View 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;
}

View 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;
}

View File

@@ -30,18 +30,36 @@ export type DataGridQueryState = {
filters: Record<string, string>;
};
export type DataGridPagination = {
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
mode?: "client" | "server";
type DataGridPaginationBase = {
page: number;
pageSize: number;
totalRows?: number;
pageSizeOptions?: number[];
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
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";
export type DataGridColumn<T> = {
@@ -78,7 +96,7 @@ type DataGridState = {
bufferWidth?: number;
};
type DataGridProps<T> = {
type DataGridBaseProps<T> = {
id: string;
rows: T[];
columns: DataGridColumn<T>[];
@@ -98,11 +116,24 @@ type DataGridProps<T> = {
storageKey?: string;
initialFilters?: Record<string, string | string[]>;
initialSort?: {columnId: string;direction: DataGridSortDirection;};
pagination?: DataGridPagination;
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
onQueryChange?: (query: DataGridQueryState) => void;
/**
* Synchronize a query selected outside the grid, for example by a summary
* 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 = {
disabled?: boolean;
removeDisabled?: boolean;
@@ -179,6 +210,7 @@ export default function DataGrid<T>({
storageKey,
initialFilters = {},
initialSort,
query,
pagination,
onQueryChange
}: DataGridProps<T>) {
@@ -205,7 +237,15 @@ export default function DataGrid<T>({
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
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 [openFilterColumnId, setOpenFilterColumnId] = useState<string | 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 filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
const serverQueryMode = pagination?.mode === "server";
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 lastContainerWidthRef = useRef<number | undefined>(undefined);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
const serverQueryMode = pagination?.mode === "server";
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
useEffect(() => {
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
if (!serverQueryMode) lastServerQueryRef.current = null;
}, [pagination, serverQueryMode]);
useEffect(() => {
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
}, [columns, effectiveResizeBehavior]);
@@ -233,6 +280,10 @@ export default function DataGrid<T>({
setState((current) => mergeInitialSort(current, normalizedInitialSort));
}, [normalizedInitialSort]);
useEffect(() => {
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
}, [normalizedExternalQuery]);
useEffect(() => {
try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
@@ -460,10 +511,17 @@ export default function DataGrid<T>({
useEffect(() => {
if (!serverQueryMode) return;
onQueryChangeRef.current?.({
const query = {
sort: state.sort ?? null,
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]);
const filterTypes = useMemo(() => {
@@ -1202,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
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 {
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" });
}
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 {
if (value === null || value === undefined) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);

View File

@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…",
"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.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.",
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…",
"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.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.",

View File

@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
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 type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
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 InlineHelp } from "./components/help/InlineHelp";
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 type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";

View File

@@ -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 {
for (const module of modules) {
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
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));
}

View File

@@ -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 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 {
hasUiCapability as hasUiCapabilityForModules,
moduleInstalled as moduleInstalledForModules,
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
routeContributionsForModules as routeContributionsForModuleList,
uiCapabilities as uiCapabilitiesForModules,
uiCapability as uiCapabilityForModules } from
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
uiCapabilities: {
...(module.uiCapabilities ?? {}),
...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[] {
if (!platformModules?.length) return localModules;
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
return routeContributionsForModuleList(modules);
}
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
return publicRouteContributionsForModuleList(modules);
}
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
flatMap((capability) => capability.widgets ?? []).

View File

@@ -1516,6 +1516,166 @@
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. */
.toggle-switch-row {
display: flex;

View File

@@ -94,7 +94,8 @@
padding: 18px 34px 16px;
}
.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 h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}

View File

@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
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 AdminSectionRenderContext = PlatformRouteContext & {
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
render: (context: PlatformRouteContext) => ReactNode;
};
export type PlatformPublicRouteContribution = {
path: string;
order?: number;
render: (context: PlatformPublicRouteContext) => ReactNode;
};
export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformTranslationDictionary = Record<string, string>;
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
remoteAssetContractVersion?: string | null;
navItems?: PlatformNavItem[];
routes?: PlatformRouteContribution[];
publicRoutes?: PlatformPublicRouteContribution[];
translations?: PlatformTranslations;
uiCapabilities?: PlatformUiCapabilities;
runtimeUiCapabilities?: PlatformUiCapabilities;
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
asset_manifest_integrity?: string | null;
asset_manifest_contract_version?: string | null;
routes: PlatformFrontendRouteInfo[];
public_routes: Array<{
path: string;
component: string;
order: number;
}>;
nav: Array<{
path: string;
label: string;
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
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 = {
id: string;
name: string;

View File

@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
import { renderToStaticMarkup } from "react-dom/server";
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";
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-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");
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");

View 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");

View 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");

View File

@@ -23,12 +23,17 @@
"tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx",
"tests/mail-components.test.tsx",
"tests/metric-card.test.tsx",
"tests/people-picker.test.tsx",
"tests/resource-access-explanation.test.tsx",
"tests/selection-list.test.tsx",
"src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.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/SelectionList.tsx",
"src/components/mail/MailServerSettingsPanel.tsx",
@@ -42,6 +47,7 @@
"src/components/IconButton.tsx",
"src/components/admin/AdminIconButton.tsx",
"src/components/ToggleSwitch.tsx",
"src/components/table/DataGrid.tsx",
"src/components/table/TableActionGroup.tsx"
]
}