feat: add temporal context and contextual help

This commit is contained in:
2026-08-05 00:03:31 +02:00
parent 982ef636b8
commit add7a99f6d
43 changed files with 1878 additions and 167 deletions
+59
View File
@@ -0,0 +1,59 @@
# Contextual Help Contract
GovOPlaN exposes context-sensitive help through `F1` and the titlebar help
control. The shell resolves a stable help identity from the focused control,
its containing surface, and the current route. The Docs module then projects
the best visible user or administrator topic for that identity.
## Resolution Order
The WebUI resolves help in this order:
1. an explicit `helpContextId` or `data-help-context-id` on the focused item
2. the focused shared control's `interfaceId`, `helpTopicId`, and label key
3. a containing dialog, card, administration section, or page surface
4. the current registered route, including dynamic module routes
5. a stable route-derived fallback when no explicit identity is available
Focused field and action contexts retain the page context as
`fallback_context`. This lets Docs show a field-specific topic when one exists
and otherwise open the owning page or module documentation instead of a generic
help page.
## Documentation Lookup
Static `DocumentationTopic` contributions announce exact contexts through
`metadata.help_contexts`. Core publishes that catalogue with the enabled module
manifest, allowing the shell to link directly to an exact topic when possible.
Docs still performs the authoritative audience, permission, configured-state,
and documentation-type filtering.
When there is no exact topic, Docs resolves the page fallback and then the first
visible topic owned by the module. If Docs is unavailable, the shell opens the
hosted documentation with the same context parameters.
## Authoring Controls
Core shared controls expose stable help metadata. Prefer these props rather
than adding custom `F1` listeners:
- `interfaceId` identifies a durable UI surface or action.
- `helpContextId` identifies a documentation context when it differs from the
interface identity.
- `helpTopicId` links directly to a module-owned documentation topic.
- translated label keys provide deterministic field identities for ordinary
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
controls.
Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
static user/admin documentation baseline and should list its important route,
workflow, setting, permission, and limitation identities in
`metadata.help_contexts`.
## Boundary
Help identities describe presentation context; they are not authorization
claims. Opening help never bypasses route or documentation permissions. Docs
owns documentation projection, feature modules own their content, and Core owns
focus capture, context resolution, and fallback routing.
+2
View File
@@ -17,6 +17,8 @@ operator, and roadmap pages.
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. | | Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. | | External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. | | Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. |
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. | | Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. | | Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
| Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. | | Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. |
+3
View File
@@ -8,6 +8,9 @@ The shared contract lives in `govoplan_core.core.institutional`.
Core owns reference shapes and provider protocols only. It does not own shared Core owns reference shapes and provider protocols only. It does not own shared
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
own persistence and authorization; optional capabilities resolve the references. own persistence and authorization; optional capabilities resolve the references.
Interactive reads use the separate platform temporal-data context documented in
`TEMPORAL_DATA_CONTEXT.md`; it never changes current authorization or supplies
mutation dates.
## Envelope ## Envelope
+1 -1
View File
@@ -13,7 +13,7 @@ domain modules own their compositions.
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` | | Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` | | Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract | | Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build | | Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
## Boundary ## Boundary
+68
View File
@@ -0,0 +1,68 @@
# Temporal Data Context
GovOPlaN exposes one read context for data validity and system knowledge. The
calendar control in the authenticated titlebar applies that context to
supported list and detail reads for the current account and tenant.
## Two Independent Axes
- **Valid time** answers when a fact applied in the represented domain.
- **Recorded time** answers what the system had recorded by a particular
instant.
The default is data valid now under the latest recorded state. `At time`
selects a valid-time instant. `All` removes the valid-time interval filter but
still uses the selected recorded state. The optional recorded-state cutoff can
be combined with any valid-time mode, which keeps correction history distinct
from changes in real-world validity.
An interval is half open: `valid_from <= instant < valid_to`. A revision belongs
to a recorded-state snapshot when `recorded_at <= cutoff` and it was not
superseded at or before that cutoff.
## Security And Mutation Rules
The temporal data context is a read projection, not an authorization context.
Authentication, permissions, active delegations, tenant boundaries, module
policy, and maintenance controls are always evaluated under current security
state. A historical projection never restores an expired permission.
The context also does not supply mutation dates. Writes continue to target the
current lifecycle revision and must carry their explicit valid/effective dates,
expected revision, reason, and evidence where the owning contract requires
them. A screen showing historical data must not silently turn a normal edit
into a historical correction.
## HTTP Contract
Core accepts these request headers:
| Header | Meaning |
| --- | --- |
| `X-Govoplan-Validity-Mode` | `current`, `at`, or `all` |
| `X-Govoplan-Valid-At` | Timezone-aware ISO 8601 instant required by `at` |
| `X-Govoplan-Recorded-At` | Optional timezone-aware system-knowledge cutoff |
Invalid or naive timestamps fail with HTTP 400. Responses expose the resolved
mode and evaluated instant. Conditional JSON responses vary by all three
request headers, and the shared WebUI API client includes them in request
deduplication and conditional-cache keys.
## Module Adoption
Revision-owning modules apply
`govoplan_core.db.temporal.apply_temporal_revision_filter` only to read queries
that are meant to follow the platform context. Explicit version references and
explicit resolver `effective_at` arguments take precedence. Current-row
lookups used for optimistic concurrency, authorization, routing, effects, or
other mutations must remain explicit and context-independent.
The initial bitemporal adoption covers Decisions, Mandates, Parties, and
Services. Their immutable revisions have indexed valid, recorded, and
superseded timestamps. Modules with effective-dated security records or
recorded-only revision histories require separate display-query adoption so
the global selector cannot affect current authorization or execution.
The WebUI selection is stored in session storage per account and tenant. A
change remounts the active module route so existing page loaders issue a fresh
request. Returning both axes to their defaults removes the stored selection.
+3 -1
View File
@@ -51,10 +51,12 @@ contestability, responsibility, and traceability at the point of action.
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code | | UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables | | UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell | | UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell | | UX-028 | Maintenance and offline states use their established textual warning banners centered in the titlebar. They must not recolor the shell or add decorative status icons. Global search is a right-side command, so warnings do not replace its trigger or overlay. | Accepted | Core WebUI shell |
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs | | UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces | | UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs | | UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal
TemporalValidityMode = Literal["current", "at", "all"]
VALIDITY_MODES = frozenset({"current", "at", "all"})
VALIDITY_MODE_HEADER = "X-Govoplan-Validity-Mode"
VALID_AT_HEADER = "X-Govoplan-Valid-At"
RECORDED_AT_HEADER = "X-Govoplan-Recorded-At"
TEMPORAL_EVALUATED_AT_HEADER = "X-Govoplan-Temporal-Evaluated-At"
TEMPORAL_VARY_HEADERS = (
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
RECORDED_AT_HEADER,
)
class TemporalContextError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class TemporalDataContext:
"""Bitemporal read context.
Valid time answers when a fact applies. Recorded time answers which version
of that fact was known to the system. Authorization remains outside this
context and is always evaluated under the current security state.
"""
validity_mode: TemporalValidityMode = "current"
valid_at: datetime | None = None
recorded_at: datetime | None = None
evaluated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def __post_init__(self) -> None:
if self.validity_mode not in VALIDITY_MODES:
raise TemporalContextError(
f"Unsupported temporal validity mode: {self.validity_mode!r}."
)
for name in ("valid_at", "recorded_at", "evaluated_at"):
value = getattr(self, name)
if value is not None and value.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
if self.validity_mode == "at" and self.valid_at is None:
raise TemporalContextError("Validity mode 'at' requires valid_at.")
if self.validity_mode != "at" and self.valid_at is not None:
raise TemporalContextError(
"valid_at is only permitted when validity mode is 'at'."
)
@property
def validity_instant(self) -> datetime | None:
if self.validity_mode == "all":
return None
if self.validity_mode == "at":
return self.valid_at
return self.evaluated_at
@property
def is_default(self) -> bool:
return self.validity_mode == "current" and self.recorded_at is None
def to_dict(self) -> dict[str, str | None]:
return {
"validity_mode": self.validity_mode,
"valid_at": _datetime_text(self.valid_at),
"recorded_at": _datetime_text(self.recorded_at),
"evaluated_at": _datetime_text(self.evaluated_at),
}
_temporal_context: ContextVar[TemporalDataContext | None] = ContextVar(
"govoplan_temporal_data_context",
default=None,
)
def parse_temporal_data_context(
*,
validity_mode: str | None = None,
valid_at: str | None = None,
recorded_at: str | None = None,
evaluated_at: datetime | None = None,
) -> TemporalDataContext:
clean_mode = (validity_mode or "current").strip().lower()
if clean_mode not in VALIDITY_MODES:
raise TemporalContextError(
"Temporal validity mode must be one of: current, at, all."
)
return TemporalDataContext(
validity_mode=clean_mode, # type: ignore[arg-type]
valid_at=_parse_datetime(valid_at, "valid_at"),
recorded_at=_parse_datetime(recorded_at, "recorded_at"),
evaluated_at=evaluated_at or datetime.now(UTC),
)
def current_temporal_data_context() -> TemporalDataContext:
return _temporal_context.get() or TemporalDataContext()
def bind_temporal_data_context(
context: TemporalDataContext,
) -> Token[TemporalDataContext | None]:
return _temporal_context.set(context)
def reset_temporal_data_context(token: Token[TemporalDataContext | None]) -> None:
_temporal_context.reset(token)
def temporal_revision_matches(
context: TemporalDataContext,
*,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
revision_recorded_at: datetime | None = None,
superseded_at: datetime | None = None,
) -> bool:
cutoff = context.recorded_at
if cutoff is None:
if superseded_at is not None:
return False
else:
if revision_recorded_at is None or revision_recorded_at > cutoff:
return False
if superseded_at is not None and superseded_at <= cutoff:
return False
instant = context.validity_instant
if instant is None:
return True
return (valid_from is None or valid_from <= instant) and (
valid_to is None or valid_to > instant
)
def _parse_datetime(value: str | None, name: str) -> datetime | None:
clean = str(value or "").strip()
if not clean:
return None
if len(clean) > 64:
raise TemporalContextError(f"Temporal {name} is too long.")
normalized = f"{clean[:-1]}+00:00" if clean.endswith(("Z", "z")) else clean
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise TemporalContextError(
f"Temporal {name} must be an ISO 8601 timestamp."
) from exc
if parsed.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
return parsed.astimezone(UTC)
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = [
"RECORDED_AT_HEADER",
"TEMPORAL_EVALUATED_AT_HEADER",
"TEMPORAL_VARY_HEADERS",
"VALIDITY_MODE_HEADER",
"VALID_AT_HEADER",
"TemporalContextError",
"TemporalDataContext",
"TemporalValidityMode",
"bind_temporal_data_context",
"current_temporal_data_context",
"parse_temporal_data_context",
"reset_temporal_data_context",
"temporal_revision_matches",
]
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import or_
from govoplan_core.core.temporal import (
TemporalContextError,
TemporalDataContext,
current_temporal_data_context,
)
def apply_temporal_revision_filter(
query: Any,
model: type[Any],
*,
context: TemporalDataContext | None = None,
valid_from: str | None = "valid_from",
valid_to: str | None = "valid_to",
recorded_at: str | None = "recorded_at",
superseded_at: str | None = "superseded_at",
) -> Any:
"""Apply latest/as-recorded and valid-time clauses to a revision query."""
resolved = context or current_temporal_data_context()
clauses: list[Any] = []
superseded_column = _optional_column(model, superseded_at)
recorded_column = _optional_column(model, recorded_at)
if resolved.recorded_at is None:
if superseded_column is not None:
clauses.append(superseded_column.is_(None))
else:
if recorded_column is None or superseded_column is None:
raise TemporalContextError(
f"{model.__name__} does not expose recorded/superseded revision time."
)
clauses.extend(
(
recorded_column <= resolved.recorded_at,
or_(
superseded_column.is_(None),
superseded_column > resolved.recorded_at,
),
)
)
instant = resolved.validity_instant
if instant is not None:
valid_from_column = _optional_column(model, valid_from)
valid_to_column = _optional_column(model, valid_to)
if valid_from_column is not None:
clauses.append(
or_(valid_from_column.is_(None), valid_from_column <= instant)
)
if valid_to_column is not None:
clauses.append(or_(valid_to_column.is_(None), valid_to_column > instant))
return query.filter(*clauses) if clauses else query
def _optional_column(model: type[Any], name: str | None) -> Any | None:
if name is None:
return None
column = getattr(model, name, None)
if column is None:
raise TemporalContextError(
f"{model.__name__} has no temporal column named {name!r}."
)
return column
__all__ = ["apply_temporal_revision_filter"]
@@ -7,7 +7,15 @@ from fastapi import Request
from starlette.responses import Response from starlette.responses import Response
JSON_CACHE_CONTROL = "private, no-cache" JSON_CACHE_CONTROL = "private, no-cache"
JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language") JSON_ETAG_VARY_HEADERS = (
"Authorization",
"Cookie",
"X-API-Key",
"Accept-Language",
"X-Govoplan-Validity-Mode",
"X-Govoplan-Valid-At",
"X-Govoplan-Recorded-At",
)
async def conditional_json_get_middleware( async def conditional_json_get_middleware(
+2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
from govoplan_core.server.temporal import temporal_data_context_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request") logger = logging.getLogger("govoplan.request")
@@ -169,6 +170,7 @@ def create_govoplan_app(
return response return response
app.middleware("http")(conditional_json_get_middleware) app.middleware("http")(conditional_json_get_middleware)
app.middleware("http")(temporal_data_context_middleware)
origins = [item.strip() for item in cors_origins if item.strip()] origins = [item.strip() for item in cors_origins if item.strip()]
if origins: if origins:
+25
View File
@@ -139,6 +139,29 @@ def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]
] ]
def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, object]]:
contexts: list[dict[str, object]] = []
seen: set[str] = set()
for topic in manifest.documentation:
raw_contexts = topic.metadata.get("help_contexts", ())
if isinstance(raw_contexts, str) or not isinstance(raw_contexts, (list, tuple, set)):
continue
for raw_context in raw_contexts:
context_id = str(raw_context).strip()
if not context_id or context_id in seen:
continue
seen.add(context_id)
contexts.append(
{
"id": context_id,
"topic_id": topic.id,
"title": topic.title,
"documentation_types": list(topic.documentation_types),
}
)
return contexts
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None: def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
frontend = manifest.frontend frontend = manifest.frontend
if frontend is None: if frontend is None:
@@ -240,6 +263,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
for key, value in manifest_interface_catalog(manifest).items() for key, value in manifest_interface_catalog(manifest).items()
if key != "declarations" if key != "declarations"
}, },
"help_contexts": _documentation_help_contexts(manifest),
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items], "nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest), "frontend": _frontend_payload(manifest),
} }
@@ -274,6 +298,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"id": manifest.id, "id": manifest.id,
"name": manifest.name, "name": manifest.name,
"version": manifest.version, "version": manifest.version,
"help_contexts": _documentation_help_contexts(manifest),
"frontend": _public_frontend_payload(manifest.frontend), "frontend": _public_frontend_payload(manifest.frontend),
} }
for manifest in registry.manifests() for manifest in registry.manifests()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from govoplan_core.core.temporal import (
RECORDED_AT_HEADER,
TEMPORAL_EVALUATED_AT_HEADER,
TEMPORAL_VARY_HEADERS,
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
TemporalContextError,
TemporalDataContext,
bind_temporal_data_context,
current_temporal_data_context,
parse_temporal_data_context,
reset_temporal_data_context,
)
async def temporal_data_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
try:
context = parse_temporal_data_context(
validity_mode=request.headers.get(VALIDITY_MODE_HEADER),
valid_at=request.headers.get(VALID_AT_HEADER),
recorded_at=request.headers.get(RECORDED_AT_HEADER),
evaluated_at=datetime.now(UTC),
)
except TemporalContextError as exc:
return JSONResponse(status_code=400, content={"detail": str(exc)})
request.state.govoplan_temporal_data_context = context
token = bind_temporal_data_context(context)
try:
response = await call_next(request)
finally:
reset_temporal_data_context(token)
response.headers[VALIDITY_MODE_HEADER] = context.validity_mode
response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp(
context.evaluated_at
)
if context.valid_at is not None:
response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at)
if context.recorded_at is not None:
response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at)
_merge_vary(response, TEMPORAL_VARY_HEADERS)
return response
def get_temporal_data_context(request: Request) -> TemporalDataContext:
context = getattr(request.state, "govoplan_temporal_data_context", None)
return context if isinstance(context, TemporalDataContext) else current_temporal_data_context()
def _merge_vary(response: Response, names: tuple[str, ...]) -> None:
current = {
item.strip().lower(): item.strip()
for item in response.headers.get("Vary", "").split(",")
if item.strip()
}
for name in names:
current.setdefault(name.lower(), name)
response.headers["Vary"] = ", ".join(current.values())
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"]
+18
View File
@@ -48,6 +48,10 @@ class ConditionalRequestTests(unittest.TestCase):
self.assertIn("private", first.headers.get("cache-control", "")) self.assertIn("private", first.headers.get("cache-control", ""))
self.assertIn("no-cache", first.headers.get("cache-control", "")) self.assertIn("no-cache", first.headers.get("cache-control", ""))
self.assertIn("authorization", first.headers.get("vary", "").lower()) self.assertIn("authorization", first.headers.get("vary", "").lower())
self.assertIn(
"x-govoplan-validity-mode",
first.headers.get("vary", "").lower(),
)
self.assertEqual("request-1", first.headers["X-Correlation-ID"]) self.assertEqual("request-1", first.headers["X-Correlation-ID"])
second = client.get("/json", headers={"If-None-Match": etag or "", "X-Request-ID": "request-2"}) second = client.get("/json", headers={"If-None-Match": etag or "", "X-Request-ID": "request-2"})
@@ -56,6 +60,20 @@ class ConditionalRequestTests(unittest.TestCase):
self.assertEqual(etag, second.headers.get("etag")) self.assertEqual(etag, second.headers.get("etag"))
self.assertEqual("request-2", second.headers["X-Correlation-ID"]) self.assertEqual("request-2", second.headers["X-Correlation-ID"])
historical = client.get(
"/json",
headers={
"X-Govoplan-Validity-Mode": "at",
"X-Govoplan-Valid-At": "2025-02-03T10:30:00Z",
},
)
self.assertEqual(200, historical.status_code, historical.text)
self.assertEqual("at", historical.headers["X-Govoplan-Validity-Mode"])
self.assertIn(
"x-govoplan-valid-at",
historical.headers.get("vary", "").lower(),
)
def test_changed_json_body_does_not_match_previous_etag(self) -> None: def test_changed_json_body_does_not_match_previous_etag(self) -> None:
with self._client() as client: with self._client() as client:
first = client.get("/json?value=alpha") first = client.get("/json?value=alpha")
+24 -3
View File
@@ -106,7 +106,7 @@ from govoplan_core.core.module_package_catalog import (
sign_module_package_catalog, sign_module_package_catalog,
validate_module_package_catalog, validate_module_package_catalog,
) )
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
from govoplan_core.core.module_guards import drop_table_retirement_provider from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, PermissionDefinition, RoleTemplate from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, PermissionDefinition, RoleTemplate
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -538,6 +538,15 @@ class ModuleSystemTests(unittest.TestCase):
name="Example", name="Example",
version="test", version="test",
public_tenant_resolver=lambda _request, _session: "tenant-1", public_tenant_resolver=lambda _request, _session: "tenant-1",
documentation=(
DocumentationTopic(
id="example.public-help",
title="Example public help",
summary="Help for the public example route.",
documentation_types=("user",),
metadata={"help_contexts": ["example.public"]},
),
),
frontend=FrontendModule( frontend=FrontendModule(
module_id="example", module_id="example",
package_name="@govoplan/example-webui", package_name="@govoplan/example-webui",
@@ -572,6 +581,15 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]]) self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
public_module = response.json()["modules"][0] public_module = response.json()["modules"][0]
self.assertNotIn("dependencies", public_module) self.assertNotIn("dependencies", public_module)
self.assertEqual(
[{
"id": "example.public",
"topic_id": "example.public-help",
"title": "Example public help",
"documentation_types": ["user"],
}],
public_module["help_contexts"],
)
self.assertNotIn("nav", public_module["frontend"]) self.assertNotIn("nav", public_module["frontend"])
self.assertNotIn("routes", public_module["frontend"]) self.assertNotIn("routes", public_module["frontend"])
self.assertEqual( self.assertEqual(
@@ -3591,8 +3609,11 @@ finally:
) )
self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower()) self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.9", modules["files"]["version"]) files_version = importlib.import_module(
self.assertIn("@v0.1.9", modules["files"]["python_ref"]) "govoplan_files.backend.manifest"
).get_manifest().version
self.assertEqual(files_version, modules["files"]["version"])
self.assertIn(f"@v{files_version}", modules["files"]["python_ref"])
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from fastapi import APIRouter
from fastapi.testclient import TestClient
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.temporal import (
TemporalContextError,
current_temporal_data_context,
parse_temporal_data_context,
temporal_revision_matches,
)
from govoplan_core.server.fastapi import create_govoplan_app
NOW = datetime(2026, 8, 4, 12, 0, tzinfo=UTC)
class TemporalContextTests(unittest.TestCase):
def test_valid_and_recorded_time_are_independent(self) -> None:
context = parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-03T10:30:00+01:00",
recorded_at="2025-03-01T00:00:00Z",
evaluated_at=NOW,
)
self.assertEqual("at", context.validity_mode)
self.assertEqual(datetime(2025, 2, 3, 9, 30, tzinfo=UTC), context.valid_at)
self.assertEqual(datetime(2025, 3, 1, tzinfo=UTC), context.recorded_at)
self.assertFalse(context.is_default)
def test_at_requires_zoned_valid_at_and_other_modes_reject_it(self) -> None:
with self.assertRaisesRegex(TemporalContextError, "requires valid_at"):
parse_temporal_data_context(validity_mode="at", evaluated_at=NOW)
with self.assertRaisesRegex(TemporalContextError, "include a timezone"):
parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-03T10:30:00",
evaluated_at=NOW,
)
with self.assertRaisesRegex(TemporalContextError, "only permitted"):
parse_temporal_data_context(
validity_mode="all",
valid_at="2025-02-03T10:30:00Z",
evaluated_at=NOW,
)
def test_revision_matching_uses_half_open_valid_and_recorded_intervals(self) -> None:
context = parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-10T00:00:00Z",
recorded_at="2025-02-15T00:00:00Z",
evaluated_at=NOW,
)
self.assertTrue(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
valid_to=datetime(2025, 3, 1, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC),
superseded_at=datetime(2025, 2, 16, tzinfo=UTC),
)
)
self.assertFalse(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
valid_to=datetime(2025, 2, 10, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC),
)
)
self.assertFalse(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 15, tzinfo=UTC)
+ timedelta(microseconds=1),
)
)
def test_request_headers_bind_context_and_invalid_headers_fail_closed(self) -> None:
router = APIRouter()
@router.get("/temporal")
def temporal_payload() -> dict[str, str | None]:
return current_temporal_data_context().to_dict()
app = create_govoplan_app(
title="temporal context test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client:
response = client.get(
"/temporal",
headers={
"X-Govoplan-Validity-Mode": "at",
"X-Govoplan-Valid-At": "2025-02-03T10:30:00Z",
"X-Govoplan-Recorded-At": "2025-03-01T00:00:00Z",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("at", response.json()["validity_mode"])
self.assertEqual("2025-02-03T10:30:00Z", response.json()["valid_at"])
self.assertEqual("at", response.headers["X-Govoplan-Validity-Mode"])
self.assertIn(
"x-govoplan-valid-at",
response.headers.get("vary", "").lower(),
)
invalid = client.get(
"/temporal",
headers={"X-Govoplan-Validity-Mode": "at"},
)
self.assertEqual(400, invalid.status_code, invalid.text)
self.assertIn("requires valid_at", invalid.json()["detail"])
if __name__ == "__main__":
unittest.main()
+16 -3
View File
@@ -12,7 +12,11 @@ const credentials = read("src/components/CredentialEnvelopeManager.tsx");
const iconRail = read("src/layout/IconRail.tsx"); const iconRail = read("src/layout/IconRail.tsx");
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx"); const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx");
const titlebar = read("src/layout/Titlebar.tsx"); const titlebar = read("src/layout/Titlebar.tsx");
const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx");
const helpMenu = read("src/layout/HelpMenu.tsx");
const helpContext = read("src/utils/helpContext.ts");
const layoutStyles = read("src/styles/layout.css"); const layoutStyles = read("src/styles/layout.css");
const authGateStyles = read("src/styles/auth-gate.css");
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation"); assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state"); assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state");
@@ -33,10 +37,19 @@ assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1
assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed"); assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed");
assert.match(layoutStyles, /\.icon-rail-bottom \{[^}]*flex: 0 0 auto;/, "the rail utility controls remain fixed"); assert.match(layoutStyles, /\.icon-rail-bottom \{[^}]*flex: 0 0 auto;/, "the rail utility controls remain fixed");
assert.match(titlebar, /className="titlebar-status-pattern"/, "shell state uses a titlebar background pattern"); assert.doesNotMatch(titlebar, /titlebar-status-pattern|TriangleAlert|WifiOff/, "shell state uses the readable text warnings rather than icon or background decoration");
assert.match(titlebar, /className="titlebar-global-search"/, "global search retains its dedicated titlebar grid cell"); assert.doesNotMatch(titlebar, /titlebar-global-search|has-global-search/, "global search no longer reserves a centered titlebar grid cell");
assert.match(titlebar, /<GlobalSearch[\s\S]*<LanguageMenu \/>[\s\S]*<ViewSelector[\s\S]*<TemporalDataMenu \/>/, "search is the first command before language, View, and temporal selectors");
assert.match(titlebar, /className="account-pill"[\s\S]*aria-label=\{displayUserName\}[\s\S]*aria-haspopup="menu"/, "the compact account menu retains an accessible name and menu state"); assert.match(titlebar, /className="account-pill"[\s\S]*aria-label=\{displayUserName\}[\s\S]*aria-haspopup="menu"/, "the compact account menu retains an accessible name and menu state");
assert.doesNotMatch(layoutStyles, /\.maintenance-topbar-link[^}]*position: absolute;/, "maintenance state does not occupy the centered search position"); assert.match(temporalDataMenu, /Calendar, CalendarOff/, "the temporal selector distinguishes bounded and all-validity modes");
assert.match(temporalDataMenu, /useUnsavedChanges[\s\S]*requestNavigation/, "changing temporal context cannot silently discard a dirty page");
assert.match(temporalDataMenu, /recordedAt/, "valid time and recorded time remain independently selectable");
assert.match(layoutStyles, /\.maintenance-topbar-link,[\s\S]*\.backend-offline-topbar-alert \{[^}]*top: 50%;[^}]*transform: translate\(-50%, -50%\);/, "maintenance and offline warnings use their established centered titlebar placement");
assert.match(authGateStyles, /\.titlebar-link,[\s\S]*\.account-pill \{[^}]*margin: 0;/, "text and account titlebar controls use the same action spacing as icon controls");
assert.match(helpMenu, /helpContextForTarget\(routeHelpContext, event\.target, modules\)/, "F1 resolves the currently focused interface item");
assert.match(helpContext, /function moduleRouteContext/, "context help covers contributed module routes");
assert.match(helpContext, /function sectionContext/, "context help covers contributed administration and settings sections");
assert.match(helpContext, /data-help-context-id/, "context help honors explicit control metadata");
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S]*grid-template-rows: 104px 51px minmax\(0, 1fr\);/, "the narrow shell reserves two non-overlapping titlebar rows"); assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S]*grid-template-rows: 104px 51px minmax\(0, 1fr\);/, "the narrow shell reserves two non-overlapping titlebar rows");
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions"); assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions");
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text"); assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text");
+21 -2
View File
@@ -11,6 +11,8 @@ import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext"; import { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext";
import { PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT } from "./platform/temporal";
import { import {
PLATFORM_VIEW_CHANGED_EVENT, PLATFORM_VIEW_CHANGED_EVENT,
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT, PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
@@ -53,6 +55,7 @@ export default function App() {
const [reloginMessage, setReloginMessage] = useState(""); const [reloginMessage, setReloginMessage] = useState("");
const [baseViewProjection, setBaseViewProjection] = useState<EffectiveViewProjection | null>(null); const [baseViewProjection, setBaseViewProjection] = useState<EffectiveViewProjection | null>(null);
const [workflowViewProjection, setWorkflowViewProjection] = useState<EffectiveViewProjection | null>(null); const [workflowViewProjection, setWorkflowViewProjection] = useState<EffectiveViewProjection | null>(null);
const [temporalRevision, setTemporalRevision] = useState(0);
const viewProjection = workflowViewProjection ?? baseViewProjection; const viewProjection = workflowViewProjection ?? baseViewProjection;
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]); const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
@@ -71,6 +74,20 @@ export default function App() {
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]); const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]); const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
useEffect(() => {
function reloadTemporalData() {
setTemporalRevision((current) => current + 1);
}
window.addEventListener(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
reloadTemporalData
);
return () => window.removeEventListener(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
reloadTemporalData
);
}, []);
useEffect(() => { useEffect(() => {
if (!auth || !viewsRuntime) { if (!auth || !viewsRuntime) {
setBaseViewProjection(null); setBaseViewProjection(null);
@@ -518,12 +535,13 @@ export default function App() {
onLanguageChange={persistLanguagePreference} onLanguageChange={persistLanguagePreference}
moduleTranslations={moduleTranslations}> moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<PlatformTemporalProvider scopeKey={`${auth.user?.id ?? "account"}:${(auth.active_tenant ?? auth.tenant).id}`}>
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}> <DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
<PlatformViewProvider modules={webModules} projection={viewProjection}> <PlatformViewProvider modules={webModules} projection={viewProjection}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<ModuleLoadBoundary resetKey={location.pathname} loading={webModulesLoading}> <ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}> <Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />} {!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
{publicRoutes.map((route) => {publicRoutes.map((route) =>
@@ -567,6 +585,7 @@ export default function App() {
</UnsavedChangesProvider> </UnsavedChangesProvider>
</PlatformViewProvider> </PlatformViewProvider>
</DocumentationHelpProvider> </DocumentationHelpProvider>
</PlatformTemporalProvider>
</PlatformModulesProvider> </PlatformModulesProvider>
</PlatformLanguageProvider>); </PlatformLanguageProvider>);
+9 -1
View File
@@ -1,4 +1,5 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
import { temporalRequestHeaders } from "../platform/temporal";
const STORAGE_KEY = "govoplan.apiSettings"; const STORAGE_KEY = "govoplan.apiSettings";
const LEGACY_STORAGE_KEYS: string[] = []; const LEGACY_STORAGE_KEYS: string[] = [];
@@ -341,6 +342,9 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
for (const [key, value] of authHeaders(settings)) { for (const [key, value] of authHeaders(settings)) {
headers.set(key, value); headers.set(key, value);
} }
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
if (!headers.has(key)) headers.set(key, value);
}
const csrf = csrfToken(); const csrf = csrfToken();
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) { if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
@@ -433,7 +437,11 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> { export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" }); const headers = authHeaders(settings);
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
headers.set(key, value);
}
const response = await fetch(apiUrl(settings, path), { headers, credentials: "include" });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
if (response.status === 401 && shouldNotifyAuthRequired(path)) { if (response.status === 401 && shouldNotifyAuthRequired(path)) {
+15 -2
View File
@@ -7,7 +7,20 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInte
disabledReason?: ReactNode; disabledReason?: ReactNode;
}; };
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) { export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpContextId, helpTopicId, children, ...props }: ButtonProps) {
const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />; const button = (
<button
data-help-scope="action"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof children === "string" ? children : undefined}
className={`btn btn-${variant} ${className}`}
disabled={disabled || Boolean(disabledReason)}
{...props}
>
{children}
</button>
);
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>; return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
} }
+11 -3
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
type CardProps = { type CardProps = PlatformInterfaceIdentityProps & {
title?: ReactNode; title?: ReactNode;
children: ReactNode; children: ReactNode;
actions?: ReactNode; actions?: ReactNode;
@@ -38,7 +39,7 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
// localStorage may be unavailable in private or restricted contexts. // localStorage may be unavailable in private or restricted contexts.
}} }}
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) { export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true, interfaceId, helpContextId, helpTopicId }: CardProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title); const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) })); const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
@@ -59,7 +60,14 @@ export default function Card({ title, children, actions, collapsible = false, co
} }
return ( return (
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}> <section
className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}
data-help-scope="interface"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof title === "string" ? title : undefined}
>
{hasHeader && {hasHeader &&
<header className="card-header"> <header className="card-header">
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)} {title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
+28 -6
View File
@@ -50,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
return `${date || dateString(new Date())}T${time || "00:00"}`; return `${date || dateString(new Date())}T${time || "00:00"}`;
} }
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) { export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
const selectedDate = parseDate(value); const selectedDate = parseDate(value);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date()); const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
@@ -94,7 +94,15 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
} }
return ( return (
<div ref={rootRef} className={`date-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}> <div
ref={rootRef}
className={`date-field ${className}`.trim()}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={props["aria-label"] ?? placeholder}
>
<input <input
{...props} {...props}
ref={inputRef} ref={inputRef}
@@ -146,7 +154,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
} }
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpTopicId, ...props }: BaseProps) { export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
const input = inputRef.current; const input = inputRef.current;
@@ -159,7 +167,14 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
}, [value, min, max]); }, [value, min, max]);
return ( return (
<div className={`time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}> <div
className={`time-field ${className}`.trim()}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={props["aria-label"] ?? placeholder}
>
<input <input
{...props} {...props}
ref={inputRef} ref={inputRef}
@@ -175,7 +190,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
} }
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpTopicId, ...props }: BaseProps) { export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
const parts = datePartsFromDateTime(value); const parts = datePartsFromDateTime(value);
const minParts = datePartsFromDateTime(min || ""); const minParts = datePartsFromDateTime(min || "");
const maxParts = datePartsFromDateTime(max || ""); const maxParts = datePartsFromDateTime(max || "");
@@ -189,7 +204,14 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
} }
return ( return (
<div className={`date-time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}> <div
className={`date-time-field ${className}`.trim()}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={props["aria-label"]}
>
<DateField <DateField
{...props} {...props}
value={parts.date} value={parts.date}
+11 -2
View File
@@ -8,8 +8,9 @@ import {
registerDialog, registerDialog,
type DialogStackId type DialogStackId
} from "./dialogStack"; } from "./dialogStack";
import type { PlatformInterfaceIdentityProps } from "../types";
export type DialogProps = { export type DialogProps = PlatformInterfaceIdentityProps & {
open: boolean; open: boolean;
title: ReactNode; title: ReactNode;
children: ReactNode; children: ReactNode;
@@ -56,7 +57,10 @@ export default function Dialog({
footerClassName = "", footerClassName = "",
portal = false, portal = false,
panelStyle, panelStyle,
backdropStyle backdropStyle,
interfaceId,
helpContextId,
helpTopicId
}: DialogProps) { }: DialogProps) {
const titleId = useId(); const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled; const canClose = Boolean(onClose) && !closeDisabled;
@@ -134,6 +138,11 @@ export default function Dialog({
role={role} role={role}
aria-modal="true" aria-modal="true"
data-dialog-stack-state="topmost" data-dialog-stack-state="topmost"
data-help-scope="dialog"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof title === "string" ? title : undefined}
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={ariaDescribedBy} aria-describedby={ariaDescribedBy}
> >
+10 -2
View File
@@ -12,11 +12,19 @@ type FormFieldProps = PlatformInterfaceIdentityProps & {
children: ReactNode; children: ReactNode;
}; };
export default function FormField({ label, help, documentation, children, interfaceId, helpTopicId }: FormFieldProps) { export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpTopicId }: FormFieldProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const renderedLabel = typeof label === "string" ? translateText(label) : label; const renderedLabel = typeof label === "string" ? translateText(label) : label;
return ( return (
<label className="form-field" data-interface-id={interfaceId} data-help-topic-id={helpTopicId}> <label
className="form-field"
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId ?? documentation?.contextId}
data-help-topic-id={helpTopicId ?? documentation?.topicId}
data-help-documentation-type={documentation?.documentationType}
data-help-key={typeof label === "string" ? label : undefined}
>
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel> <FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
{children} {children}
</label> </label>
@@ -98,6 +98,7 @@ export default function SearchableSelect({
debounceMs = 200, debounceMs = 200,
className = "", className = "",
interfaceId, interfaceId,
helpContextId,
helpTopicId helpTopicId
}: SearchableSelectProps) { }: SearchableSelectProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
@@ -297,8 +298,11 @@ export default function SearchableSelect({
<div <div
ref={rootRef} ref={rootRef}
className={rootClassName} className={rootClassName}
data-help-scope="field"
data-interface-id={interfaceId} data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId} data-help-topic-id={helpTopicId}
data-help-key={ariaLabel}
onBlur={closeOnFocusLeave} onBlur={closeOnFocusLeave}
> >
<div className="searchable-select-control"> <div className="searchable-select-control">
+9 -2
View File
@@ -14,7 +14,7 @@ type ToggleSwitchProps = PlatformInterfaceIdentityProps & {
help?: ReactNode; help?: ReactNode;
}; };
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpTopicId }: ToggleSwitchProps) { export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpContextId, helpTopicId }: ToggleSwitchProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined; const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
const renderedLabel = typeof label === "string" ? translateText(label) : label; const renderedLabel = typeof label === "string" ? translateText(label) : label;
@@ -22,7 +22,14 @@ export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checke
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel; const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined; const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
return ( return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}> <label
className={`toggle-switch-row ${disabled ? "disabled" : ""}`}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof label === "string" ? label : undefined}
>
<input <input
className="toggle-switch-input" className="toggle-switch-input"
type="checkbox" type="checkbox"
+15 -3
View File
@@ -3,8 +3,9 @@ import DismissibleAlert from "../DismissibleAlert";
import LoadingFrame from "../LoadingFrame"; import LoadingFrame from "../LoadingFrame";
import PageTitle from "../PageTitle"; import PageTitle from "../PageTitle";
import { usePlatformLanguage } from "../../i18n/LanguageContext"; import { usePlatformLanguage } from "../../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../../types";
type Props = { type Props = PlatformInterfaceIdentityProps & {
title: string; title: string;
description: string; description: string;
loading?: boolean; loading?: boolean;
@@ -25,11 +26,22 @@ export default function AdminPageLayout({
success = "", success = "",
actions, actions,
children, children,
className = "" className = "",
interfaceId,
helpContextId,
helpTopicId
}: Props) { }: Props) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
return ( return (
<div className={`admin-section-page ${className}`.trim()}> <div
className={`admin-section-page ${className}`.trim()}
data-help-scope="page"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-documentation-type="admin"
data-help-key={title}
>
<div className="page-heading split workspace-heading admin-page-heading"> <div className="page-heading split workspace-heading admin-page-heading">
<div> <div>
<PageTitle loading={loading}>{title}</PageTitle> <PageTitle loading={loading}>{title}</PageTitle>
@@ -46,6 +46,7 @@ export default function EmailAddressInput({
compact = false, compact = false,
showAddButton, showAddButton,
interfaceId, interfaceId,
helpContextId,
helpTopicId helpTopicId
}: EmailAddressInputProps) { }: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
@@ -202,7 +203,14 @@ export default function EmailAddressInput({
) : null; ) : null;
return ( return (
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}> <div
className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={emailPlaceholder}
>
<div className={`email-address-editor ${error ? "has-error" : ""}`}> <div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite"> <div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>} {normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
@@ -3,6 +3,8 @@ export const HOSTED_DOCUMENTATION_URL = "https://govoplan.add-ideas.de/";
export type DocumentationHelpReference = { export type DocumentationHelpReference = {
topicId?: string; topicId?: string;
contextId?: string; contextId?: string;
fallbackContextId?: string;
moduleId?: string;
documentationType?: "user" | "admin"; documentationType?: "user" | "admin";
anchorId?: string; anchorId?: string;
}; };
@@ -20,6 +22,10 @@ export function documentationHelpHref(
}); });
if (topicId) params.set("topic", topicId); if (topicId) params.set("topic", topicId);
else if (contextId) params.set("context", contextId); else if (contextId) params.set("context", contextId);
const fallbackContextId = reference.fallbackContextId?.trim();
if (fallbackContextId && fallbackContextId !== contextId) params.set("fallback_context", fallbackContextId);
const moduleId = reference.moduleId?.trim();
if (moduleId) params.set("module", moduleId);
const anchorId = reference.anchorId?.trim(); const anchorId = reference.anchorId?.trim();
return `${baseUrl}?${params.toString()}${anchorId ? `#${encodeURIComponent(anchorId)}` : ""}`; return `${baseUrl}?${params.toString()}${anchorId ? `#${encodeURIComponent(anchorId)}` : ""}`;
} }
+42 -2
View File
@@ -637,7 +637,27 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Enter a valid HTTP, HTTPS, mail or phone link.", "i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Enter a valid HTTP, HTTPS, mail or phone link.",
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Enter a valid HTTP, HTTPS, CID or raster data image.", "i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Enter a valid HTTP, HTTPS, CID or raster data image.",
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "This HTML uses markup outside the visual editor. Use HTML source mode to preserve it.", "i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "This HTML uses markup outside the visual editor. Use HTML source mode to preserve it.",
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive" "i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive",
"i18n:govoplan-core.data_state": "Data state",
"i18n:govoplan-core.validity": "Validity",
"i18n:govoplan-core.current": "Current",
"i18n:govoplan-core.at_time": "At time",
"i18n:govoplan-core.all": "All",
"i18n:govoplan-core.current_data": "Current data",
"i18n:govoplan-core.historical_data_state": "Historical data state",
"i18n:govoplan-core.historical_recorded_state": "Historical recorded state",
"i18n:govoplan-core.all_validity_periods": "All validity periods",
"i18n:govoplan-core.valid_at": "Valid at",
"i18n:govoplan-core.valid_at_help": "Select when the data was valid in the represented domain.",
"i18n:govoplan-core.recorded_state": "Recorded state",
"i18n:govoplan-core.recorded_state_help": "Optionally limit results to what the system had recorded by a point in time.",
"i18n:govoplan-core.latest_recorded_state": "Latest recorded state",
"i18n:govoplan-core.recorded_by_time": "State recorded by a point in time",
"i18n:govoplan-core.recorded_by": "Recorded by",
"i18n:govoplan-core.temporal_data_explanation": "Validity controls when a fact applies. Recorded state controls what the system knew. Access permissions are always evaluated now.",
"i18n:govoplan-core.apply_data_state": "Apply data state",
"i18n:govoplan-core.select_valid_date_time": "Select a valid date and time.",
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
}, },
"de": { "de": {
"i18n:govoplan-core.generate_password.bd5bede8": "Passwort generieren", "i18n:govoplan-core.generate_password.bd5bede8": "Passwort generieren",
@@ -1275,6 +1295,26 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Geben Sie einen gültigen HTTP-, HTTPS-, E-Mail- oder Telefon-Link ein.", "i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Geben Sie einen gültigen HTTP-, HTTPS-, E-Mail- oder Telefon-Link ein.",
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Geben Sie eine gültige HTTP-, HTTPS-, CID- oder Rasterdaten-Bildadresse ein.", "i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Geben Sie eine gültige HTTP-, HTTPS-, CID- oder Rasterdaten-Bildadresse ein.",
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "Dieses HTML verwendet Markup außerhalb des visuellen Editors. Verwenden Sie den HTML-Quelltextmodus, um es zu erhalten.", "i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "Dieses HTML verwendet Markup außerhalb des visuellen Editors. Verwenden Sie den HTML-Quelltextmodus, um es zu erhalten.",
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive" "i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive",
"i18n:govoplan-core.data_state": "Datenstand",
"i18n:govoplan-core.validity": "Gültigkeit",
"i18n:govoplan-core.current": "Aktuell",
"i18n:govoplan-core.at_time": "Zeitpunkt",
"i18n:govoplan-core.all": "Alle",
"i18n:govoplan-core.current_data": "Aktuell gültige Daten",
"i18n:govoplan-core.historical_data_state": "Historischer Datenstand",
"i18n:govoplan-core.historical_recorded_state": "Historischer Erfassungsstand",
"i18n:govoplan-core.all_validity_periods": "Alle Gültigkeitszeiträume",
"i18n:govoplan-core.valid_at": "Gültig am",
"i18n:govoplan-core.valid_at_help": "Wählen Sie, wann die Daten im dargestellten Sachverhalt gültig waren.",
"i18n:govoplan-core.recorded_state": "Erfassungsstand",
"i18n:govoplan-core.recorded_state_help": "Begrenzt die Ergebnisse optional auf den Stand, den das System bis zu einem Zeitpunkt erfasst hatte.",
"i18n:govoplan-core.latest_recorded_state": "Neuester Erfassungsstand",
"i18n:govoplan-core.recorded_by_time": "Bis zu einem Zeitpunkt erfasster Stand",
"i18n:govoplan-core.recorded_by": "Erfasst bis",
"i18n:govoplan-core.temporal_data_explanation": "Die Gültigkeit bestimmt, wann ein Sachverhalt gilt. Der Erfassungsstand bestimmt, was das System wusste. Berechtigungen werden immer aktuell geprüft.",
"i18n:govoplan-core.apply_data_state": "Datenstand anwenden",
"i18n:govoplan-core.select_valid_date_time": "Wählen Sie ein gültiges Datum und eine Uhrzeit.",
"i18n:govoplan-core.temporal_selection_invalid": "Der ausgewählte Datenstand ist ungültig."
} }
}; };
+2
View File
@@ -32,6 +32,8 @@ export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents"; export * from "./platform/moduleEvents";
export * from "./platform/ViewContext"; export * from "./platform/ViewContext";
export * from "./platform/views"; export * from "./platform/views";
export * from "./platform/temporal";
export * from "./platform/TemporalContext";
export * from "./platform/wizards"; export * from "./platform/wizards";
export * from "./utils/permissions"; export * from "./utils/permissions";
+31 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react"; import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import packageInfo from "../../package.json"; import packageInfo from "../../package.json";
@@ -7,7 +7,7 @@ import Dialog from "../components/Dialog";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { usePlatformModules } from "../platform/ModuleContext"; import { usePlatformModules } from "../platform/ModuleContext";
import type { PlatformWebModule } from "../types"; import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext"; import { helpContextForPathname, helpContextForTarget, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { AuthInfo } from "../types"; import type { AuthInfo } from "../types";
import { hasAnyScope } from "../utils/permissions"; import { hasAnyScope } from "../utils/permissions";
@@ -21,8 +21,12 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation(); const location = useLocation();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const helpContext = helpContextForPathname(location.pathname, location.search);
const modules = usePlatformModules(); const modules = usePlatformModules();
const routeHelpContext = useMemo(
() => helpContextForPathname(location.pathname, location.search, modules),
[location.pathname, location.search, modules]
);
const [activeHelpContext, setActiveHelpContext] = useState<HelpContext>(routeHelpContext);
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]); const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable; const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
@@ -35,6 +39,7 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setActiveHelpContext(helpContextForTarget(routeHelpContext, event.target, modules));
setOpen(false); setOpen(false);
setContextOpen(true); setContextOpen(true);
} }
@@ -49,22 +54,28 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
window.removeEventListener("keydown", openContextHelp, true); window.removeEventListener("keydown", openContextHelp, true);
window.removeEventListener("mousedown", onPointerDown); window.removeEventListener("mousedown", onPointerDown);
}; };
}, []); }, [modules, routeHelpContext]);
useEffect(() => {
if (!contextOpen) setActiveHelpContext(routeHelpContext);
}, [contextOpen, routeHelpContext]);
function openHelp() { function openHelp() {
setActiveHelpContext(routeHelpContext);
setOpen(false); setOpen(false);
setContextOpen(true); setContextOpen(true);
} }
function openDocs(type: "user" | "admin") { function openDocs(type: "user" | "admin", context: HelpContext = routeHelpContext) {
setOpen(false); setOpen(false);
setContextOpen(false); setContextOpen(false);
if (!docsAvailable) { if (!docsAvailable) {
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer"); window.open(externalDocsUrl(type, context), "_blank", "noopener,noreferrer");
return; return;
} }
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
params.set("context", helpContext.id); const contextParams = new URLSearchParams(helpQueryForContext(context));
contextParams.forEach((value, key) => params.set(key, value));
navigate(`/docs?${params.toString()}`); navigate(`/docs?${params.toString()}`);
} }
@@ -72,6 +83,9 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
<div className="context-menu-wrap" ref={wrapRef}> <div className="context-menu-wrap" ref={wrapRef}>
<button <button
className="titlebar-link" className="titlebar-link"
data-help-context-id="core.contextual-help"
data-help-module-id="core"
data-help-scope="action"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "F1") { if (event.key === "F1") {
@@ -90,11 +104,11 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small> <HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
</button> </button>
<hr /> <hr />
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}> <button className="dropdown-item" onClick={() => openDocs("user", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")} <BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button> </button>
{adminDocsAvailable && {adminDocsAvailable &&
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}> <button className="dropdown-item" onClick={() => openDocs("admin", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")} <BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button> </button>
} }
@@ -103,14 +117,16 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button> <button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
</div> </div>
} }
{contextOpen && <ContextHelpModal context={helpContext} onOpenDocs={() => openDocs("user")} onClose={() => setContextOpen(false)} />} {contextOpen && <ContextHelpModal context={activeHelpContext} onOpenDocs={() => openDocs(activeHelpContext.documentationType ?? "user", activeHelpContext)} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />} {aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
</div>); </div>);
} }
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string { function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
const params = new URLSearchParams({ type, context: context.id }); const params = new URLSearchParams({ type });
const contextParams = new URLSearchParams(helpQueryForContext(context));
contextParams.forEach((value, key) => params.set(key, value));
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`; return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
} }
@@ -125,13 +141,12 @@ function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContex
<div className="help-panel-section" data-help-context={context.id}> <div className="help-panel-section" data-help-context={context.id}>
<h3>{translateText(context.title)}</h3> <h3>{translateText(context.title)}</h3>
{context.parentId &&
<p className="muted"><strong>{translateText("i18n:govoplan-core.page.fb06270f")}:</strong> {translateText(context.parentTitle ?? context.parentId)}</p>
}
<p className="mono-small">{translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id}</p> <p className="mono-small">{translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id}</p>
<p className="muted">{translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} <span className="kbd">{helpQueryForContext(context)}</span> {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")}</p> <p className="muted">{translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} <span className="kbd">{helpQueryForContext(context)}</span> {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")}</p>
</div> <Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText(context.documentationType === "admin" ? "i18n:govoplan-core.open_admin_documentation.6adbdae3" : "i18n:govoplan-core.open_user_documentation.084af515")}</Button>
<div className="help-panel-section">
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText("i18n:govoplan-core.open_user_documentation.084af515")}</Button>
</div> </div>
</Dialog>); </Dialog>);
+9 -1
View File
@@ -25,7 +25,15 @@ export default function LanguageMenu() {
return ( return (
<div className="context-menu-wrap language-menu-wrap" ref={menuRef}> <div className="context-menu-wrap language-menu-wrap" ref={menuRef}>
<button className="titlebar-link language-menu-button" onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open}> <button
className="titlebar-link language-menu-button"
data-help-context-id="core.titlebar.language"
data-help-module-id="core"
data-help-scope="action"
onClick={() => setOpen(!open)}
aria-haspopup="menu"
aria-expanded={open}
>
<span className="language-menu-code">{language.toUpperCase()}</span> <span className="language-menu-code">{language.toUpperCase()}</span>
<span className="tenant-caret"></span> <span className="tenant-caret"></span>
</button> </button>
+220
View File
@@ -0,0 +1,220 @@
import { Calendar, CalendarOff } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import Button from "../components/Button";
import DismissibleAlert from "../components/DismissibleAlert";
import FormField from "../components/FormField";
import SegmentedControl from "../components/SegmentedControl";
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useTemporalDataContext } from "../platform/TemporalContext";
import {
normalizeTemporalDataSelection,
type TemporalDataSelection,
type TemporalValidityMode
} from "../platform/temporal";
export default function TemporalDataMenu() {
const { selection, applySelection, isDefault } = useTemporalDataContext();
const { translateText } = usePlatformLanguage();
const { requestNavigation } = useUnsavedChanges();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<TemporalDataSelection>(selection);
const [error, setError] = useState("");
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
if (!open) setDraft(selection);
}, [open, selection]);
function toggleOpen() {
if (!open) {
setDraft(selection);
setError("");
}
setOpen(!open);
}
function selectValidityMode(validityMode: TemporalValidityMode) {
setDraft((current) => ({
...current,
validityMode,
validAt: validityMode === "at"
? current.validAt ?? new Date().toISOString()
: null
}));
}
function selectRecordedMode(mode: "latest" | "at") {
setDraft((current) => ({
...current,
recordedAt: mode === "at"
? current.recordedAt ?? new Date().toISOString()
: null
}));
}
function apply() {
try {
const normalized = normalizeTemporalDataSelection(draft);
requestNavigation(() => {
applySelection(normalized);
setOpen(false);
setError("");
});
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "i18n:govoplan-core.temporal_selection_invalid"
);
}
}
const iconTitle = selection.validityMode === "all"
? "i18n:govoplan-core.all_validity_periods"
: selection.validityMode === "at"
? "i18n:govoplan-core.historical_data_state"
: selection.recordedAt
? "i18n:govoplan-core.historical_recorded_state"
: "i18n:govoplan-core.current_data";
const Icon = selection.validityMode === "all" ? CalendarOff : Calendar;
const validDateMissing = draft.validityMode === "at" && !draft.validAt;
return (
<div className="context-menu-wrap temporal-data-menu-wrap" ref={menuRef}>
<button
type="button"
className={`titlebar-icon-link${isDefault ? "" : " is-context-active"}`}
data-help-context-id="core.temporal-data-context"
data-help-module-id="core"
data-help-scope="action"
onClick={toggleOpen}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={translateText(iconTitle)}
title={translateText(iconTitle)}
>
<Icon size={18} aria-hidden="true" />
</button>
{open && (
<div
className="dropdown-menu temporal-data-menu"
role="dialog"
aria-label={translateText("i18n:govoplan-core.data_state")}
>
<div className="temporal-data-menu-heading">
<strong>i18n:govoplan-core.data_state</strong>
</div>
<SegmentedControl<TemporalValidityMode>
className="temporal-validity-control"
value={draft.validityMode}
width="fill"
size="equal"
ariaLabel={translateText("i18n:govoplan-core.validity")}
onChange={selectValidityMode}
options={[
{ id: "current", label: "i18n:govoplan-core.current" },
{ id: "at", label: "i18n:govoplan-core.at_time" },
{ id: "all", label: "i18n:govoplan-core.all" }
]}
/>
{draft.validityMode === "at" && (
<FormField
label="i18n:govoplan-core.valid_at"
help="i18n:govoplan-core.valid_at_help"
>
<input
type="datetime-local"
value={toLocalInput(draft.validAt)}
onChange={(event) => setDraft((current) => ({
...current,
validAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
<div className="temporal-recorded-section">
<FormField
label="i18n:govoplan-core.recorded_state"
help="i18n:govoplan-core.recorded_state_help"
>
<select
value={draft.recordedAt ? "at" : "latest"}
onChange={(event) => selectRecordedMode(
event.target.value === "at" ? "at" : "latest"
)}
>
<option value="latest">i18n:govoplan-core.latest_recorded_state</option>
<option value="at">i18n:govoplan-core.recorded_by_time</option>
</select>
</FormField>
{draft.recordedAt && (
<FormField label="i18n:govoplan-core.recorded_by">
<input
type="datetime-local"
value={toLocalInput(draft.recordedAt)}
onChange={(event) => setDraft((current) => ({
...current,
recordedAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
</div>
<p className="temporal-data-explanation">
i18n:govoplan-core.temporal_data_explanation
</p>
{error && (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
)}
<div className="temporal-data-menu-actions">
<Button
type="button"
variant="primary"
onClick={apply}
disabled={validDateMissing}
disabledReason={validDateMissing
? "i18n:govoplan-core.select_valid_date_time"
: undefined}
>
i18n:govoplan-core.apply_data_state
</Button>
</div>
</div>
)}
</div>
);
}
function toLocalInput(value: string | null): string {
if (!value) return "";
const instant = new Date(value);
if (!Number.isFinite(instant.getTime())) return "";
const local = new Date(instant.getTime() - instant.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function fromLocalInput(value: string): string | null {
if (!value) return null;
const instant = new Date(value);
return Number.isFinite(instant.getTime()) ? instant.toISOString() : null;
}
+31 -39
View File
@@ -1,8 +1,9 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
import { Bell, Check, LogOut, Settings, TriangleAlert, UserCircle, WifiOff } from "lucide-react"; import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types"; import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
import HelpMenu from "./HelpMenu"; import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu"; import LanguageMenu from "./LanguageMenu";
import TemporalDataMenu from "./TemporalDataMenu";
import LoginModal from "../features/auth/LoginModal"; import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert"; import DismissibleAlert from "../components/DismissibleAlert";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
@@ -48,8 +49,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
hasAnyScope(auth, searchRuntime.anyOf) hasAnyScope(auth, searchRuntime.anyOf)
) )
); );
const showGlobalSearch = Boolean(auth && GlobalSearch && canUseGlobalSearch);
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null; const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []); const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
const displayUserName = auth?.user?.display_name || auth?.user?.email || translateText("i18n:govoplan-core.sign_in.ada2e9e9"); const displayUserName = auth?.user?.display_name || auth?.user?.email || translateText("i18n:govoplan-core.sign_in.ada2e9e9");
@@ -62,7 +61,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
); );
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants)); const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const showContextSelectors = Boolean( const showContextSelectors = Boolean(
auth && ((activeTenant && showTenantControl) || ViewSelector || ActingContextSelector) auth && ((activeTenant && showTenantControl) || ActingContextSelector)
); );
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications")); const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationSummary = useSharedNotificationSummary(settings, { const notificationSummary = useSharedNotificationSummary(settings, {
@@ -152,34 +151,27 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
} }
return ( return (
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}${titlebarState ? ` is-${titlebarState}` : ""}`}> <header className="titlebar">
{titlebarState && {titlebarState === "offline" &&
<div className="titlebar-status-pattern" aria-hidden="true"> <div
{Array.from({ length: 10 }, (_, index) => <span key={index}>{titlebarStateLabel}</span>)} className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
title={titlebarStateLabel}>
{titlebarStateLabel}
</div> </div>
} }
{titlebarState === "maintenance" &&
<button
type="button"
className="maintenance-topbar-link"
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}>
{titlebarStateLabel}
</button>
}
<div className="titlebar-leading"> <div className="titlebar-leading">
{titlebarState === "offline" &&
<div
className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
aria-label={titlebarStateLabel}
title={titlebarStateLabel}>
<WifiOff size={18} aria-hidden="true" />
</div>
}
{titlebarState === "maintenance" &&
<button
type="button"
className="maintenance-topbar-link"
aria-label={translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}>
<TriangleAlert size={18} aria-hidden="true" />
</button>
}
{auth && showContextSelectors && {auth && showContextSelectors &&
<div className="titlebar-context-selectors"> <div className="titlebar-context-selectors">
{activeTenant && showTenantControl && {activeTenant && showTenantControl &&
@@ -187,7 +179,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span> <span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
{canSwitchTenant ? {canSwitchTenant ?
<> <>
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}> <button className="tenant-name-button" data-help-context-id="tenancy.selector" data-help-module-id="tenancy" onClick={() => setTenantOpen(!tenantOpen)}>
<strong>{activeTenant.name}</strong> <strong>{activeTenant.name}</strong>
<span className="tenant-caret"></span> <span className="tenant-caret"></span>
</button> </button>
@@ -216,9 +208,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
} }
</div> </div>
} }
{ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
{ActingContextSelector && {ActingContextSelector &&
<ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} /> <ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} />
} }
@@ -226,18 +215,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
} }
</div> </div>
{auth && GlobalSearch && canUseGlobalSearch &&
<div className="titlebar-global-search">
<GlobalSearch settings={settings} auth={auth} />
</div>
}
<div className="titlebar-actions"> <div className="titlebar-actions">
{auth && GlobalSearch && canUseGlobalSearch &&
<GlobalSearch settings={settings} auth={auth} />
}
<LanguageMenu /> <LanguageMenu />
{auth && ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
{auth && <TemporalDataMenu />}
<HelpMenu auth={auth} /> <HelpMenu auth={auth} />
{auth && notificationsAvailable && {auth && notificationsAvailable &&
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}> <button className="titlebar-icon-link titlebar-notification-button" data-help-context-id="notifications.route.notifications" data-help-module-id="notifications" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
<Bell size={18} /> <Bell size={18} />
{unreadNotificationCount > 0 && {unreadNotificationCount > 0 &&
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}> <span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
@@ -250,6 +240,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="context-menu-wrap" ref={accountRef}> <div className="context-menu-wrap" ref={accountRef}>
<button <button
className="account-pill" className="account-pill"
data-help-context-id="access.account-menu"
data-help-module-id="access"
aria-label={displayUserName} aria-label={displayUserName}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={accountOpen} aria-expanded={accountOpen}
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import {
DEFAULT_TEMPORAL_DATA_SELECTION,
dispatchTemporalContextChanged,
normalizeTemporalDataSelection,
setActiveTemporalDataSelection,
temporalDataSelectionIsDefault,
temporalDataSelectionKey,
type TemporalDataSelection
} from "./temporal";
const TEMPORAL_STORAGE_PREFIX = "govoplan.temporal-data";
type TemporalContextValue = {
selection: TemporalDataSelection;
applySelection: (selection: TemporalDataSelection) => void;
isDefault: boolean;
selectionKey: string;
};
const TemporalContext = createContext<TemporalContextValue>({
selection: DEFAULT_TEMPORAL_DATA_SELECTION,
applySelection: () => undefined,
isDefault: true,
selectionKey: temporalDataSelectionKey(DEFAULT_TEMPORAL_DATA_SELECTION)
});
export function PlatformTemporalProvider({
scopeKey,
children
}: {
scopeKey: string;
children: ReactNode;
}) {
const [selection, setSelection] = useState<TemporalDataSelection>(() =>
loadSelection(scopeKey)
);
useEffect(() => {
const stored = loadSelection(scopeKey);
setActiveTemporalDataSelection(stored);
setSelection(stored);
dispatchTemporalContextChanged(stored, scopeKey);
return () => {
setActiveTemporalDataSelection(DEFAULT_TEMPORAL_DATA_SELECTION);
};
}, [scopeKey]);
const applySelection = useCallback((next: TemporalDataSelection) => {
const normalized = normalizeTemporalDataSelection(next);
setActiveTemporalDataSelection(normalized);
storeSelection(scopeKey, normalized);
setSelection(normalized);
dispatchTemporalContextChanged(normalized, scopeKey);
}, [scopeKey]);
const value = useMemo<TemporalContextValue>(() => ({
selection,
applySelection,
isDefault: temporalDataSelectionIsDefault(selection),
selectionKey: temporalDataSelectionKey(selection)
}), [applySelection, selection]);
return (
<TemporalContext.Provider value={value}>
{children}
</TemporalContext.Provider>
);
}
export function useTemporalDataContext(): TemporalContextValue {
return useContext(TemporalContext);
}
function storageKey(scopeKey: string): string {
return `${TEMPORAL_STORAGE_PREFIX}.${scopeKey}`;
}
function loadSelection(scopeKey: string): TemporalDataSelection {
if (typeof sessionStorage === "undefined") {
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
const stored = sessionStorage.getItem(storageKey(scopeKey));
if (!stored) return DEFAULT_TEMPORAL_DATA_SELECTION;
try {
return normalizeTemporalDataSelection(JSON.parse(stored));
} catch {
sessionStorage.removeItem(storageKey(scopeKey));
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
}
function storeSelection(
scopeKey: string,
selection: TemporalDataSelection
): void {
if (typeof sessionStorage === "undefined") return;
if (temporalDataSelectionIsDefault(selection)) {
sessionStorage.removeItem(storageKey(scopeKey));
return;
}
sessionStorage.setItem(storageKey(scopeKey), JSON.stringify(selection));
}
+2
View File
@@ -194,6 +194,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
routes: routesWithServerMetadata(module, info), routes: routesWithServerMetadata(module, info),
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes), publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
viewSurfaces: mergeViewSurfaces(module, info), viewSurfaces: mergeViewSurfaces(module, info),
helpContexts: info.help_contexts ?? module.helpContexts,
uiCapabilities: { uiCapabilities: {
...(module.uiCapabilities ?? {}), ...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info) ...runtimeUiCapabilitiesForModule(module, info)
@@ -218,6 +219,7 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
dependencies: [], dependencies: [],
optional_dependencies: [], optional_dependencies: [],
enabled: true, enabled: true,
help_contexts: info.help_contexts,
runtime_ui_capabilities: [], runtime_ui_capabilities: [],
nav: [], nav: [],
frontend: { frontend: {
+98
View File
@@ -0,0 +1,98 @@
export type TemporalValidityMode = "current" | "at" | "all";
export type TemporalDataSelection = {
validityMode: TemporalValidityMode;
validAt: string | null;
recordedAt: string | null;
};
export type TemporalContextChangedEventDetail = {
selection: TemporalDataSelection;
scopeKey: string;
};
export const PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT =
"govoplan:temporal-context-changed";
export const DEFAULT_TEMPORAL_DATA_SELECTION: TemporalDataSelection = {
validityMode: "current",
validAt: null,
recordedAt: null
};
let activeSelection = DEFAULT_TEMPORAL_DATA_SELECTION;
export function normalizeTemporalDataSelection(
value: Partial<TemporalDataSelection> | null | undefined
): TemporalDataSelection {
const validityMode = ["current", "at", "all"].includes(
String(value?.validityMode)
)
? value?.validityMode as TemporalValidityMode
: "current";
const validAt = validityMode === "at"
? normalizeTimestamp(value?.validAt)
: null;
if (validityMode === "at" && !validAt) {
throw new Error("i18n:govoplan-core.select_valid_date_time");
}
return {
validityMode,
validAt,
recordedAt: normalizeTimestamp(value?.recordedAt)
};
}
export function temporalDataSelectionIsDefault(
value: TemporalDataSelection
): boolean {
return value.validityMode === "current" && !value.recordedAt;
}
export function temporalDataSelectionKey(value: TemporalDataSelection): string {
return [value.validityMode, value.validAt ?? "", value.recordedAt ?? ""].join(":");
}
export function setActiveTemporalDataSelection(
value: TemporalDataSelection
): void {
activeSelection = normalizeTemporalDataSelection(value);
}
export function activeTemporalDataSelection(): TemporalDataSelection {
return activeSelection;
}
export function temporalRequestHeaders(): Record<string, string> {
const selection = activeTemporalDataSelection();
if (temporalDataSelectionIsDefault(selection)) return {};
const headers: Record<string, string> = {
"X-Govoplan-Validity-Mode": selection.validityMode
};
if (selection.validAt) headers["X-Govoplan-Valid-At"] = selection.validAt;
if (selection.recordedAt) {
headers["X-Govoplan-Recorded-At"] = selection.recordedAt;
}
return headers;
}
export function dispatchTemporalContextChanged(
selection: TemporalDataSelection,
scopeKey: string
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<TemporalContextChangedEventDetail>(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
{ detail: { selection, scopeKey } }
)
);
}
function normalizeTimestamp(value: string | null | undefined): string | null {
const clean = String(value || "").trim();
if (!clean) return null;
const instant = new Date(clean);
if (!Number.isFinite(instant.getTime())) return null;
return instant.toISOString();
}
+1 -1
View File
@@ -124,7 +124,7 @@
.titlebar-link, .titlebar-link,
.account-pill { .account-pill {
padding: 8px 10px; padding: 8px 10px;
margin: -8px -10px; margin: 0;
border-radius: 7px; border-radius: 7px;
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease; transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
cursor: pointer; cursor: pointer;
+17 -27
View File
@@ -28,17 +28,8 @@
.icon-rail.compact { width: 58px; } .icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); } .titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
.titlebar.is-maintenance { background: var(--warning-bg); border-bottom-color: var(--warning-border-soft); }
.titlebar.is-offline { background: var(--danger-bg); border-bottom-color: var(--danger-border-deep); }
.titlebar > :not(.titlebar-status-pattern) { position: relative; z-index: 1; }
.titlebar-status-pattern { position: absolute; inset: 0; display: flex; align-items: center; gap: 34px; overflow: hidden; padding: 0 14px; pointer-events: none; white-space: nowrap; }
.titlebar-status-pattern span { flex: 0 0 auto; color: var(--warning-text); font-size: 11px; font-weight: 800; opacity: .16; }
.titlebar.is-offline .titlebar-status-pattern span { color: var(--danger-text); opacity: .18; }
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); }
.titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; } .titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; }
.titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; }
.titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; } .titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; }
.titlebar.has-global-search .titlebar-actions { grid-column: 3; }
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; } .titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
.acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); } .acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); }
.acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; } .acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; }
@@ -53,20 +44,31 @@
.titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; } .titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; } .titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
.titlebar-icon-link:hover, .titlebar-link:hover { background: var(--titlebar-hover-bg); color: var(--text-strong); } .titlebar-icon-link:hover, .titlebar-link:hover { background: var(--titlebar-hover-bg); color: var(--text-strong); }
.titlebar-icon-link.is-context-active { background: var(--accent-hover-bg); color: var(--accent); }
.titlebar-icon-link.is-context-active:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
.titlebar-notification-button { position: relative; } .titlebar-notification-button { position: relative; }
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); } .titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
.account-pill { color: var(--text); } .account-pill { color: var(--text); }
.maintenance-topbar-link, .maintenance-topbar-link,
.backend-offline-topbar-alert { width: 34px; height: 34px; flex: 0 0 auto; box-sizing: border-box; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; padding: 0; font: inherit; box-shadow: 0 1px 2px var(--hover-tint); } .backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); min-height: 32px; max-width: min(560px, calc(100vw - 24px)); box-sizing: border-box; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; padding: 0 14px; overflow: hidden; font: inherit; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--surface); color: var(--warning-text); cursor: pointer; } .maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); } .maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--surface); color: var(--danger-text); } .backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; } .language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } .language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
.language-menu { min-width: 210px; } .language-menu { min-width: 210px; }
.language-menu .dropdown-item { justify-content: flex-start; } .language-menu .dropdown-item { justify-content: flex-start; }
.language-menu .dropdown-item svg { margin-left: auto; } .language-menu .dropdown-item svg { margin-left: auto; }
.language-option-code { width: 34px; color: var(--muted); } .language-option-code { width: 34px; color: var(--muted); }
.temporal-data-menu { width: min(360px, calc(100vw - 20px)); box-sizing: border-box; display: grid; gap: 12px; padding: 14px; }
.temporal-data-menu-heading { display: flex; align-items: center; min-height: 24px; }
.temporal-validity-control .segmented-control-option { padding-inline: 10px; }
.temporal-recorded-section { display: grid; gap: 10px; padding-top: 12px; border-top: var(--border-line); }
.temporal-data-menu .form-field { gap: 5px; }
.temporal-data-menu input,
.temporal-data-menu select { width: 100%; min-width: 0; box-sizing: border-box; }
.temporal-data-explanation { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
.temporal-data-menu-actions { display: flex; justify-content: flex-end; }
.api-mini { display: flex; gap: 6px; } .api-mini { display: flex; gap: 6px; }
.api-mini input { width: 155px; height: 30px; border: var(--border-line); border-radius: var(--radius-sm); padding: 0 8px; } .api-mini input { width: 155px; height: 30px; border: var(--border-line); border-radius: var(--radius-sm); padding: 0 8px; }
.breadcrumb-bar { background: var(--bar); border-bottom: var(--border-line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: var(--shadow-chrome); z-index: 90; } .breadcrumb-bar { background: var(--bar); border-bottom: var(--border-line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: var(--shadow-chrome); z-index: 90; }
@@ -219,7 +221,6 @@
.code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; } .code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; }
@media (max-width: 900px) { @media (max-width: 900px) {
.api-mini { display: none; } .api-mini { display: none; }
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) 34px auto; }
.workspace { grid-template-columns: 1fr; } .workspace { grid-template-columns: 1fr; }
.section-sidebar { display: none; } .section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; } .wizard-card { grid-template-columns: 1fr; }
@@ -344,9 +345,8 @@
grid-template-rows: 104px 51px minmax(0, 1fr); grid-template-rows: 104px 51px minmax(0, 1fr);
} }
.titlebar, .titlebar {
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr);
grid-template-columns: 34px minmax(0, 1fr);
grid-template-rows: 42px 42px; grid-template-rows: 42px 42px;
column-gap: 6px; column-gap: 6px;
row-gap: 4px; row-gap: 4px;
@@ -367,22 +367,12 @@
scrollbar-width: thin; scrollbar-width: thin;
} }
.titlebar-global-search { .titlebar-actions {
grid-column: 1; grid-column: 1;
grid-row: 2; grid-row: 2;
}
.titlebar-actions,
.titlebar.has-global-search .titlebar-actions {
grid-column: 2;
grid-row: 2;
gap: 2px; gap: 2px;
} }
.titlebar:not(.has-global-search) .titlebar-actions {
grid-column: 1 / -1;
}
.language-menu-button { .language-menu-button {
min-width: 40px; min-width: 40px;
padding-inline: 4px; padding-inline: 4px;
+20
View File
@@ -337,6 +337,8 @@ export type AdminSectionContribution = {
anyOf?: string[]; anyOf?: string[];
allOf?: string[]; allOf?: string[];
surfaceId?: string; surfaceId?: string;
helpContextId?: string;
helpTopicId?: string;
render: (context: AdminSectionRenderContext) => ReactNode; render: (context: AdminSectionRenderContext) => ReactNode;
}; };
@@ -361,6 +363,8 @@ export type SettingsSectionContribution = {
anyOf?: string[]; anyOf?: string[];
allOf?: string[]; allOf?: string[];
surfaceId?: string; surfaceId?: string;
helpContextId?: string;
helpTopicId?: string;
render: (context: SettingsSectionRenderContext) => ReactNode; render: (context: SettingsSectionRenderContext) => ReactNode;
}; };
@@ -374,12 +378,16 @@ export type PlatformRouteContribution = {
allOf?: string[]; allOf?: string[];
order?: number; order?: number;
surfaceId?: string; surfaceId?: string;
helpContextId?: string;
helpTopicId?: string;
render: (context: PlatformRouteContext) => ReactNode; render: (context: PlatformRouteContext) => ReactNode;
}; };
export type PlatformPublicRouteContribution = { export type PlatformPublicRouteContribution = {
path: string; path: string;
order?: number; order?: number;
helpContextId?: string;
helpTopicId?: string;
render: (context: PlatformPublicRouteContext) => ReactNode; render: (context: PlatformPublicRouteContext) => ReactNode;
}; };
@@ -388,6 +396,8 @@ export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformInterfaceIdentityProps = { export type PlatformInterfaceIdentityProps = {
/** Stable control-plane identity; use a module-namespaced value. */ /** Stable control-plane identity; use a module-namespaced value. */
interfaceId?: string; interfaceId?: string;
/** Stable contextual-help identifier associated with the control. */
helpContextId?: string;
/** Optional stable documentation/help topic associated with the control. */ /** Optional stable documentation/help topic associated with the control. */
helpTopicId?: string; helpTopicId?: string;
}; };
@@ -414,6 +424,7 @@ export type PlatformWebModule = {
uiCapabilities?: PlatformUiCapabilities; uiCapabilities?: PlatformUiCapabilities;
runtimeUiCapabilities?: PlatformUiCapabilities; runtimeUiCapabilities?: PlatformUiCapabilities;
viewSurfaces?: PlatformViewSurface[]; viewSurfaces?: PlatformViewSurface[];
helpContexts?: PlatformDocumentationHelpContext[];
}; };
export type EffectiveViewOption = { export type EffectiveViewOption = {
@@ -1040,10 +1051,18 @@ export type PlatformFrontendModuleInfo = {
}>; }>;
}; };
export type PlatformDocumentationHelpContext = {
id: string;
topic_id: string;
title: string;
documentation_types: Array<"user" | "admin">;
};
export type PlatformPublicModuleInfo = { export type PlatformPublicModuleInfo = {
id: string; id: string;
name: string; name: string;
version: string; version: string;
help_contexts?: PlatformDocumentationHelpContext[];
frontend: Pick< frontend: Pick<
PlatformFrontendModuleInfo, PlatformFrontendModuleInfo,
| "module_id" | "module_id"
@@ -1090,6 +1109,7 @@ export type PlatformModuleInfo = {
dependencies: string[]; dependencies: string[];
optional_dependencies: string[]; optional_dependencies: string[];
enabled: boolean; enabled: boolean;
help_contexts?: PlatformDocumentationHelpContext[];
architecture?: { architecture?: {
contract_version: string; contract_version: string;
layer: string; layer: string;
+385 -45
View File
@@ -1,74 +1,414 @@
import type { PlatformWebModule } from "../types";
export type HelpContextKind = "page" | "field" | "action" | "dialog" | "interface";
export type HelpContext = { export type HelpContext = {
id: string; id: string;
title: string; title: string;
route: string; route: string;
kind: HelpContextKind;
moduleId?: string;
parentId?: string;
parentTitle?: string;
documentationTopicId?: string;
documentationType?: "user" | "admin";
}; };
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = { type ContextDefinition = Omit<HelpContext, "route" | "kind">;
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" }, const campaignSectionContexts: Record<string, ContextDefinition> = {
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80" }, data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06" }, campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" }, fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80", moduleId: "campaigns" },
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" }, template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06", moduleId: "campaigns" },
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24" }, files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10" }, attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" }, recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24", moduleId: "campaigns" },
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" }, "recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10", moduleId: "campaigns" },
mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" }, "mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
"global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" }, "server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" }, mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" }, "global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849", moduleId: "campaigns" },
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" }, settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849", moduleId: "campaigns" },
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" }, review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" }, send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c" }, report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303", moduleId: "campaigns" },
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76" } reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303", moduleId: "campaigns" },
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c", moduleId: "campaigns" },
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76", moduleId: "campaigns" }
}; };
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = { const topLevelContexts: Record<string, ContextDefinition> = {
dashboard: { id: "app.dashboard", title: "i18n:govoplan-core.dashboard.d87f47b4" }, dashboard: { id: "dashboard.page", title: "i18n:govoplan-core.dashboard.d87f47b4", moduleId: "dashboard" },
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" }, campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28", moduleId: "campaigns" },
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" }, templates: { id: "templates.page", title: "i18n:govoplan-core.templates.f25b700e", moduleId: "templates" },
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" }, files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512", moduleId: "files" },
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" }, mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb", moduleId: "mail" },
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" }, "address-book": { id: "addresses.page", title: "i18n:govoplan-core.address_book.f6327f59", moduleId: "addresses" },
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" }, settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5", moduleId: "core", documentationType: "user" },
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" }, admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc", moduleId: "admin", documentationType: "admin" }
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
}; };
export function helpContextForPathname(pathname: string, search = ""): HelpContext { export function helpContextForPathname(
pathname: string,
search = "",
modules: readonly PlatformWebModule[] = []
): HelpContext {
const route = pathname || "/"; const route = pathname || "/";
const routeWithSearch = `${route}${search}`;
const segments = route.split("/").filter(Boolean); const segments = route.split("/").filter(Boolean);
if (segments[0] === "settings") { if (segments[0] === "settings" || segments[0] === "admin") {
const section = new URLSearchParams(search).get("section") || ""; const area = segments[0];
if (section === "mail-profiles") { const requestedSection = new URLSearchParams(search).get("section");
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` }; const section = requestedSection || (area === "admin" ? "overview" : "interface");
if (area === "settings" && section === "mail-profiles") {
return pageContext(
{ id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", moduleId: "mail" },
routeWithSearch,
modules
);
} }
const surfaceContext = sectionContext(modules, section, area);
if (surfaceContext) return pageContext(surfaceContext, routeWithSearch, modules);
const fallback = topLevelContexts[area];
return pageContext({
...fallback,
id: `${fallback.id}.${stableSlug(section)}`,
title: humanize(section),
documentationType: area === "admin" ? "admin" : "user"
}, routeWithSearch, modules);
} }
if (segments[0] === "campaigns" && segments[1]) { if (segments[0] === "campaigns" && segments[1]) {
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route }; if (segments[1] === "queue") {
return pageContext({ id: "campaign.operator-queue", title: "Operator queue", moduleId: "campaigns" }, routeWithSearch, modules);
}
if (segments[1] === "reports") {
return pageContext({ id: "campaign.report", title: "i18n:govoplan-core.reports.88bc3fe3", moduleId: "campaigns" }, routeWithSearch, modules);
}
if (!segments[2]) {
return pageContext({ id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", moduleId: "campaigns" }, routeWithSearch, modules);
}
if (segments[2] === "wizard") { if (segments[2] === "wizard") {
const step = segments[3] || "create"; const step = segments[3] || "create";
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route }; return pageContext({ id: `campaign.wizard.${stableSlug(step)}`, title: `${humanize(step)} wizard`, moduleId: "campaigns" }, routeWithSearch, modules);
} }
const context = campaignSectionContexts[segments[2]]; const context = campaignSectionContexts[segments[2]];
if (context) return { ...context, route }; if (context) return pageContext(context, routeWithSearch, modules);
return { id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", route }; return pageContext({ id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", moduleId: "campaigns" }, routeWithSearch, modules);
} }
const context = topLevelContexts[segments[0] || "campaigns"]; if (segments.length <= 1) {
if (context) return { ...context, route }; const context = topLevelContexts[segments[0] || "campaigns"];
return { id: "app.general", title: "i18n:govoplan-core.application.b291beb8", route }; if (context) return pageContext(context, routeWithSearch, modules);
}
const matchedRoute = moduleRouteContext(route, modules);
if (matchedRoute) return pageContext(matchedRoute, routeWithSearch, modules);
const root = stableSlug(segments[0] || "application");
return pageContext({
id: root === "application" ? "app.general" : `${root}.page`,
title: root === "application" ? "i18n:govoplan-core.application.b291beb8" : humanize(segments[segments.length - 1] || root),
moduleId: root === "application" ? "core" : root
}, routeWithSearch, modules);
}
export function helpContextForTarget(
base: HelpContext,
target: EventTarget | null,
modules: readonly PlatformWebModule[] = []
): HelpContext {
if (typeof Element === "undefined" || !(target instanceof Element)) return base;
const explicit = target.closest<HTMLElement>(
"[data-help-context-id], [data-help-context], [data-help-topic-id], [data-interface-id]"
);
if (explicit) {
const contextId = explicit.dataset.helpContextId || explicit.dataset.helpContext;
const topicId = explicit.dataset.helpTopicId;
const interfaceId = explicit.dataset.interfaceId;
const title = helpLabel(explicit) || base.title;
const kind = helpKind(explicit, target);
if (contextId || topicId || interfaceId) {
return withDeclaredDocumentation(childContext(base, {
id: contextId || interfaceId || topicId || base.id,
title,
kind,
moduleId: explicit.dataset.helpModuleId || base.moduleId,
documentationTopicId: topicId || undefined,
documentationType: documentationType(explicit) ?? base.documentationType
}), modules);
}
}
const field = target.closest<HTMLElement>(
".form-field, .toggle-switch-row, .searchable-select, .email-address-input, .date-field, .time-field, .date-time-field"
);
if (field) {
const label = helpLabel(field) || helpLabel(target) || "Field";
return withDeclaredDocumentation(derivedChildContext(base, field, label, "field"), modules);
}
const control = target.closest<HTMLElement>(
"button, a, input, select, textarea, [role='button'], [role='checkbox'], [role='combobox'], [role='menuitem'], [role='option'], [role='radio'], [role='switch'], [role='tab']"
);
const dialog = target.closest<HTMLElement>("[role='dialog'], [role='alertdialog'], [data-help-scope='dialog']");
if (control) {
const isField = control.matches("input, select, textarea, [role='checkbox'], [role='combobox'], [role='radio'], [role='switch']");
const label = helpLabel(control) || (isField ? "Field" : "Action");
const slug = stableHelpKey(control, label);
if (dialog && ["close", "cancel"].includes(slug)) {
const dialogLabel = helpLabel(dialog) || "Dialog";
return withDeclaredDocumentation(derivedChildContext(base, dialog, dialogLabel, "dialog"), modules);
}
return withDeclaredDocumentation(derivedChildContext(base, control, label, isField ? "field" : "action"), modules);
}
if (dialog) {
return withDeclaredDocumentation(derivedChildContext(base, dialog, helpLabel(dialog) || "Dialog", "dialog"), modules);
}
const contextualRegion = target.closest<HTMLElement>("[data-help-scope], .card, .admin-section-page");
if (contextualRegion) {
return withDeclaredDocumentation(derivedChildContext(base, contextualRegion, helpLabel(contextualRegion) || base.title, "interface"), modules);
}
return base;
} }
export function helpQueryForContext(context: HelpContext): string { export function helpQueryForContext(context: HelpContext): string {
return `context=${encodeURIComponent(context.id)}`; const params = new URLSearchParams();
if (context.documentationTopicId) params.set("topic", context.documentationTopicId);
else params.set("context", context.id);
if (context.parentId && context.parentId !== context.id) params.set("fallback_context", context.parentId);
if (context.moduleId) params.set("module", context.moduleId);
return params.toString();
} }
function capitalize(value: string): string { function pageContext(
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); definition: ContextDefinition,
route: string,
modules: readonly PlatformWebModule[]
): HelpContext {
return withDeclaredDocumentation({ ...definition, route, kind: "page" }, modules);
}
function childContext(
base: HelpContext,
child: Pick<HelpContext, "id" | "title" | "kind"> & Partial<HelpContext>
): HelpContext {
return {
...child,
route: base.route,
moduleId: child.moduleId ?? base.moduleId,
parentId: child.parentId ?? base.id,
parentTitle: child.parentTitle ?? base.title,
documentationType: child.documentationType ?? base.documentationType
};
}
function withDeclaredDocumentation(
context: HelpContext,
modules: readonly PlatformWebModule[]
): HelpContext {
if (context.documentationTopicId) return context;
const candidates = context.moduleId
? modules.filter((module) => module.id === context.moduleId)
: modules;
const aliases = [
context.id,
context.id.replace(".section.", "."),
context.id.replace(".route.", ".")
];
for (const module of candidates) {
const match = module.helpContexts?.find((item) => aliases.includes(item.id));
if (!match) continue;
const documentationType = context.documentationType
?? (match.documentation_types.includes("user") ? "user" : match.documentation_types[0]);
return {
...context,
id: match.id,
title: context.title || match.title,
moduleId: module.id,
documentationTopicId: match.topic_id,
documentationType
};
}
return context;
}
function derivedChildContext(base: HelpContext, element: HTMLElement, label: string, kind: HelpContextKind): HelpContext {
const prefix = contextPrefix(base);
const key = stableHelpKey(element, label);
return childContext(base, {
id: `${prefix}.${kind === "field" ? "field" : kind === "action" ? "action" : kind}.${key}`,
title: label,
kind,
documentationType: documentationType(element) ?? base.documentationType
});
}
function contextPrefix(context: HelpContext): string {
const idPrefix = context.id.split(".", 1)[0];
if (idPrefix && !["app", "admin"].includes(idPrefix)) return idPrefix;
return stableSlug(context.moduleId || idPrefix || "app");
}
function sectionContext(
modules: readonly PlatformWebModule[],
section: string,
area: "settings" | "admin"
): ContextDefinition | null {
const normalizedSection = stableSlug(section);
let best: { score: number; definition: ContextDefinition } | null = null;
for (const module of modules) {
for (const surface of module.viewSurfaces ?? []) {
if (surface.kind !== "section") continue;
const normalizedId = surface.id.replace(/_/g, "-").toLowerCase();
const normalizedLabel = stableSlug(surface.label);
let score = 0;
if (normalizedId.includes(`.${area}.${normalizedSection}`)) score = 120;
else if (normalizedId.includes(`.section.${normalizedSection}`)) score = 110;
else if (normalizedId.endsWith(`.${normalizedSection}`)) score = 100;
else if (normalizedLabel === normalizedSection) score = 80;
if (!score || (best && best.score >= score)) continue;
best = {
score,
definition: {
id: surface.id,
title: surface.label,
moduleId: surface.moduleId || module.id,
documentationType: area === "admin" ? "admin" : "user"
}
};
}
}
return best?.definition ?? null;
}
function moduleRouteContext(pathname: string, modules: readonly PlatformWebModule[]): ContextDefinition | null {
let best: { score: number; definition: ContextDefinition } | null = null;
for (const module of modules) {
for (const route of [...(module.routes ?? []), ...(module.publicRoutes ?? [])]) {
const score = routeMatchScore(route.path, pathname);
if (score < 0 || (best && best.score >= score)) continue;
const surfaceId = "surfaceId" in route ? route.surfaceId : undefined;
const surface = surfaceId ? module.viewSurfaces?.find((item) => item.id === surfaceId) : undefined;
const navItem = module.navItems?.find((item) => item.to === route.path || item.to === pathname);
best = {
score,
definition: {
id: route.helpContextId || surfaceId || `${module.id}.page.${stableSlug(route.path)}`,
title: surface?.label || navItem?.label || module.label || humanize(pathname),
moduleId: module.id,
documentationTopicId: route.helpTopicId
}
};
}
}
return best?.definition ?? null;
}
function routeMatchScore(pattern: string, pathname: string): number {
const patternSegments = pattern.split("/").filter(Boolean);
const pathSegments = pathname.split("/").filter(Boolean);
let score = 0;
let wildcard = false;
for (let index = 0; index < patternSegments.length; index += 1) {
const expected = patternSegments[index];
if (expected === "*") {
wildcard = true;
score += 1;
break;
}
const actual = pathSegments[index];
if (actual === undefined) return -1;
if (expected.startsWith(":")) score += 10;
else if (expected === actual) score += 100;
else return -1;
}
if (!wildcard && patternSegments.length !== pathSegments.length) return -1;
return score + patternSegments.length;
}
function helpKind(element: HTMLElement, target: Element): HelpContextKind {
const explicit = element.dataset.helpScope as HelpContextKind | undefined;
if (explicit && ["page", "field", "action", "dialog", "interface"].includes(explicit)) return explicit;
if (element.matches(".form-field, .toggle-switch-row, .searchable-select, .email-address-input, .date-field, .time-field, .date-time-field")) return "field";
if (target.closest("[role='dialog'], [role='alertdialog']")) return "dialog";
return "interface";
}
function helpLabel(element: Element): string {
const own = element as HTMLElement;
const direct = own.dataset?.helpLabel || own.getAttribute("aria-label") || own.getAttribute("title");
if (direct?.trim()) return direct.trim();
const labelledBy = own.getAttribute("aria-labelledby");
if (labelledBy && typeof document !== "undefined") {
const text = labelledBy
.split(/\s+/)
.map((id) => document.getElementById(id)?.textContent?.trim() || "")
.filter(Boolean)
.join(" ");
if (text) return text;
}
const fieldLabel = own.querySelector<HTMLElement>(".form-label, .toggle-switch-label");
if (fieldLabel?.textContent?.trim()) return fieldLabel.textContent.trim();
const dialogTitle = own.querySelector<HTMLElement>(".dialog-title");
if (dialogTitle?.textContent?.trim()) return dialogTitle.textContent.trim();
const heading = own.querySelector<HTMLElement>("h1, h2, h3");
if (heading?.textContent?.trim()) return heading.textContent.trim();
const associatedLabel = own.id && typeof document !== "undefined"
? document.querySelector<HTMLLabelElement>(`label[for="${cssEscape(own.id)}"]`)
: null;
if (associatedLabel?.textContent?.trim()) return associatedLabel.textContent.trim();
return own.textContent?.replace(/\s+/g, " ").trim().slice(0, 120) || "";
}
function stableHelpKey(element: HTMLElement, label: string): string {
const key = element.dataset.helpKey
|| element.getAttribute("name")
|| stableElementId(element.id)
|| element.getAttribute("aria-label")
|| element.getAttribute("title")
|| label;
return stableSlug(key || "item");
}
function stableElementId(value: string): string {
if (!value || /(?:^|[-_])(?:r\d+|\d{3,}|[0-9a-f]{8,})(?:$|[-_])/i.test(value)) return "";
return value;
}
function documentationType(element: HTMLElement): "user" | "admin" | undefined {
return element.dataset.helpDocumentationType === "admin" ? "admin"
: element.dataset.helpDocumentationType === "user" ? "user"
: undefined;
}
function stableSlug(value: string): string {
const i18nMatch = value.match(/^i18n:[^.]+\.([^.]+)(?:\.[0-9a-f]{8})?$/i);
const source = i18nMatch?.[1] || value;
return source
.replace(/^\/+|\/+$/g, "")
.replace(/:[^/]+/g, "item")
.replace(/\*/g, "all")
.replace(/[_\s/]+/g, "-")
.replace(/[^a-zA-Z0-9.-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "item";
}
function humanize(value: string): string {
return value
.replace(/^\/+|\/+$/g, "")
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase()) || "Application";
}
function cssEscape(value: string): string {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
return value.replace(/["\\]/g, "\\$&");
} }
@@ -17,6 +17,11 @@ assert(
"/docs?type=admin&context=campaign.review-send", "/docs?type=admin&context=campaign.review-send",
"context references retain the requested audience projection" "context references retain the requested audience projection"
); );
assert(
documentationHelpHref({ contextId: "calendar.field.title", fallbackContextId: "calendar.page", moduleId: "calendar" }) ===
"/docs?type=user&context=calendar.field.title&fallback_context=calendar.page&module=calendar",
"focused controls retain their page and module documentation fallback"
);
assert( assert(
documentationHelpHref({ topicId: "topic", anchorId: "details" }) === documentationHelpHref({ topicId: "topic", anchorId: "details" }) ===
"/docs?type=user&topic=topic#details", "/docs?type=user&topic=topic#details",
+64 -1
View File
@@ -1,4 +1,5 @@
import { helpContextForPathname } from "../src/utils/helpContext"; import { helpContextForPathname, helpQueryForContext } from "../src/utils/helpContext";
import type { PlatformWebModule } from "../src/types";
function assertEqual(actual: string, expected: string, message: string): void { function assertEqual(actual: string, expected: string, message: string): void {
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`); if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
@@ -16,3 +17,65 @@ assertEqual(
"campaign.attachments", "campaign.attachments",
"Campaign attachment context" "Campaign attachment context"
); );
assertEqual(helpContextForPathname("/address-book").id, "addresses.page", "Address book context");
const modules: PlatformWebModule[] = [
{
id: "calendar",
label: "Calendar",
version: "1",
routes: [{ path: "/calendar", surfaceId: "calendar.page", render: () => null }],
viewSurfaces: [{ id: "calendar.page", moduleId: "calendar", kind: "route", label: "Calendar" }],
helpContexts: [{
id: "calendar.page",
topic_id: "calendar.manage-calendars-and-events",
title: "Use calendars and events",
documentation_types: ["user"]
}]
},
{
id: "cases",
label: "Cases",
version: "1",
routes: [
{ path: "/cases", surfaceId: "cases.list", render: () => null },
{ path: "/cases/:caseId", surfaceId: "cases.detail", render: () => null }
],
viewSurfaces: [
{ id: "cases.list", moduleId: "cases", kind: "route", label: "Cases" },
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "Case details" }
]
},
{
id: "admin",
label: "Administration",
version: "1",
viewSurfaces: [
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "Modules" }
]
}
];
assertEqual(helpContextForPathname("/calendar", "", modules).id, "calendar.page", "Contributed route context");
if (!helpQueryForContext(helpContextForPathname("/calendar", "", modules)).includes("topic=calendar.manage-calendars-and-events")) {
throw new Error("Declared route help context did not resolve its documentation topic");
}
assertEqual(helpContextForPathname("/cases/case-1", "", modules).id, "cases.detail", "Dynamic route context");
assertEqual(
helpContextForPathname("/admin", "?section=system-modules", modules).id,
"admin.section.system-modules",
"Contributed admin section context"
);
assertEqual(helpContextForPathname("/future-module").id, "future-module.page", "Unknown route fallback context");
const focusedContextQuery = helpQueryForContext({
id: "calendar.field.title",
title: "Title",
route: "/calendar",
kind: "field",
moduleId: "calendar",
parentId: "calendar.page"
});
if (!focusedContextQuery.includes("context=calendar.field.title")) throw new Error("Focused context query is missing the control context");
if (!focusedContextQuery.includes("fallback_context=calendar.page")) throw new Error("Focused context query is missing the page fallback");
if (!focusedContextQuery.includes("module=calendar")) throw new Error("Focused context query is missing the owning module");