feat: add temporal context and contextual help
This commit is contained in:
@@ -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.
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -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
|
||||
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
|
||||
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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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-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-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-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-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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
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(
|
||||
|
||||
@@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry
|
||||
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.request_limits import RequestBodyLimitMiddleware
|
||||
from govoplan_core.server.temporal import temporal_data_context_middleware
|
||||
|
||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
@@ -169,6 +170,7 @@ def create_govoplan_app(
|
||||
return response
|
||||
|
||||
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()]
|
||||
if origins:
|
||||
|
||||
@@ -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:
|
||||
frontend = manifest.frontend
|
||||
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()
|
||||
if key != "declarations"
|
||||
},
|
||||
"help_contexts": _documentation_help_contexts(manifest),
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
}
|
||||
@@ -274,6 +298,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"help_contexts": _documentation_help_contexts(manifest),
|
||||
"frontend": _public_frontend_payload(manifest.frontend),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
|
||||
@@ -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"]
|
||||
@@ -48,6 +48,10 @@ class ConditionalRequestTests(unittest.TestCase):
|
||||
self.assertIn("private", first.headers.get("cache-control", ""))
|
||||
self.assertIn("no-cache", first.headers.get("cache-control", ""))
|
||||
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"])
|
||||
|
||||
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("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:
|
||||
with self._client() as client:
|
||||
first = client.get("/json?value=alpha")
|
||||
|
||||
@@ -106,7 +106,7 @@ from govoplan_core.core.module_package_catalog import (
|
||||
sign_module_package_catalog,
|
||||
validate_module_package_catalog,
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, 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.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
@@ -538,6 +538,15 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
name="Example",
|
||||
version="test",
|
||||
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(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
@@ -572,6 +581,15 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
|
||||
public_module = response.json()["modules"][0]
|
||||
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("routes", public_module["frontend"])
|
||||
self.assertEqual(
|
||||
@@ -3591,8 +3609,11 @@ finally:
|
||||
)
|
||||
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||
self.assertEqual("0.1.9", modules["files"]["version"])
|
||||
self.assertIn("@v0.1.9", modules["files"]["python_ref"])
|
||||
files_version = importlib.import_module(
|
||||
"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:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
||||
|
||||
@@ -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()
|
||||
@@ -12,7 +12,11 @@ const credentials = read("src/components/CredentialEnvelopeManager.tsx");
|
||||
const iconRail = read("src/layout/IconRail.tsx");
|
||||
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.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 authGateStyles = read("src/styles/auth-gate.css");
|
||||
|
||||
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");
|
||||
@@ -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-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.match(titlebar, /className="titlebar-global-search"/, "global search retains its dedicated titlebar grid cell");
|
||||
assert.doesNotMatch(titlebar, /titlebar-status-pattern|TriangleAlert|WifiOff/, "shell state uses the readable text warnings rather than icon or background decoration");
|
||||
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.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]*\.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");
|
||||
|
||||
+21
-2
@@ -11,6 +11,8 @@ import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PlatformViewProvider } from "./platform/ViewContext";
|
||||
import { PlatformTemporalProvider } from "./platform/TemporalContext";
|
||||
import { PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT } from "./platform/temporal";
|
||||
import {
|
||||
PLATFORM_VIEW_CHANGED_EVENT,
|
||||
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
|
||||
@@ -53,6 +55,7 @@ export default function App() {
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
const [baseViewProjection, setBaseViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||
const [workflowViewProjection, setWorkflowViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||
const [temporalRevision, setTemporalRevision] = useState(0);
|
||||
const viewProjection = workflowViewProjection ?? baseViewProjection;
|
||||
|
||||
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 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(() => {
|
||||
if (!auth || !viewsRuntime) {
|
||||
setBaseViewProjection(null);
|
||||
@@ -518,12 +535,13 @@ export default function App() {
|
||||
onLanguageChange={persistLanguagePreference}
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformTemporalProvider scopeKey={`${auth.user?.id ?? "account"}:${(auth.active_tenant ?? auth.tenant).id}`}>
|
||||
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
|
||||
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<ModuleLoadBoundary resetKey={location.pathname} loading={webModulesLoading}>
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
|
||||
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||
{publicRoutes.map((route) =>
|
||||
@@ -567,6 +585,7 @@ export default function App() {
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</DocumentationHelpProvider>
|
||||
</PlatformTemporalProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import { temporalRequestHeaders } from "../platform/temporal";
|
||||
|
||||
const STORAGE_KEY = "govoplan.apiSettings";
|
||||
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)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
|
||||
if (!headers.has(key)) headers.set(key, value);
|
||||
}
|
||||
|
||||
const csrf = csrfToken();
|
||||
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> {
|
||||
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) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
|
||||
@@ -7,7 +7,20 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInte
|
||||
disabledReason?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) {
|
||||
const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpContextId, helpTopicId, children, ...props }: ButtonProps) {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
type CardProps = {
|
||||
type CardProps = PlatformInterfaceIdentityProps & {
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
@@ -38,7 +39,7 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
|
||||
// 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 storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
|
||||
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
|
||||
@@ -59,7 +60,14 @@ export default function Card({ title, children, actions, collapsible = false, co
|
||||
}
|
||||
|
||||
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 &&
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
|
||||
@@ -50,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
|
||||
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 [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||
@@ -94,7 +94,15 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
|
||||
}
|
||||
|
||||
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
|
||||
{...props}
|
||||
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);
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -159,7 +167,14 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
|
||||
}, [value, min, max]);
|
||||
|
||||
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
|
||||
{...props}
|
||||
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 minParts = datePartsFromDateTime(min || "");
|
||||
const maxParts = datePartsFromDateTime(max || "");
|
||||
@@ -189,7 +204,14 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
|
||||
}
|
||||
|
||||
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
|
||||
{...props}
|
||||
value={parts.date}
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
registerDialog,
|
||||
type DialogStackId
|
||||
} from "./dialogStack";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
export type DialogProps = {
|
||||
export type DialogProps = PlatformInterfaceIdentityProps & {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -56,7 +57,10 @@ export default function Dialog({
|
||||
footerClassName = "",
|
||||
portal = false,
|
||||
panelStyle,
|
||||
backdropStyle
|
||||
backdropStyle,
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const canClose = Boolean(onClose) && !closeDisabled;
|
||||
@@ -134,6 +138,11 @@ export default function Dialog({
|
||||
role={role}
|
||||
aria-modal="true"
|
||||
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-describedby={ariaDescribedBy}
|
||||
>
|
||||
|
||||
@@ -12,11 +12,19 @@ type FormFieldProps = PlatformInterfaceIdentityProps & {
|
||||
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 renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
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>
|
||||
{children}
|
||||
</label>
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function SearchableSelect({
|
||||
debounceMs = 200,
|
||||
className = "",
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: SearchableSelectProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
@@ -297,8 +298,11 @@ export default function SearchableSelect({
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={rootClassName}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={ariaLabel}
|
||||
onBlur={closeOnFocusLeave}
|
||||
>
|
||||
<div className="searchable-select-control">
|
||||
|
||||
@@ -14,7 +14,7 @@ type ToggleSwitchProps = PlatformInterfaceIdentityProps & {
|
||||
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 hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
|
||||
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 inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
|
||||
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
|
||||
className="toggle-switch-input"
|
||||
type="checkbox"
|
||||
|
||||
@@ -3,8 +3,9 @@ import DismissibleAlert from "../DismissibleAlert";
|
||||
import LoadingFrame from "../LoadingFrame";
|
||||
import PageTitle from "../PageTitle";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../../types";
|
||||
|
||||
type Props = {
|
||||
type Props = PlatformInterfaceIdentityProps & {
|
||||
title: string;
|
||||
description: string;
|
||||
loading?: boolean;
|
||||
@@ -25,11 +26,22 @@ export default function AdminPageLayout({
|
||||
success = "",
|
||||
actions,
|
||||
children,
|
||||
className = ""
|
||||
className = "",
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: Props) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
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>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function EmailAddressInput({
|
||||
compact = false,
|
||||
showAddButton,
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: EmailAddressInputProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
@@ -202,7 +203,14 @@ export default function EmailAddressInput({
|
||||
) : null;
|
||||
|
||||
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-chip-list" aria-live="polite">
|
||||
{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 = {
|
||||
topicId?: string;
|
||||
contextId?: string;
|
||||
fallbackContextId?: string;
|
||||
moduleId?: string;
|
||||
documentationType?: "user" | "admin";
|
||||
anchorId?: string;
|
||||
};
|
||||
@@ -20,6 +22,10 @@ export function documentationHelpHref(
|
||||
});
|
||||
if (topicId) params.set("topic", topicId);
|
||||
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();
|
||||
return `${baseUrl}?${params.toString()}${anchorId ? `#${encodeURIComponent(anchorId)}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -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_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.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": {
|
||||
"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_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.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."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ export * from "./platform/ModuleContext";
|
||||
export * from "./platform/moduleEvents";
|
||||
export * from "./platform/ViewContext";
|
||||
export * from "./platform/views";
|
||||
export * from "./platform/temporal";
|
||||
export * from "./platform/TemporalContext";
|
||||
export * from "./platform/wizards";
|
||||
|
||||
export * from "./utils/permissions";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
|
||||
import packageInfo from "../../package.json";
|
||||
@@ -7,7 +7,7 @@ import Dialog from "../components/Dialog";
|
||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
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 type { AuthInfo } from "../types";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
@@ -21,8 +21,12 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const helpContext = helpContextForPathname(location.pathname, location.search);
|
||||
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 adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
|
||||
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
|
||||
@@ -35,6 +39,7 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
setActiveHelpContext(helpContextForTarget(routeHelpContext, event.target, modules));
|
||||
setOpen(false);
|
||||
setContextOpen(true);
|
||||
}
|
||||
@@ -49,22 +54,28 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
window.removeEventListener("keydown", openContextHelp, true);
|
||||
window.removeEventListener("mousedown", onPointerDown);
|
||||
};
|
||||
}, []);
|
||||
}, [modules, routeHelpContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextOpen) setActiveHelpContext(routeHelpContext);
|
||||
}, [contextOpen, routeHelpContext]);
|
||||
|
||||
function openHelp() {
|
||||
setActiveHelpContext(routeHelpContext);
|
||||
setOpen(false);
|
||||
setContextOpen(true);
|
||||
}
|
||||
|
||||
function openDocs(type: "user" | "admin") {
|
||||
function openDocs(type: "user" | "admin", context: HelpContext = routeHelpContext) {
|
||||
setOpen(false);
|
||||
setContextOpen(false);
|
||||
if (!docsAvailable) {
|
||||
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
||||
window.open(externalDocsUrl(type, context), "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
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()}`);
|
||||
}
|
||||
|
||||
@@ -72,6 +83,9 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
<div className="context-menu-wrap" ref={wrapRef}>
|
||||
<button
|
||||
className="titlebar-link"
|
||||
data-help-context-id="core.contextual-help"
|
||||
data-help-module-id="core"
|
||||
data-help-scope="action"
|
||||
onClick={() => setOpen(!open)}
|
||||
onKeyDown={(event) => {
|
||||
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>
|
||||
</button>
|
||||
<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")}
|
||||
</button>
|
||||
{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")}
|
||||
</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>
|
||||
</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)} />}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
@@ -125,13 +141,12 @@ function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContex
|
||||
|
||||
<div className="help-panel-section" data-help-context={context.id}>
|
||||
<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="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>
|
||||
<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>
|
||||
<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>
|
||||
</Dialog>);
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@ export default function LanguageMenu() {
|
||||
|
||||
return (
|
||||
<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="tenant-caret">▾</span>
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
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 HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import TemporalDataMenu from "./TemporalDataMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
@@ -48,8 +49,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
hasAnyScope(auth, searchRuntime.anyOf)
|
||||
)
|
||||
);
|
||||
const showGlobalSearch = Boolean(auth && GlobalSearch && canUseGlobalSearch);
|
||||
|
||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
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 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 notificationSummary = useSharedNotificationSummary(settings, {
|
||||
@@ -152,34 +151,27 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}${titlebarState ? ` is-${titlebarState}` : ""}`}>
|
||||
{titlebarState &&
|
||||
<div className="titlebar-status-pattern" aria-hidden="true">
|
||||
{Array.from({ length: 10 }, (_, index) => <span key={index}>{titlebarStateLabel}</span>)}
|
||||
<header className="titlebar">
|
||||
{titlebarState === "offline" &&
|
||||
<div
|
||||
className="backend-offline-topbar-alert"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
title={titlebarStateLabel}>
|
||||
{titlebarStateLabel}
|
||||
</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">
|
||||
{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 &&
|
||||
<div className="titlebar-context-selectors">
|
||||
{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>
|
||||
{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>
|
||||
<span className="tenant-caret">▾</span>
|
||||
</button>
|
||||
@@ -216,9 +208,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
{ActingContextSelector &&
|
||||
<ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} />
|
||||
}
|
||||
@@ -226,18 +215,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
}
|
||||
</div>
|
||||
|
||||
{auth && GlobalSearch && canUseGlobalSearch &&
|
||||
<div className="titlebar-global-search">
|
||||
<GlobalSearch settings={settings} auth={auth} />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="titlebar-actions">
|
||||
{auth && GlobalSearch && canUseGlobalSearch &&
|
||||
<GlobalSearch settings={settings} auth={auth} />
|
||||
}
|
||||
<LanguageMenu />
|
||||
{auth && ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
{auth && <TemporalDataMenu />}
|
||||
<HelpMenu auth={auth} />
|
||||
|
||||
{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} />
|
||||
{unreadNotificationCount > 0 &&
|
||||
<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}>
|
||||
<button
|
||||
className="account-pill"
|
||||
data-help-context-id="access.account-menu"
|
||||
data-help-module-id="access"
|
||||
aria-label={displayUserName}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountOpen}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -194,6 +194,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
routes: routesWithServerMetadata(module, info),
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
viewSurfaces: mergeViewSurfaces(module, info),
|
||||
helpContexts: info.help_contexts ?? module.helpContexts,
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -218,6 +219,7 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||
dependencies: [],
|
||||
optional_dependencies: [],
|
||||
enabled: true,
|
||||
help_contexts: info.help_contexts,
|
||||
runtime_ui_capabilities: [],
|
||||
nav: [],
|
||||
frontend: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -124,7 +124,7 @@
|
||||
.titlebar-link,
|
||||
.account-pill {
|
||||
padding: 8px 10px;
|
||||
margin: -8px -10px;
|
||||
margin: 0;
|
||||
border-radius: 7px;
|
||||
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
|
||||
cursor: pointer;
|
||||
|
||||
+17
-27
@@ -28,17 +28,8 @@
|
||||
.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); }
|
||||
.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-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.has-global-search .titlebar-actions { grid-column: 3; }
|
||||
.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 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-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.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-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); }
|
||||
.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); }
|
||||
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--surface); color: var(--warning-text); cursor: pointer; }
|
||||
.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(--warning-bg); color: var(--warning-text); cursor: pointer; }
|
||||
.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-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.language-menu { min-width: 210px; }
|
||||
.language-menu .dropdown-item { justify-content: flex-start; }
|
||||
.language-menu .dropdown-item svg { margin-left: auto; }
|
||||
.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 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; }
|
||||
@@ -219,7 +221,6 @@
|
||||
.code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; }
|
||||
@media (max-width: 900px) {
|
||||
.api-mini { display: none; }
|
||||
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) 34px auto; }
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.section-sidebar { display: none; }
|
||||
.wizard-card { grid-template-columns: 1fr; }
|
||||
@@ -344,9 +345,8 @@
|
||||
grid-template-rows: 104px 51px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.titlebar,
|
||||
.titlebar.has-global-search {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
.titlebar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 42px 42px;
|
||||
column-gap: 6px;
|
||||
row-gap: 4px;
|
||||
@@ -367,22 +367,12 @@
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.titlebar-global-search {
|
||||
.titlebar-actions {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.titlebar-actions,
|
||||
.titlebar.has-global-search .titlebar-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.titlebar:not(.has-global-search) .titlebar-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.language-menu-button {
|
||||
min-width: 40px;
|
||||
padding-inline: 4px;
|
||||
|
||||
@@ -337,6 +337,8 @@ export type AdminSectionContribution = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: AdminSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -361,6 +363,8 @@ export type SettingsSectionContribution = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: SettingsSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -374,12 +378,16 @@ export type PlatformRouteContribution = {
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContribution = {
|
||||
path: string;
|
||||
order?: number;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -388,6 +396,8 @@ export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
export type PlatformInterfaceIdentityProps = {
|
||||
/** Stable control-plane identity; use a module-namespaced value. */
|
||||
interfaceId?: string;
|
||||
/** Stable contextual-help identifier associated with the control. */
|
||||
helpContextId?: string;
|
||||
/** Optional stable documentation/help topic associated with the control. */
|
||||
helpTopicId?: string;
|
||||
};
|
||||
@@ -414,6 +424,7 @@ export type PlatformWebModule = {
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
viewSurfaces?: PlatformViewSurface[];
|
||||
helpContexts?: PlatformDocumentationHelpContext[];
|
||||
};
|
||||
|
||||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
help_contexts?: PlatformDocumentationHelpContext[];
|
||||
frontend: Pick<
|
||||
PlatformFrontendModuleInfo,
|
||||
| "module_id"
|
||||
@@ -1090,6 +1109,7 @@ export type PlatformModuleInfo = {
|
||||
dependencies: string[];
|
||||
optional_dependencies: string[];
|
||||
enabled: boolean;
|
||||
help_contexts?: PlatformDocumentationHelpContext[];
|
||||
architecture?: {
|
||||
contract_version: string;
|
||||
layer: string;
|
||||
|
||||
+385
-45
@@ -1,74 +1,414 @@
|
||||
import type { PlatformWebModule } from "../types";
|
||||
|
||||
export type HelpContextKind = "page" | "field" | "action" | "dialog" | "interface";
|
||||
|
||||
export type HelpContext = {
|
||||
id: string;
|
||||
title: string;
|
||||
route: string;
|
||||
kind: HelpContextKind;
|
||||
moduleId?: string;
|
||||
parentId?: string;
|
||||
parentTitle?: string;
|
||||
documentationTopicId?: string;
|
||||
documentationType?: "user" | "admin";
|
||||
};
|
||||
|
||||
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
|
||||
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
|
||||
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80" },
|
||||
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06" },
|
||||
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
|
||||
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
|
||||
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
"global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
|
||||
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
|
||||
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
|
||||
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
|
||||
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
|
||||
reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
|
||||
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c" },
|
||||
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76" }
|
||||
type ContextDefinition = Omit<HelpContext, "route" | "kind">;
|
||||
|
||||
const campaignSectionContexts: Record<string, ContextDefinition> = {
|
||||
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
|
||||
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
|
||||
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80", moduleId: "campaigns" },
|
||||
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06", moduleId: "campaigns" },
|
||||
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
|
||||
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
|
||||
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24", moduleId: "campaigns" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10", moduleId: "campaigns" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
|
||||
mail: { 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", moduleId: "campaigns" },
|
||||
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849", moduleId: "campaigns" },
|
||||
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
|
||||
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
|
||||
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303", moduleId: "campaigns" },
|
||||
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">> = {
|
||||
dashboard: { id: "app.dashboard", title: "i18n:govoplan-core.dashboard.d87f47b4" },
|
||||
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
|
||||
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
|
||||
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
|
||||
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" },
|
||||
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
|
||||
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
|
||||
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
|
||||
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
|
||||
const topLevelContexts: Record<string, ContextDefinition> = {
|
||||
dashboard: { id: "dashboard.page", title: "i18n:govoplan-core.dashboard.d87f47b4", moduleId: "dashboard" },
|
||||
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28", moduleId: "campaigns" },
|
||||
templates: { id: "templates.page", title: "i18n:govoplan-core.templates.f25b700e", moduleId: "templates" },
|
||||
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512", moduleId: "files" },
|
||||
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb", moduleId: "mail" },
|
||||
"address-book": { id: "addresses.page", title: "i18n:govoplan-core.address_book.f6327f59", moduleId: "addresses" },
|
||||
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5", moduleId: "core", documentationType: "user" },
|
||||
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc", moduleId: "admin", documentationType: "admin" }
|
||||
};
|
||||
|
||||
export function helpContextForPathname(pathname: string, search = ""): HelpContext {
|
||||
export function helpContextForPathname(
|
||||
pathname: string,
|
||||
search = "",
|
||||
modules: readonly PlatformWebModule[] = []
|
||||
): HelpContext {
|
||||
const route = pathname || "/";
|
||||
const routeWithSearch = `${route}${search}`;
|
||||
const segments = route.split("/").filter(Boolean);
|
||||
|
||||
if (segments[0] === "settings") {
|
||||
const section = new URLSearchParams(search).get("section") || "";
|
||||
if (section === "mail-profiles") {
|
||||
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` };
|
||||
if (segments[0] === "settings" || segments[0] === "admin") {
|
||||
const area = segments[0];
|
||||
const requestedSection = new URLSearchParams(search).get("section");
|
||||
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[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") {
|
||||
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]];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", route };
|
||||
if (context) return pageContext(context, routeWithSearch, modules);
|
||||
return pageContext({ id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", moduleId: "campaigns" }, routeWithSearch, modules);
|
||||
}
|
||||
|
||||
const context = topLevelContexts[segments[0] || "campaigns"];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "app.general", title: "i18n:govoplan-core.application.b291beb8", route };
|
||||
if (segments.length <= 1) {
|
||||
const context = topLevelContexts[segments[0] || "campaigns"];
|
||||
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 {
|
||||
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 {
|
||||
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
function pageContext(
|
||||
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",
|
||||
"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(
|
||||
documentationHelpHref({ topicId: "topic", anchorId: "details" }) ===
|
||||
"/docs?type=user&topic=topic#details",
|
||||
|
||||
@@ -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 {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
@@ -16,3 +17,65 @@ assertEqual(
|
||||
"campaign.attachments",
|
||||
"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");
|
||||
|
||||
Reference in New Issue
Block a user