diff --git a/docs/CONTEXTUAL_HELP_CONTRACT.md b/docs/CONTEXTUAL_HELP_CONTRACT.md new file mode 100644 index 0000000..edfde49 --- /dev/null +++ b/docs/CONTEXTUAL_HELP_CONTRACT.md @@ -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. diff --git a/docs/DOCUMENTATION_MAP.md b/docs/DOCUMENTATION_MAP.md index 0f6a66a..94116af 100644 --- a/docs/DOCUMENTATION_MAP.md +++ b/docs/DOCUMENTATION_MAP.md @@ -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. | diff --git a/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md b/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md index 8214eb0..059b215 100644 --- a/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md +++ b/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md @@ -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 diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index 2f89818..f4af96b 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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 diff --git a/docs/TEMPORAL_DATA_CONTEXT.md b/docs/TEMPORAL_DATA_CONTEXT.md new file mode 100644 index 0000000..d96dee3 --- /dev/null +++ b/docs/TEMPORAL_DATA_CONTEXT.md @@ -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. diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index ed1b1ea..d9193a5 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -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 diff --git a/src/govoplan_core/core/temporal.py b/src/govoplan_core/core/temporal.py new file mode 100644 index 0000000..b54faec --- /dev/null +++ b/src/govoplan_core/core/temporal.py @@ -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", +] diff --git a/src/govoplan_core/db/temporal.py b/src/govoplan_core/db/temporal.py new file mode 100644 index 0000000..bab7cf3 --- /dev/null +++ b/src/govoplan_core/db/temporal.py @@ -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"] diff --git a/src/govoplan_core/server/conditional_requests.py b/src/govoplan_core/server/conditional_requests.py index 06a78b7..82dec02 100644 --- a/src/govoplan_core/server/conditional_requests.py +++ b/src/govoplan_core/server/conditional_requests.py @@ -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( diff --git a/src/govoplan_core/server/fastapi.py b/src/govoplan_core/server/fastapi.py index 467111c..a804603 100644 --- a/src/govoplan_core/server/fastapi.py +++ b/src/govoplan_core/server/fastapi.py @@ -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: diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index 3f3a163..0079577 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -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() diff --git a/src/govoplan_core/server/temporal.py b/src/govoplan_core/server/temporal.py new file mode 100644 index 0000000..5f36d2e --- /dev/null +++ b/src/govoplan_core/server/temporal.py @@ -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"] diff --git a/tests/test_conditional_requests.py b/tests/test_conditional_requests.py index 6a47dee..22a429c 100644 --- a/tests/test_conditional_requests.py +++ b/tests/test_conditional_requests.py @@ -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") diff --git a/tests/test_module_system.py b/tests/test_module_system.py index b79bfaa..b707610 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -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)) diff --git a/tests/test_temporal_context.py b/tests/test_temporal_context.py new file mode 100644 index 0000000..ffecc23 --- /dev/null +++ b/tests/test_temporal_context.py @@ -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() diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs index d28175c..b75558a 100644 --- a/webui/scripts/test-core-interface-patterns.mjs +++ b/webui/scripts/test-core-interface-patterns.mjs @@ -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, /[\s\S]*/, "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"); diff --git a/webui/src/App.tsx b/webui/src/App.tsx index ca741f8..d193313 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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(null); const [workflowViewProjection, setWorkflowViewProjection] = useState(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}> + - - + + } /> {!dashboardModuleInstalled && } />} {publicRoutes.map((route) => @@ -567,6 +585,7 @@ export default function App() { + ); diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index aa7dce7..a132289 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -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(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(settings: ApiSettings, path: string, init?: Re export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise { - 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)) { diff --git a/webui/src/components/Button.tsx b/webui/src/components/Button.tsx index 63ca832..9a6ed5a 100644 --- a/webui/src/components/Button.tsx +++ b/webui/src/components/Button.tsx @@ -7,7 +7,20 @@ export type ButtonProps = ButtonHTMLAttributes & PlatformInte disabledReason?: ReactNode; }; -export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) { - const button = ; +export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpContextId, helpTopicId, children, ...props }: ButtonProps) { + const button = ( + + {children} + + ); return {button}; } diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx index 545975d..0f07a72 100644 --- a/webui/src/components/Card.tsx +++ b/webui/src/components/Card.tsx @@ -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 ( - + {hasHeader && {title && (typeof title === "string" ? {translateText(title)} : {title})} @@ -85,4 +93,4 @@ export default function Card({ title, children, actions, collapsible = false, co {shouldRenderBody && (collapsible ? {body} : body)} ); -} \ No newline at end of file +} diff --git a/webui/src/components/DateTimeField.tsx b/webui/src/components/DateTimeField.tsx index ff67e51..8de6704 100644 --- a/webui/src/components/DateTimeField.tsx +++ b/webui/src/components/DateTimeField.tsx @@ -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(() => selectedDate ?? new Date()); @@ -94,7 +94,15 @@ export function DateField({ value, onChange, min, max, disabled, className = "", } return ( - + (null); useEffect(() => { const input = inputRef.current; @@ -159,7 +167,14 @@ export function TimeField({ value, onChange, min, max, className = "", placehold }, [value, min, max]); return ( - + + diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx index 834365f..fd87bc4 100644 --- a/webui/src/components/FormField.tsx +++ b/webui/src/components/FormField.tsx @@ -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 ( - + {renderedLabel} {children} diff --git a/webui/src/components/SearchableSelect.tsx b/webui/src/components/SearchableSelect.tsx index edd795e..c77e208 100644 --- a/webui/src/components/SearchableSelect.tsx +++ b/webui/src/components/SearchableSelect.tsx @@ -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({ diff --git a/webui/src/components/ToggleSwitch.tsx b/webui/src/components/ToggleSwitch.tsx index 015532a..fb41b7e 100644 --- a/webui/src/components/ToggleSwitch.tsx +++ b/webui/src/components/ToggleSwitch.tsx @@ -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 ( - + + {title} @@ -44,4 +56,4 @@ export default function AdminPageLayout({ ); -} \ No newline at end of file +} diff --git a/webui/src/components/email/EmailAddressInput.tsx b/webui/src/components/email/EmailAddressInput.tsx index 3f80f37..49409c2 100644 --- a/webui/src/components/email/EmailAddressInput.tsx +++ b/webui/src/components/email/EmailAddressInput.tsx @@ -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 ( - + {normalizedValue.length === 0 && !entryText && {translateText(emptyText)}} diff --git a/webui/src/components/help/documentationHelp.ts b/webui/src/components/help/documentationHelp.ts index 0304179..1821c25 100644 --- a/webui/src/components/help/documentationHelp.ts +++ b/webui/src/components/help/documentationHelp.ts @@ -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)}` : ""}`; } diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 4beaf40..720b44a 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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." } }; diff --git a/webui/src/index.ts b/webui/src/index.ts index e998d7b..a3cc619 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/layout/HelpMenu.tsx b/webui/src/layout/HelpMenu.tsx index 4b80e69..b791583 100644 --- a/webui/src/layout/HelpMenu.tsx +++ b/webui/src/layout/HelpMenu.tsx @@ -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(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(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 }) { setOpen(!open)} onKeyDown={(event) => { if (event.key === "F1") { @@ -90,11 +104,11 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) { {translateText("i18n:govoplan-core.help.c47ae153")} i18n:govoplan-core.f1.88bfad9c - openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}> + openDocs("user", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")} {adminDocsAvailable && - openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}> + openDocs("admin", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}> {translateText("i18n:govoplan-core.admin_docs.bf504a56")} } @@ -103,14 +117,16 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) { {setAboutOpen(true);setOpen(false);}}> {translateText("i18n:govoplan-core.about.6b21fb79")} } - {contextOpen && openDocs("user")} onClose={() => setContextOpen(false)} />} + {contextOpen && openDocs(activeHelpContext.documentationType ?? "user", activeHelpContext)} onClose={() => setContextOpen(false)} />} {aboutOpen && setAboutOpen(false)} />} ); } 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 {translateText(context.title)} + {context.parentId && + {translateText("i18n:govoplan-core.page.fb06270f")}: {translateText(context.parentTitle ?? context.parentId)} + } {translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id} {translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} {helpQueryForContext(context)} {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")} - - - {translateText("i18n:govoplan-core.next_actions.7b09055a")} - {translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")} - {translateText("i18n:govoplan-core.open_user_documentation.084af515")} + {translateText(context.documentationType === "admin" ? "i18n:govoplan-core.open_admin_documentation.6adbdae3" : "i18n:govoplan-core.open_user_documentation.084af515")} ); diff --git a/webui/src/layout/LanguageMenu.tsx b/webui/src/layout/LanguageMenu.tsx index cb3d9be..5b36978 100644 --- a/webui/src/layout/LanguageMenu.tsx +++ b/webui/src/layout/LanguageMenu.tsx @@ -25,7 +25,15 @@ export default function LanguageMenu() { return ( - setOpen(!open)} aria-haspopup="menu" aria-expanded={open}> + setOpen(!open)} + aria-haspopup="menu" + aria-expanded={open} + > {language.toUpperCase()} ▾ diff --git a/webui/src/layout/TemporalDataMenu.tsx b/webui/src/layout/TemporalDataMenu.tsx new file mode 100644 index 0000000..4a86260 --- /dev/null +++ b/webui/src/layout/TemporalDataMenu.tsx @@ -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(selection); + const [error, setError] = useState(""); + const menuRef = useRef(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 ( + + + + + + {open && ( + + + i18n:govoplan-core.data_state + + + + 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" && ( + + setDraft((current) => ({ + ...current, + validAt: fromLocalInput(event.target.value) + }))} + /> + + )} + + + + selectRecordedMode( + event.target.value === "at" ? "at" : "latest" + )} + > + i18n:govoplan-core.latest_recorded_state + i18n:govoplan-core.recorded_by_time + + + {draft.recordedAt && ( + + setDraft((current) => ({ + ...current, + recordedAt: fromLocalInput(event.target.value) + }))} + /> + + )} + + + + i18n:govoplan-core.temporal_data_explanation + + + {error && ( + + {error} + + )} + + + + i18n:govoplan-core.apply_data_state + + + + )} + + ); +} + +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; +} diff --git a/webui/src/layout/Titlebar.tsx b/webui/src/layout/Titlebar.tsx index 0e79b91..31b90e4 100644 --- a/webui/src/layout/Titlebar.tsx +++ b/webui/src/layout/Titlebar.tsx @@ -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 ( - - {titlebarState && - - {Array.from({ length: 10 }, (_, index) => {titlebarStateLabel})} + + {titlebarState === "offline" && + + {titlebarStateLabel} } + {titlebarState === "maintenance" && + + {titlebarStateLabel} + + } - {titlebarState === "offline" && - - - - } - {titlebarState === "maintenance" && - - - - } {auth && showContextSelectors && {activeTenant && showTenantControl && @@ -187,7 +179,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode {translateText("i18n:govoplan-core.tenant_label_prefix")} {canSwitchTenant ? <> - setTenantOpen(!tenantOpen)}> + setTenantOpen(!tenantOpen)}> {activeTenant.name} ▾ @@ -216,9 +208,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode } } - {ViewSelector && - - } {ActingContextSelector && } @@ -226,18 +215,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode } - {auth && GlobalSearch && canUseGlobalSearch && - - - - } - + {auth && GlobalSearch && canUseGlobalSearch && + + } + {auth && ViewSelector && + + } + {auth && } {auth && notificationsAvailable && - + {unreadNotificationCount > 0 && @@ -250,6 +240,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode void; + isDefault: boolean; + selectionKey: string; +}; + +const TemporalContext = createContext({ + 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(() => + 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(() => ({ + selection, + applySelection, + isDefault: temporalDataSelectionIsDefault(selection), + selectionKey: temporalDataSelectionKey(selection) + }), [applySelection, selection]); + + return ( + + {children} + + ); +} + +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)); +} diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index 8792906..2304e16 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -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: { diff --git a/webui/src/platform/temporal.ts b/webui/src/platform/temporal.ts new file mode 100644 index 0000000..da98868 --- /dev/null +++ b/webui/src/platform/temporal.ts @@ -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 | 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 { + const selection = activeTemporalDataSelection(); + if (temporalDataSelectionIsDefault(selection)) return {}; + const headers: Record = { + "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( + 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(); +} diff --git a/webui/src/styles/auth-gate.css b/webui/src/styles/auth-gate.css index a4f7d7f..28eae19 100644 --- a/webui/src/styles/auth-gate.css +++ b/webui/src/styles/auth-gate.css @@ -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; diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index f0603d0..ed975dd 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -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; diff --git a/webui/src/types.ts b/webui/src/types.ts index 19cfa3e..8196df4 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -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; 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; diff --git a/webui/src/utils/helpContext.ts b/webui/src/utils/helpContext.ts index e8e4898..460b74c 100644 --- a/webui/src/utils/helpContext.ts +++ b/webui/src/utils/helpContext.ts @@ -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> = { - 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; + +const campaignSectionContexts: Record = { + 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> = { - 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 = { + 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( + "[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( + ".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( + "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("[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("[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 & Partial +): 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(".form-label, .toggle-switch-label"); + if (fieldLabel?.textContent?.trim()) return fieldLabel.textContent.trim(); + const dialogTitle = own.querySelector(".dialog-title"); + if (dialogTitle?.textContent?.trim()) return dialogTitle.textContent.trim(); + const heading = own.querySelector("h1, h2, h3"); + if (heading?.textContent?.trim()) return heading.textContent.trim(); + const associatedLabel = own.id && typeof document !== "undefined" + ? document.querySelector(`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, "\\$&"); } diff --git a/webui/tests/documentation-help-link.test.tsx b/webui/tests/documentation-help-link.test.tsx index 740ee08..2884d2f 100644 --- a/webui/tests/documentation-help-link.test.tsx +++ b/webui/tests/documentation-help-link.test.tsx @@ -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", diff --git a/webui/tests/help-context.test.ts b/webui/tests/help-context.test.ts index 8898c04..0b42828 100644 --- a/webui/tests/help-context.test.ts +++ b/webui/tests/help-context.test.ts @@ -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");
{translateText("i18n:govoplan-core.page.fb06270f")}: {translateText(context.parentTitle ?? context.parentId)}
{translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id}
{translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} {helpQueryForContext(context)} {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")}
{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}
+ i18n:govoplan-core.temporal_data_explanation +