Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a5be2a93 | ||
|
|
add7a99f6d |
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "versions"
|
||||
/ "a36d8e4f9b12_german_reference_locale.py"
|
||||
)
|
||||
_spec = spec_from_file_location("govoplan_german_reference_locale_migration", _path)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||
_module = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
|
||||
revision = _module.revision
|
||||
down_revision = _module.down_revision
|
||||
branch_labels = _module.branch_labels
|
||||
depends_on = _module.depends_on
|
||||
upgrade = _module.upgrade
|
||||
downgrade = _module.downgrade
|
||||
@@ -0,0 +1,44 @@
|
||||
"""adopt German as the untouched system reference locale
|
||||
|
||||
Revision ID: a36d8e4f9b12
|
||||
Revises: f25c9d3e7a01
|
||||
Create Date: 2026-08-05 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a36d8e4f9b12"
|
||||
down_revision = "f25c9d3e7a01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if "core_system_settings" not in set(sa.inspect(bind).get_table_names()):
|
||||
return
|
||||
|
||||
settings = sa.table(
|
||||
"core_system_settings",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("default_locale", sa.String),
|
||||
sa.column("created_at", sa.DateTime(timezone=True)),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
bind.execute(
|
||||
settings.update()
|
||||
.where(settings.c.id == "global")
|
||||
.where(settings.c.default_locale == "en")
|
||||
.where(settings.c.created_at == settings.c.updated_at)
|
||||
.values(default_locale="de", updated_at=sa.func.now())
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Locale selection is user-visible state. A downgrade must not overwrite a
|
||||
# German value that may have been selected explicitly after this migration.
|
||||
pass
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
|
||||
Core also maps explicit route, navigation, settings, and View surface IDs to
|
||||
the module's static user or administrator documentation baseline. This makes a
|
||||
page association complete by default and gives every derived field/action
|
||||
context a useful fallback. Exact `metadata.help_contexts` remain the preferred
|
||||
authoring mechanism for consequential or unfamiliar controls.
|
||||
|
||||
When there is no exact topic, Docs resolves the page fallback and then the first
|
||||
visible topic owned by the module. If Docs is unavailable, the shell opens the
|
||||
hosted documentation with the same context parameters.
|
||||
|
||||
## Authoring Controls
|
||||
|
||||
Core shared controls expose stable help metadata. Prefer these props rather
|
||||
than adding custom `F1` listeners:
|
||||
|
||||
- `interfaceId` identifies a durable UI surface or action.
|
||||
- `helpContextId` identifies a documentation context when it differs from the
|
||||
interface identity.
|
||||
- `helpTopicId` links directly to a module-owned documentation topic.
|
||||
- translated label keys provide deterministic field identities for ordinary
|
||||
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
|
||||
controls.
|
||||
|
||||
Module routes, public routes, settings sections, and administration sections
|
||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||
static user/admin documentation baseline and should list its important route,
|
||||
workflow, setting, permission, and limitation identities in
|
||||
`metadata.help_contexts`.
|
||||
|
||||
## Boundary
|
||||
|
||||
Help identities describe presentation context; they are not authorization
|
||||
claims. Opening help never bypasses route or documentation permissions. Docs
|
||||
owns documentation projection, feature modules own their content, and Core owns
|
||||
focus capture, context resolution, and fallback routing.
|
||||
@@ -17,6 +17,10 @@ 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. |
|
||||
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
|
||||
| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. |
|
||||
| German localization and help quality gate | `LOCALIZATION_AND_HELP_QUALITY.md` | German reference locale, new-installation default, catalog completeness, automatic page associations, and explicit-help review priorities. |
|
||||
| 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. |
|
||||
@@ -37,6 +41,8 @@ operator, and roadmap pages.
|
||||
| Topic | Canonical document | Notes |
|
||||
| --- | --- | --- |
|
||||
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
||||
| Stable platform ideas | `govoplan/docs/PLATFORM_CORE_IDEAS.md` | Cross-product thesis, canonical distinctions, product experience rule, maturity rule, and decision test. |
|
||||
| Current cross-product reconciliation | `govoplan/docs/STRATEGY_STATUS.md` | The only current prose status source; generated evidence and Gitea remain authoritative inputs. |
|
||||
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
|
||||
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
|
||||
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Information Governance Adoption
|
||||
|
||||
## Platform Rule
|
||||
|
||||
Temporal browsing, purpose-aware access, retention, and institutional context
|
||||
are platform-wide information-governance dimensions. Every module receives the
|
||||
same contract by default. A module may claim `partial` or `enforced` only with
|
||||
repository-owned object scope, evidence, and limitations; it may claim
|
||||
`not_applicable` only when the dimension genuinely does not apply.
|
||||
|
||||
Historical business data is always authorized under the current security
|
||||
state. No module may use a historical permission, membership, role, function
|
||||
assignment, or policy projection to weaken present-day access.
|
||||
|
||||
Platform-wide adoption is tracked in
|
||||
[GovOPlaN #40](https://git.add-ideas.de/GovOPlaN/govoplan/issues/40), with
|
||||
temporal reads detailed in
|
||||
[GovOPlaN #39](https://git.add-ideas.de/GovOPlaN/govoplan/issues/39).
|
||||
|
||||
## Manifest Declaration
|
||||
|
||||
`ModuleManifest.information_governance` publishes four dimensions:
|
||||
|
||||
- `temporal_browsing`;
|
||||
- `purpose_aware_access`;
|
||||
- `retention`;
|
||||
- `institutional_context`.
|
||||
|
||||
Each dimension declares:
|
||||
|
||||
- adoption: `not_applicable`, `contract_only`, `partial`, or `enforced`;
|
||||
- object types covered;
|
||||
- repository-local test/documentation evidence;
|
||||
- the remaining limitation for `contract_only` or `partial`.
|
||||
|
||||
The default is intentionally `contract_only`. It applies the platform rule
|
||||
without pretending that existing domain queries and effects already enforce
|
||||
it. `reference_ready`, `supported`, and `lts` modules cannot retain an
|
||||
applicable dimension below `enforced`.
|
||||
|
||||
## Read Contract
|
||||
|
||||
For every persistent domain object, the owner classifies the read:
|
||||
|
||||
1. **Current-only:** historical semantics do not exist and the API says so.
|
||||
2. **Valid-time:** select facts effective now or at the requested instant.
|
||||
3. **Bitemporal:** additionally select only revisions known by `recorded_at`.
|
||||
4. **All-validity:** return effective revisions in a bounded history view.
|
||||
|
||||
The Core temporal middleware supplies the request context. Owners apply it in
|
||||
repositories or query helpers, include it in cache keys, return evaluated
|
||||
context, and test current/at/all plus recorded-time boundaries. Search,
|
||||
reporting, exports, selectors, counts, and drill-through must use the same
|
||||
projection as the owning list/detail API.
|
||||
|
||||
## Purpose-Aware Access Contract
|
||||
|
||||
Permission establishes a technical action ceiling. Purpose-aware access asks
|
||||
whether this actor, represented capacity, case/work item, legal basis, and
|
||||
declared use may access this object now.
|
||||
|
||||
- A client-supplied purpose is an assertion, never authority by itself.
|
||||
- The owner or Policy capability validates the purpose and returns explainable
|
||||
provenance.
|
||||
- Sensitive access can require case assignment, mandate, reason capture,
|
||||
approval, or break-glass evidence.
|
||||
- Search, selectors, reporting, exports, background jobs, and connectors apply
|
||||
the same decision.
|
||||
- Audit records the validated purpose identifier and decision reference, not
|
||||
unnecessary content.
|
||||
|
||||
## Retention Contract
|
||||
|
||||
Every persistent object declares an owner, retention class or policy reference,
|
||||
trigger, start instant, hold behavior, review/disposition action, and evidence.
|
||||
Retention is not a generic timestamp deletion job.
|
||||
|
||||
- Domain owners enumerate and execute their own effects through a typed
|
||||
retention provider.
|
||||
- Policy resolves inherited ceilings and simulation.
|
||||
- Records owns record disposition; Files owns byte/object effects; Audit owns
|
||||
audit-detail behavior; external providers declare their own effect and
|
||||
recovery semantics.
|
||||
- Dry-run, legal hold, exact revision, idempotency, outcome unknown,
|
||||
reconciliation, correction, and destruction evidence are mandatory for
|
||||
consequential removal.
|
||||
|
||||
## Institutional Context Contract
|
||||
|
||||
Consequential objects and effects carry the relevant tenant, institution,
|
||||
organization unit, function, mandate/jurisdiction, service/case/work item,
|
||||
party/representation, decision, and record references. Context is minimized to
|
||||
what the operation needs. Organizational membership is not itself permission
|
||||
or mandate.
|
||||
|
||||
Events, automation intents, audit evidence, records, and external effects retain
|
||||
the same governed context envelope or an exact reference to it. Consumers must
|
||||
not reconstruct authority later from mutable current structures.
|
||||
|
||||
## Adoption Order
|
||||
|
||||
1. Inventory every domain list/detail/search/export/effect and classify all
|
||||
four dimensions.
|
||||
2. Migrate institutional owners first: Access, IDM, Organizations, Mandates,
|
||||
Services, Parties, Cases, Approvals, Committee, Decisions, Voting, and
|
||||
Records.
|
||||
3. Migrate communication and content: Addresses, Distribution Lists, Campaign,
|
||||
Postbox, Mail, Calendar, Files, Templates, and Forms Runtime.
|
||||
4. Migrate data projections: Connectors, Datasources, Dataflow, Reporting,
|
||||
Search, Risk Compliance, and Dashboard.
|
||||
5. Migrate workflow/task/background/provider operations and prove that no
|
||||
asynchronous path drops context.
|
||||
6. Advance manifest claims only after owner tests and browser/reference-journey
|
||||
evidence pass.
|
||||
|
||||
The generated platform inventory reports adoption counts and module details.
|
||||
Gitea tracks individual migrations; the declaration is evidence and a maturity
|
||||
gate, not a substitute for implementation.
|
||||
|
||||
## Definition Of Enforced
|
||||
|
||||
A dimension is `enforced` only when:
|
||||
|
||||
- all declared object types and public reads/effects use it;
|
||||
- list/detail/count/search/export/worker behavior is consistent;
|
||||
- cache and pagination semantics cannot cross contexts;
|
||||
- absence, invalid values, and inaccessible referenced context fail safely;
|
||||
- tests cover current, historical, unauthorized, replay, and module-absence
|
||||
combinations appropriate to the dimension;
|
||||
- user/admin documentation explains behavior and limitations;
|
||||
- the manifest cites those tests and docs.
|
||||
@@ -8,6 +8,9 @@ The shared contract lives in `govoplan_core.core.institutional`.
|
||||
Core owns reference shapes and provider protocols only. It does not own shared
|
||||
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
|
||||
own persistence and authorization; optional capabilities resolve the references.
|
||||
Interactive reads use the separate platform temporal-data context documented in
|
||||
`TEMPORAL_DATA_CONTEXT.md`; it never changes current authorization or supplies
|
||||
mutation dates.
|
||||
|
||||
## Envelope
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ domain modules own their compositions.
|
||||
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
|
||||
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
|
||||
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
|
||||
|
||||
## Boundary
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Localization And Contextual Help Quality
|
||||
|
||||
## Reference Language
|
||||
|
||||
German (`de`) is GovOPlaN's first-class reference target. Every translation key
|
||||
used by a shipped WebUI must exist in German and English. German completeness is
|
||||
a release gate; English remains the source-code fallback language so existing
|
||||
literal labels and external developer APIs do not change semantics.
|
||||
|
||||
New installations and tenants default to German. Existing system, tenant, and
|
||||
user preferences are preserved. The available-language and policy model can
|
||||
still select another default or disable a package at the relevant scope.
|
||||
|
||||
Explicit high-risk help content and browser acceptance are tracked in
|
||||
[Core #284](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/284).
|
||||
|
||||
The platform inventory recognizes both inline locale objects and generated
|
||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||
locales and reports `de` explicitly as the reference locale.
|
||||
|
||||
## Help Resolution
|
||||
|
||||
Every focusable field and action receives a stable derived F1 identity from the
|
||||
shared shell, even when the component has no dedicated help text. Resolution
|
||||
falls back from field/action to dialog or page and then to the module's visible
|
||||
documentation baseline.
|
||||
|
||||
Backend manifests publish explicit topic associations first. Core additionally
|
||||
associates declared route, navigation, settings, and View surface IDs with the
|
||||
module's static user or administrator documentation baseline. Feature modules
|
||||
should still add exact `metadata.help_contexts` entries for consequential,
|
||||
unfamiliar, policy-controlled, destructive, security-sensitive, or legally
|
||||
meaningful fields and actions.
|
||||
|
||||
The generated `help_review_candidates` list is therefore a content-depth queue,
|
||||
not a list of controls on which F1 cannot work. It should prioritize:
|
||||
|
||||
1. effect, deletion, delivery, retention, disclosure, encryption, and recovery;
|
||||
2. identity, representation, mandate, institutional context, and purpose;
|
||||
3. valid-time versus recorded-time selection;
|
||||
4. provider authority, synchronization, conflict, and outcome unknown;
|
||||
5. fields whose consequences are not evident from their label.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python \
|
||||
tools/inventory/platform-interface-inventory.py \
|
||||
--strict --strict-declarations --strict-endpoints
|
||||
```
|
||||
|
||||
The check must report:
|
||||
|
||||
- reference locale `de` present and complete;
|
||||
- no used key missing from `de` or `en`;
|
||||
- every field has a resolvable F1 context;
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
@@ -93,10 +93,17 @@ Alembic, and post-migration tasks. The lock is session-scoped and therefore
|
||||
released if the migration process dies.
|
||||
|
||||
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
|
||||
the database has exactly the configured Core/module Alembic heads and never
|
||||
upgrades schema. This permits a migration Job and runtime Deployments to be
|
||||
the database has exactly the configured, dependency-resolved Core/module
|
||||
Alembic heads and never upgrades schema. Cross-module `depends_on` revisions
|
||||
therefore do not leave runtime roles waiting for a branch marker Alembic has
|
||||
correctly consumed. This permits a migration Job and runtime Deployments to be
|
||||
submitted together while keeping startup fail-closed.
|
||||
|
||||
Runtime coordination records the installed `govoplan-core` distribution
|
||||
version for API, worker and scheduler roles. FastAPI/OpenAPI metadata versions
|
||||
are presentation metadata and must not be used as deployable software identity;
|
||||
mixing the two would create a false version-skew readiness failure.
|
||||
|
||||
## Recovery Ledger
|
||||
|
||||
`govoplan_core.core.recovery` provides a durable operation and evidence
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Temporal Data Context
|
||||
|
||||
GovOPlaN exposes one read context for data validity and system knowledge. The
|
||||
calendar control in the authenticated titlebar applies that context to
|
||||
supported list and detail reads for the current account and tenant.
|
||||
|
||||
## Two Independent Axes
|
||||
|
||||
- **Valid time** answers when a fact applied in the represented domain.
|
||||
- **Recorded time** answers what the system had recorded by a particular
|
||||
instant.
|
||||
|
||||
The default is data valid now under the latest recorded state. `At time`
|
||||
selects a valid-time instant. `All` removes the valid-time interval filter but
|
||||
still uses the selected recorded state. The optional recorded-state cutoff can
|
||||
be combined with any valid-time mode, which keeps correction history distinct
|
||||
from changes in real-world validity.
|
||||
|
||||
An interval is half open: `valid_from <= instant < valid_to`. A revision belongs
|
||||
to a recorded-state snapshot when `recorded_at <= cutoff` and it was not
|
||||
superseded at or before that cutoff.
|
||||
|
||||
## Security And Mutation Rules
|
||||
|
||||
The temporal data context is a read projection, not an authorization context.
|
||||
Authentication, permissions, active delegations, tenant boundaries, module
|
||||
policy, and maintenance controls are always evaluated under current security
|
||||
state. A historical projection never restores an expired permission.
|
||||
|
||||
The context also does not supply mutation dates. Writes continue to target the
|
||||
current lifecycle revision and must carry their explicit valid/effective dates,
|
||||
expected revision, reason, and evidence where the owning contract requires
|
||||
them. A screen showing historical data must not silently turn a normal edit
|
||||
into a historical correction.
|
||||
|
||||
## HTTP Contract
|
||||
|
||||
Core accepts these request headers:
|
||||
|
||||
| Header | Meaning |
|
||||
| --- | --- |
|
||||
| `X-Govoplan-Validity-Mode` | `current`, `at`, or `all` |
|
||||
| `X-Govoplan-Valid-At` | Timezone-aware ISO 8601 instant required by `at` |
|
||||
| `X-Govoplan-Recorded-At` | Optional timezone-aware system-knowledge cutoff |
|
||||
|
||||
Invalid or naive timestamps fail with HTTP 400. Responses expose the resolved
|
||||
mode and evaluated instant. Conditional JSON responses vary by all three
|
||||
request headers, and the shared WebUI API client includes them in request
|
||||
deduplication and conditional-cache keys.
|
||||
|
||||
## Module Adoption
|
||||
|
||||
Revision-owning modules apply
|
||||
`govoplan_core.db.temporal.apply_temporal_revision_filter` only to read queries
|
||||
that are meant to follow the platform context. Explicit version references and
|
||||
explicit resolver `effective_at` arguments take precedence. Current-row
|
||||
lookups used for optimistic concurrency, authorization, routing, effects, or
|
||||
other mutations must remain explicit and context-independent.
|
||||
|
||||
The initial bitemporal adoption covers Decisions, Mandates, Parties, and
|
||||
Services. Their immutable revisions have indexed valid, recorded, and
|
||||
superseded timestamps. Modules with effective-dated security records or
|
||||
recorded-only revision histories require separate display-query adoption so
|
||||
the global selector cannot affect current authorization or execution.
|
||||
|
||||
The WebUI selection is stored in session storage per account and tenant. A
|
||||
change remounts the active module route so existing page loaders issue a fresh
|
||||
request. Returning both axes to their defaults removes the stored selection.
|
||||
@@ -51,10 +51,12 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
|
||||
| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell |
|
||||
| UX-028 | Maintenance and offline states use their established textual warning banners centered in the titlebar. They must not recolor the shell or add decorative status icons. Global search is a right-side command, so warnings do not replace its trigger or overlay. | Accepted | Core WebUI shell |
|
||||
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
|
||||
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
|
||||
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
|
||||
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
|
||||
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
|
||||
|
||||
@@ -1065,6 +1065,408 @@
|
||||
"release": "0.1.15",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "93b4c5d6e7f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "a36d8e4f9b12"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "a3d5f7b9c1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "a8d1e4f7b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "b1c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "b8d2f5a0c3e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "b8e4c1f7a2d9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "c5d7e8f9a0b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "c7e0a3d6f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8e3f6a9b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "e3c8f4a5b6d7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "e4a1f8c2d7b6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "f1a2b3c4d5e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "f6c2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "f6d3a8b1c4e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "f7c8d9e0a1b2"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"c7e0a3d6f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"c5d7e8f9a0b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"a8d1e4f7b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"e3c8f4a5b6d7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"f6d3a8b1c4e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"f7c8d9e0a1b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"a36d8e4f9b12"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"f6c2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"b8d2f5a0c3e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"f1a2b3c4d5e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"a3d5f7b9c1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"b1c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"93b4c5d6e7f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8e3f6a9b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"b8e4c1f7a2d9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"e4a1f8c2d7b6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-08-05T17:51:25Z",
|
||||
"release": "0.1.16",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -12,7 +12,7 @@ class SystemSettings(Base, TimestampMixin):
|
||||
__tablename__ = "core_system_settings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
|
||||
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
@@ -20,4 +20,3 @@ class SystemSettings(Base, TimestampMixin):
|
||||
|
||||
|
||||
__all__ = ["SystemSettings"]
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class TenantInfo(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
is_active: bool = True
|
||||
default_locale: str = "en"
|
||||
default_locale: str = "de"
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ class AuthProfileResponse(BaseModel):
|
||||
active_tenant: TenantInfo
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
default_language: str = "de"
|
||||
profile_loaded: bool = True
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ class LoginResponse(BaseModel):
|
||||
principal: PrincipalContextInfo | None = None
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
default_language: str = "de"
|
||||
profile_loaded: bool = True
|
||||
roles_loaded: bool = True
|
||||
groups_loaded: bool = True
|
||||
@@ -257,7 +257,7 @@ class MeResponse(BaseModel):
|
||||
principal: PrincipalContextInfo | None = None
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
default_language: str = "de"
|
||||
profile_loaded: bool = True
|
||||
roles_loaded: bool = True
|
||||
groups_loaded: bool = True
|
||||
|
||||
@@ -356,7 +356,7 @@ def consume_first_admin_credential(
|
||||
tenant = Tenant(
|
||||
slug=clean_tenant_slug,
|
||||
name=clean_tenant_name,
|
||||
default_locale="en",
|
||||
default_locale="de",
|
||||
settings={},
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Mapping, cast
|
||||
|
||||
|
||||
InformationGovernanceAdoption = Literal[
|
||||
"not_applicable",
|
||||
"contract_only",
|
||||
"partial",
|
||||
"enforced",
|
||||
]
|
||||
|
||||
INFORMATION_GOVERNANCE_ADOPTION_ORDER: tuple[InformationGovernanceAdoption, ...] = (
|
||||
"not_applicable",
|
||||
"contract_only",
|
||||
"partial",
|
||||
"enforced",
|
||||
)
|
||||
|
||||
|
||||
class InformationGovernanceDeclarationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InformationGovernanceDimension:
|
||||
"""Truthful module-level adoption claim for one cross-cutting dimension."""
|
||||
|
||||
adoption: InformationGovernanceAdoption = "contract_only"
|
||||
object_types: tuple[str, ...] = ()
|
||||
evidence: tuple[str, ...] = ()
|
||||
limitation: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.adoption not in INFORMATION_GOVERNANCE_ADOPTION_ORDER:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Unsupported information-governance adoption: {self.adoption!r}."
|
||||
)
|
||||
for field_name in ("object_types", "evidence"):
|
||||
values = getattr(self, field_name)
|
||||
if len(values) != len(set(values)) or any(not item.strip() for item in values):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {field_name.replace('_', ' ')} must "
|
||||
"contain unique non-empty values."
|
||||
)
|
||||
if self.adoption == "enforced" and not self.evidence:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"An enforced information-governance dimension requires evidence."
|
||||
)
|
||||
if self.adoption in {"partial", "enforced"} and not self.object_types:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Partial and enforced information-governance dimensions must "
|
||||
"name their covered object types."
|
||||
)
|
||||
if self.adoption == "not_applicable" and self.object_types:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"A non-applicable information-governance dimension cannot declare object types."
|
||||
)
|
||||
if self.adoption in {"contract_only", "partial"} and not str(
|
||||
self.limitation or ""
|
||||
).strip():
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Contract-only and partial adoption must state the current limitation."
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"adoption": self.adoption,
|
||||
"object_types": list(self.object_types),
|
||||
"evidence": list(self.evidence),
|
||||
"limitation": self.limitation,
|
||||
}
|
||||
|
||||
|
||||
def _contract_only_dimension() -> InformationGovernanceDimension:
|
||||
return InformationGovernanceDimension(
|
||||
adoption="contract_only",
|
||||
limitation=(
|
||||
"The platform contract applies, but module-specific adoption evidence "
|
||||
"has not been declared."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleInformationGovernance:
|
||||
"""Cross-cutting data-use requirements and honest adoption evidence."""
|
||||
|
||||
temporal_browsing: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
purpose_aware_access: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
retention: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
institutional_context: InformationGovernanceDimension = field(
|
||||
default_factory=_contract_only_dimension
|
||||
)
|
||||
current_authorization_for_historical_reads: bool = True
|
||||
contract_version: str = "1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.contract_version != "1":
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Unsupported module information-governance contract version."
|
||||
)
|
||||
if not self.current_authorization_for_historical_reads:
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Historical reads must always use current authorization."
|
||||
)
|
||||
for name, dimension in self.dimensions.items():
|
||||
if not isinstance(dimension, InformationGovernanceDimension):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance dimension {name!r} has an invalid value."
|
||||
)
|
||||
|
||||
@property
|
||||
def dimensions(self) -> Mapping[str, InformationGovernanceDimension]:
|
||||
return {
|
||||
"temporal_browsing": self.temporal_browsing,
|
||||
"purpose_aware_access": self.purpose_aware_access,
|
||||
"retention": self.retention,
|
||||
"institutional_context": self.institutional_context,
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": self.contract_version,
|
||||
"current_authorization_for_historical_reads": (
|
||||
self.current_authorization_for_historical_reads
|
||||
),
|
||||
"dimensions": {
|
||||
name: dimension.to_dict()
|
||||
for name, dimension in self.dimensions.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def information_governance_from_mapping(
|
||||
value: Mapping[str, object],
|
||||
) -> ModuleInformationGovernance:
|
||||
raw_dimensions = value.get("dimensions")
|
||||
if not isinstance(raw_dimensions, Mapping):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"Information-governance dimensions must be an object."
|
||||
)
|
||||
|
||||
def dimension(name: str) -> InformationGovernanceDimension:
|
||||
raw_dimension = raw_dimensions.get(name)
|
||||
if not isinstance(raw_dimension, Mapping):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance dimension {name!r} must be an object."
|
||||
)
|
||||
|
||||
def text_tuple(field_name: str) -> tuple[str, ...]:
|
||||
raw_values = raw_dimension.get(field_name, ())
|
||||
if not isinstance(raw_values, (list, tuple)):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.{field_name} must be a list."
|
||||
)
|
||||
if any(not isinstance(item, str) for item in raw_values):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.{field_name} must contain strings."
|
||||
)
|
||||
return tuple(raw_values)
|
||||
|
||||
raw_limitation = raw_dimension.get("limitation")
|
||||
raw_adoption = raw_dimension.get("adoption") or "contract_only"
|
||||
if not isinstance(raw_adoption, str):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.adoption must be a string."
|
||||
)
|
||||
if raw_limitation is not None and not isinstance(raw_limitation, str):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
f"Information-governance {name}.limitation must be a string."
|
||||
)
|
||||
return InformationGovernanceDimension(
|
||||
adoption=cast(
|
||||
InformationGovernanceAdoption,
|
||||
raw_adoption,
|
||||
),
|
||||
object_types=text_tuple("object_types"),
|
||||
evidence=text_tuple("evidence"),
|
||||
limitation=raw_limitation,
|
||||
)
|
||||
|
||||
current_authorization = value.get(
|
||||
"current_authorization_for_historical_reads",
|
||||
True,
|
||||
)
|
||||
if not isinstance(current_authorization, bool):
|
||||
raise InformationGovernanceDeclarationError(
|
||||
"current_authorization_for_historical_reads must be boolean."
|
||||
)
|
||||
return ModuleInformationGovernance(
|
||||
contract_version=str(value.get("contract_version") or "1"),
|
||||
current_authorization_for_historical_reads=current_authorization,
|
||||
temporal_browsing=dimension("temporal_browsing"),
|
||||
purpose_aware_access=dimension("purpose_aware_access"),
|
||||
retention=dimension("retention"),
|
||||
institutional_context=dimension("institutional_context"),
|
||||
)
|
||||
|
||||
|
||||
def information_governance_maturity_issues(
|
||||
declaration: ModuleInformationGovernance,
|
||||
*,
|
||||
maturity: str | None,
|
||||
) -> tuple[str, ...]:
|
||||
if maturity not in {"reference_ready", "supported", "lts"}:
|
||||
return ()
|
||||
incomplete = [
|
||||
name
|
||||
for name, dimension in declaration.dimensions.items()
|
||||
if dimension.adoption not in {"not_applicable", "enforced"}
|
||||
]
|
||||
if not incomplete:
|
||||
return ()
|
||||
return (
|
||||
f"Maturity {maturity!r} requires enforced or explicitly non-applicable "
|
||||
"information governance for: " + ", ".join(incomplete),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INFORMATION_GOVERNANCE_ADOPTION_ORDER",
|
||||
"InformationGovernanceAdoption",
|
||||
"InformationGovernanceDeclarationError",
|
||||
"InformationGovernanceDimension",
|
||||
"ModuleInformationGovernance",
|
||||
"information_governance_from_mapping",
|
||||
"information_governance_maturity_issues",
|
||||
]
|
||||
@@ -17,6 +17,10 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.information_governance import (
|
||||
information_governance_from_mapping,
|
||||
information_governance_maturity_issues,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
external_provider_from_mapping,
|
||||
module_architecture_from_mapping,
|
||||
@@ -630,6 +634,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
"tags": _string_list(value.get("tags")),
|
||||
}
|
||||
raw_architecture = value.get("architecture")
|
||||
architecture_maturity: str | None = None
|
||||
if raw_architecture is not None:
|
||||
if not isinstance(raw_architecture, Mapping):
|
||||
raise ValueError(
|
||||
@@ -647,6 +652,27 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
+ "; ".join(issues)
|
||||
)
|
||||
item["architecture"] = architecture.to_dict()
|
||||
architecture_maturity = architecture.maturity
|
||||
raw_information_governance = value.get("information_governance")
|
||||
if raw_information_governance is not None:
|
||||
if not isinstance(raw_information_governance, Mapping):
|
||||
raise ValueError(
|
||||
"Module package catalog information_governance for "
|
||||
f"{module_id!r} must be an object."
|
||||
)
|
||||
information_governance = information_governance_from_mapping(
|
||||
raw_information_governance
|
||||
)
|
||||
governance_issues = information_governance_maturity_issues(
|
||||
information_governance,
|
||||
maturity=architecture_maturity,
|
||||
)
|
||||
if governance_issues:
|
||||
raise ValueError(
|
||||
"Module package catalog information_governance for "
|
||||
f"{module_id!r} is invalid: " + "; ".join(governance_issues)
|
||||
)
|
||||
item["information_governance"] = information_governance.to_dict()
|
||||
raw_providers = value.get("external_providers")
|
||||
if raw_providers is not None:
|
||||
if not isinstance(raw_providers, list):
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
||||
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
@@ -446,6 +447,9 @@ class ModuleManifest:
|
||||
...,
|
||||
] = ()
|
||||
architecture: ModuleArchitectureDeclaration | None = None
|
||||
information_governance: ModuleInformationGovernance = field(
|
||||
default_factory=ModuleInformationGovernance
|
||||
)
|
||||
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
|
||||
external_provider_state_providers: tuple[
|
||||
ExternalProviderStateProviderRegistration,
|
||||
|
||||
@@ -32,6 +32,9 @@ from govoplan_core.core.module_entitlements import (
|
||||
current_tenant_execution_context,
|
||||
tenant_execution_scope,
|
||||
)
|
||||
from govoplan_core.core.information_governance import (
|
||||
information_governance_maturity_issues,
|
||||
)
|
||||
from govoplan_core.core.ownership import (
|
||||
OwnershipProviderRegistration,
|
||||
ResourceOwnershipProvider,
|
||||
@@ -788,6 +791,13 @@ def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} architecture declaration: {issue}"
|
||||
)
|
||||
for issue in information_governance_maturity_issues(
|
||||
manifest.information_governance,
|
||||
maturity=architecture.maturity if architecture is not None else None,
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} information-governance declaration: {issue}"
|
||||
)
|
||||
|
||||
provider_ids: set[str] = set()
|
||||
declared_capabilities = {
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -73,7 +73,7 @@ def bootstrap_dev_data(
|
||||
) -> BootstrapResult:
|
||||
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
|
||||
if tenant is None:
|
||||
tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="en", settings={})
|
||||
tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="de", settings={})
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
|
||||
|
||||
@@ -685,7 +685,10 @@ def configured_migration_heads(
|
||||
manifest_factories=manifest_factories,
|
||||
migration_track=migration_track,
|
||||
)
|
||||
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
|
||||
scripts = ScriptDirectory.from_config(config)
|
||||
return tuple(
|
||||
sorted(revision.revision for revision in scripts.get_revisions("heads"))
|
||||
)
|
||||
|
||||
|
||||
def database_is_at_configured_heads(
|
||||
|
||||
@@ -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"]
|
||||
@@ -4,10 +4,12 @@ import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
I18N_SETTINGS_KEY = "i18n"
|
||||
REFERENCE_LANGUAGE_CODE = "de"
|
||||
SOURCE_LANGUAGE_CODE = "en"
|
||||
|
||||
DEFAULT_LANGUAGE_PACKAGES: tuple[dict[str, str], ...] = (
|
||||
{"code": "en", "label": "English", "native_label": "English"},
|
||||
{"code": "de", "label": "German", "native_label": "Deutsch"},
|
||||
{"code": "en", "label": "English", "native_label": "English"},
|
||||
)
|
||||
|
||||
|
||||
@@ -66,7 +68,7 @@ def normalize_enabled_language_codes(
|
||||
available_languages: Iterable[dict[str, Any]],
|
||||
*,
|
||||
default_locale: object = None,
|
||||
fallback_codes: Iterable[str] = ("en", "de"),
|
||||
fallback_codes: Iterable[str] = (REFERENCE_LANGUAGE_CODE, SOURCE_LANGUAGE_CODE),
|
||||
) -> list[str]:
|
||||
available_codes = [normalize_language_code(item.get("code")) for item in available_languages if isinstance(item, dict)]
|
||||
available = {code for code in available_codes if code}
|
||||
@@ -88,8 +90,10 @@ def normalize_enabled_language_codes(
|
||||
|
||||
if enabled:
|
||||
return enabled
|
||||
if "en" in available:
|
||||
return ["en"]
|
||||
if REFERENCE_LANGUAGE_CODE in available:
|
||||
return [REFERENCE_LANGUAGE_CODE]
|
||||
if SOURCE_LANGUAGE_CODE in available:
|
||||
return [SOURCE_LANGUAGE_CODE]
|
||||
return available_codes[:1]
|
||||
|
||||
|
||||
@@ -126,7 +130,7 @@ def preferred_language_code(user_settings: dict[str, Any] | None, allowed_codes:
|
||||
default = resolve_language_code(default_locale, enabled)
|
||||
if default:
|
||||
return default
|
||||
return enabled[0] if enabled else "en"
|
||||
return enabled[0] if enabled else REFERENCE_LANGUAGE_CODE
|
||||
|
||||
|
||||
def update_i18n_settings(settings: dict[str, Any] | None, **values: Any) -> dict[str, Any]:
|
||||
@@ -141,10 +145,13 @@ def system_i18n_payload(settings_item: Any | None) -> dict[str, object]:
|
||||
settings = getattr(settings_item, "settings", None)
|
||||
packages = system_language_packages(settings)
|
||||
available_codes = [item["code"] for item in packages]
|
||||
default_language = resolve_language_code(getattr(settings_item, "default_locale", None), available_codes) or "en"
|
||||
default_language = (
|
||||
resolve_language_code(getattr(settings_item, "default_locale", None), available_codes)
|
||||
or REFERENCE_LANGUAGE_CODE
|
||||
)
|
||||
enabled = system_enabled_language_codes(settings, default_locale=default_language)
|
||||
if default_language not in enabled:
|
||||
default_language = enabled[0] if enabled else "en"
|
||||
default_language = enabled[0] if enabled else REFERENCE_LANGUAGE_CODE
|
||||
return {
|
||||
"available_languages": packages,
|
||||
"enabled_languages": enabled,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi import HTTPException, status
|
||||
@@ -20,6 +21,13 @@ from govoplan_core.core.runtime_coordination import (
|
||||
from govoplan_core.settings import Settings, settings
|
||||
|
||||
|
||||
def _core_distribution_version() -> str:
|
||||
try:
|
||||
return version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "development"
|
||||
|
||||
|
||||
def _dev_bootstrap_needs_create_all(database_url: str) -> bool:
|
||||
try:
|
||||
return make_url(database_url).get_backend_name() == "sqlite"
|
||||
@@ -61,7 +69,7 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
runtime_agent = RuntimeNodeAgent(
|
||||
settings=settings,
|
||||
software_version=app.version,
|
||||
software_version=_core_distribution_version(),
|
||||
module_ids=module_ids,
|
||||
metadata={"process": "api"},
|
||||
identity=configured_identity
|
||||
@@ -93,7 +101,7 @@ def register_health_details(
|
||||
):
|
||||
app.state.govoplan_runtime_identity = runtime_identity(
|
||||
active_settings,
|
||||
software_version=app.version,
|
||||
software_version=_core_distribution_version(),
|
||||
module_ids=module_ids,
|
||||
)
|
||||
bind_process_runtime_identity(app.state.govoplan_runtime_identity)
|
||||
|
||||
@@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
|
||||
from govoplan_core.server.temporal import temporal_data_context_middleware
|
||||
|
||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
@@ -169,6 +170,7 @@ def create_govoplan_app(
|
||||
return response
|
||||
|
||||
app.middleware("http")(conditional_json_get_middleware)
|
||||
app.middleware("http")(temporal_data_context_middleware)
|
||||
|
||||
origins = [item.strip() for item in cors_origins if item.strip()]
|
||||
if origins:
|
||||
|
||||
@@ -139,6 +139,64 @@ 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()
|
||||
|
||||
def append_context(context_id: str, topic) -> None:
|
||||
if not context_id or context_id in seen:
|
||||
return
|
||||
seen.add(context_id)
|
||||
contexts.append(
|
||||
{
|
||||
"id": context_id,
|
||||
"topic_id": topic.id,
|
||||
"title": topic.title,
|
||||
"documentation_types": list(topic.documentation_types),
|
||||
}
|
||||
)
|
||||
|
||||
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()
|
||||
append_context(context_id, topic)
|
||||
|
||||
frontend = manifest.frontend
|
||||
if frontend is None or not manifest.documentation:
|
||||
return contexts
|
||||
|
||||
ordered_topics = sorted(manifest.documentation, key=lambda item: (item.order, item.id))
|
||||
|
||||
def baseline_topic(documentation_type: str):
|
||||
return next(
|
||||
(
|
||||
topic
|
||||
for topic in ordered_topics
|
||||
if documentation_type in topic.documentation_types
|
||||
),
|
||||
ordered_topics[0],
|
||||
)
|
||||
|
||||
user_topic = baseline_topic("user")
|
||||
admin_topic = baseline_topic("admin")
|
||||
for route in frontend.routes:
|
||||
if route.surface_id:
|
||||
append_context(route.surface_id, user_topic)
|
||||
for route in frontend.settings_routes:
|
||||
if route.surface_id:
|
||||
append_context(route.surface_id, admin_topic)
|
||||
for item in frontend.nav_items:
|
||||
if item.surface_id:
|
||||
append_context(item.surface_id, user_topic)
|
||||
for surface in frontend.view_surfaces:
|
||||
topic = admin_topic if ".admin." in surface.id else user_topic
|
||||
append_context(surface.id, topic)
|
||||
return contexts
|
||||
|
||||
|
||||
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
@@ -230,6 +288,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
if manifest.architecture is not None
|
||||
else None
|
||||
),
|
||||
"information_governance": manifest.information_governance.to_dict(),
|
||||
"external_providers": [
|
||||
declaration.to_dict()
|
||||
for declaration in manifest.external_providers
|
||||
@@ -240,6 +299,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 +334,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"help_contexts": _documentation_help_contexts(manifest),
|
||||
"frontend": _public_frontend_payload(manifest.frontend),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from govoplan_core.core.temporal import (
|
||||
RECORDED_AT_HEADER,
|
||||
TEMPORAL_EVALUATED_AT_HEADER,
|
||||
TEMPORAL_VARY_HEADERS,
|
||||
VALIDITY_MODE_HEADER,
|
||||
VALID_AT_HEADER,
|
||||
TemporalContextError,
|
||||
TemporalDataContext,
|
||||
bind_temporal_data_context,
|
||||
current_temporal_data_context,
|
||||
parse_temporal_data_context,
|
||||
reset_temporal_data_context,
|
||||
)
|
||||
|
||||
|
||||
async def temporal_data_context_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
try:
|
||||
context = parse_temporal_data_context(
|
||||
validity_mode=request.headers.get(VALIDITY_MODE_HEADER),
|
||||
valid_at=request.headers.get(VALID_AT_HEADER),
|
||||
recorded_at=request.headers.get(RECORDED_AT_HEADER),
|
||||
evaluated_at=datetime.now(UTC),
|
||||
)
|
||||
except TemporalContextError as exc:
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
request.state.govoplan_temporal_data_context = context
|
||||
token = bind_temporal_data_context(context)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
reset_temporal_data_context(token)
|
||||
|
||||
response.headers[VALIDITY_MODE_HEADER] = context.validity_mode
|
||||
response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp(
|
||||
context.evaluated_at
|
||||
)
|
||||
if context.valid_at is not None:
|
||||
response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at)
|
||||
if context.recorded_at is not None:
|
||||
response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at)
|
||||
_merge_vary(response, TEMPORAL_VARY_HEADERS)
|
||||
return response
|
||||
|
||||
|
||||
def get_temporal_data_context(request: Request) -> TemporalDataContext:
|
||||
context = getattr(request.state, "govoplan_temporal_data_context", None)
|
||||
return context if isinstance(context, TemporalDataContext) else current_temporal_data_context()
|
||||
|
||||
|
||||
def _merge_vary(response: Response, names: tuple[str, ...]) -> None:
|
||||
current = {
|
||||
item.strip().lower(): item.strip()
|
||||
for item in response.headers.get("Vary", "").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
for name in names:
|
||||
current.setdefault(name.lower(), name)
|
||||
response.headers["Vary"] = ", ".join(current.values())
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> str:
|
||||
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"]
|
||||
@@ -33,7 +33,7 @@ class Tenant:
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -184,6 +184,12 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
system_settings_count = connection.execute(
|
||||
text("SELECT COUNT(*) FROM core_system_settings WHERE id = 'global'"),
|
||||
).scalar_one()
|
||||
default_locale = connection.execute(
|
||||
text(
|
||||
"SELECT default_locale FROM core_system_settings "
|
||||
"WHERE id = 'global'"
|
||||
),
|
||||
).scalar_one()
|
||||
current = database_migration_heads(connection)
|
||||
|
||||
self.assertEqual(
|
||||
@@ -204,6 +210,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
self.assertIn("file_assets", tables)
|
||||
self.assertIn("mail_server_profiles", tables)
|
||||
self.assertEqual(system_settings_count, 1)
|
||||
self.assertEqual(default_locale, "de")
|
||||
self.assertEqual(
|
||||
role_flags,
|
||||
{"system_owner": True, "system_admin": False, "system_auditor": False},
|
||||
@@ -211,6 +218,42 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_german_reference_migration_preserves_explicit_english(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-locale-migration-test-") as directory:
|
||||
database = Path(directory) / "locale.db"
|
||||
url = f"sqlite:///{database}"
|
||||
config = alembic_config(database_url=url, enabled_modules=())
|
||||
command.upgrade(config, "f25c9d3e7a01")
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE core_system_settings "
|
||||
"SET default_locale = 'en', "
|
||||
"updated_at = '2040-01-01 00:00:00' "
|
||||
"WHERE id = 'global'"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(config, "heads")
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
default_locale = connection.execute(
|
||||
text(
|
||||
"SELECT default_locale FROM core_system_settings "
|
||||
"WHERE id = 'global'"
|
||||
)
|
||||
).scalar_one()
|
||||
self.assertEqual(default_locale, "en")
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_default_module_baselines_apply_to_fresh_database(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-default-baseline-test-") as directory:
|
||||
database = Path(directory) / "default.db"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.i18n import (
|
||||
REFERENCE_LANGUAGE_CODE,
|
||||
normalize_enabled_language_codes,
|
||||
normalize_language_packages,
|
||||
preferred_language_code,
|
||||
system_i18n_payload,
|
||||
)
|
||||
|
||||
|
||||
class I18nReferenceLanguageTests(unittest.TestCase):
|
||||
def test_german_is_the_new_installation_reference_default(self) -> None:
|
||||
packages = normalize_language_packages()
|
||||
|
||||
self.assertEqual("de", REFERENCE_LANGUAGE_CODE)
|
||||
self.assertEqual(["de", "en"], [item["code"] for item in packages])
|
||||
self.assertEqual(
|
||||
["de", "en"],
|
||||
normalize_enabled_language_codes(None, packages),
|
||||
)
|
||||
self.assertEqual("de", system_i18n_payload(None)["default_language"])
|
||||
self.assertEqual("de", preferred_language_code(None, ()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.information_governance import (
|
||||
InformationGovernanceDeclarationError,
|
||||
InformationGovernanceDimension,
|
||||
ModuleInformationGovernance,
|
||||
information_governance_from_mapping,
|
||||
information_governance_maturity_issues,
|
||||
)
|
||||
|
||||
|
||||
class InformationGovernanceTests(unittest.TestCase):
|
||||
def test_default_applies_contract_without_claiming_enforcement(self) -> None:
|
||||
declaration = ModuleInformationGovernance()
|
||||
|
||||
self.assertTrue(declaration.current_authorization_for_historical_reads)
|
||||
self.assertEqual(
|
||||
{"contract_only"},
|
||||
{item.adoption for item in declaration.dimensions.values()},
|
||||
)
|
||||
self.assertEqual("1", declaration.to_dict()["contract_version"])
|
||||
|
||||
def test_enforced_adoption_requires_evidence(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
InformationGovernanceDeclarationError,
|
||||
"requires evidence",
|
||||
):
|
||||
InformationGovernanceDimension(adoption="enforced")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
InformationGovernanceDeclarationError,
|
||||
"covered object types",
|
||||
):
|
||||
InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
evidence=("tests/test_information_governance.py",),
|
||||
)
|
||||
|
||||
def test_catalog_shape_round_trips(self) -> None:
|
||||
declaration = ModuleInformationGovernance()
|
||||
|
||||
self.assertEqual(
|
||||
declaration,
|
||||
information_governance_from_mapping(declaration.to_dict()),
|
||||
)
|
||||
|
||||
def test_reference_ready_rejects_unproved_dimensions(self) -> None:
|
||||
issues = information_governance_maturity_issues(
|
||||
ModuleInformationGovernance(),
|
||||
maturity="reference_ready",
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(issues))
|
||||
self.assertIn("temporal_browsing", issues[0])
|
||||
self.assertIn("purpose_aware_access", issues[0])
|
||||
self.assertEqual(
|
||||
(),
|
||||
information_governance_maturity_issues(
|
||||
ModuleInformationGovernance(),
|
||||
maturity="vertical_slice",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -106,7 +106,8 @@ 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.information_governance import ModuleInformationGovernance
|
||||
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
|
||||
@@ -119,7 +120,8 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import scope_grants
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.platform import _documentation_help_contexts, create_platform_router
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import RouteCollisionError
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
@@ -538,6 +540,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 +583,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(
|
||||
@@ -579,6 +599,70 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
[item["path"] for item in public_module["frontend"]["public_routes"]],
|
||||
)
|
||||
|
||||
def test_declared_surfaces_receive_static_documentation_fallbacks(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.user",
|
||||
title="Example user guide",
|
||||
summary="User guide.",
|
||||
documentation_types=("user",),
|
||||
order=20,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="example.admin",
|
||||
title="Example administrator guide",
|
||||
summary="Administrator guide.",
|
||||
documentation_types=("admin",),
|
||||
order=10,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/example",
|
||||
component="ExamplePage",
|
||||
surface_id="example.workspace",
|
||||
),
|
||||
),
|
||||
settings_routes=(
|
||||
FrontendRoute(
|
||||
path="/admin?section=example",
|
||||
component="ExampleAdmin",
|
||||
surface_id="example.admin.settings",
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="example.workspace",
|
||||
module_id="example",
|
||||
kind="route",
|
||||
label="Example workspace",
|
||||
),
|
||||
ViewSurface(
|
||||
id="example.admin.settings",
|
||||
module_id="example",
|
||||
kind="section",
|
||||
label="Example settings",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
contexts = {
|
||||
item["id"]: item for item in _documentation_help_contexts(manifest)
|
||||
}
|
||||
|
||||
self.assertEqual("example.user", contexts["example.workspace"]["topic_id"])
|
||||
self.assertEqual(
|
||||
"example.admin",
|
||||
contexts["example.admin.settings"]["topic_id"],
|
||||
)
|
||||
|
||||
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for module_id in ("first", "second"):
|
||||
@@ -3282,6 +3366,7 @@ finally:
|
||||
"recovery_notes": "Restore rehearsal completed for the release candidate.",
|
||||
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
|
||||
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
|
||||
"information_governance": ModuleInformationGovernance().to_dict(),
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-files==0.1.4",
|
||||
@@ -3327,6 +3412,12 @@ finally:
|
||||
[{"name": "access.directory", "optional": True, "version_min": "1.0.0"}],
|
||||
catalog[0]["requires_interfaces"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"contract_only",
|
||||
catalog[0]["information_governance"]["dimensions"]["retention"][
|
||||
"adoption"
|
||||
],
|
||||
)
|
||||
self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
|
||||
|
||||
validation = validate_module_package_catalog(catalog_path)
|
||||
@@ -3591,8 +3682,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))
|
||||
|
||||
@@ -40,6 +40,22 @@ class _Consumer:
|
||||
self.added.append(queue)
|
||||
|
||||
|
||||
def test_api_runtime_identity_uses_the_installed_distribution_version(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from govoplan_core.server import default_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
default_config,
|
||||
"version",
|
||||
lambda distribution: "0.1.15"
|
||||
if distribution == "govoplan-core"
|
||||
else "unexpected",
|
||||
)
|
||||
|
||||
assert default_config._core_distribution_version() == "0.1.15"
|
||||
|
||||
|
||||
def test_api_runtime_agent_fails_readiness_on_heartbeat_error() -> None:
|
||||
agent = RuntimeNodeAgent(
|
||||
settings=SimpleNamespace(
|
||||
|
||||
@@ -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()
|
||||
@@ -1,12 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.commands.wait_for_database import main
|
||||
from govoplan_core.db.migrations import configured_migration_heads
|
||||
|
||||
|
||||
class WaitForDatabaseTests(unittest.TestCase):
|
||||
def test_configured_heads_resolve_cross_branch_dependencies(self) -> None:
|
||||
scripts = MagicMock()
|
||||
scripts.get_revisions.return_value = (
|
||||
SimpleNamespace(revision="head-b"),
|
||||
SimpleNamespace(revision="head-a"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("govoplan_core.db.migrations.alembic_config"),
|
||||
patch(
|
||||
"govoplan_core.db.migrations.ScriptDirectory.from_config",
|
||||
return_value=scripts,
|
||||
),
|
||||
):
|
||||
heads = configured_migration_heads("sqlite://")
|
||||
|
||||
self.assertEqual(("head-a", "head-b"), heads)
|
||||
scripts.get_revisions.assert_called_once_with("heads")
|
||||
scripts.get_heads.assert_not_called()
|
||||
|
||||
def test_waits_until_exact_configured_heads_are_visible(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
|
||||
Generated
+76
-76
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
@@ -73,12 +73,12 @@
|
||||
},
|
||||
"../../govoplan-access/webui": {
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -92,9 +92,9 @@
|
||||
},
|
||||
"../../govoplan-addresses/webui": {
|
||||
"name": "@govoplan/addresses-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -108,12 +108,12 @@
|
||||
},
|
||||
"../../govoplan-admin/webui": {
|
||||
"name": "@govoplan/admin-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -127,9 +127,9 @@
|
||||
},
|
||||
"../../govoplan-approvals/webui": {
|
||||
"name": "@govoplan/approvals-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -142,9 +142,9 @@
|
||||
},
|
||||
"../../govoplan-audit/webui": {
|
||||
"name": "@govoplan/audit-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -158,9 +158,9 @@
|
||||
},
|
||||
"../../govoplan-calendar/webui": {
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"../../govoplan-campaign/webui": {
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -185,7 +185,7 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -199,9 +199,9 @@
|
||||
},
|
||||
"../../govoplan-cases/webui": {
|
||||
"name": "@govoplan/cases-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -215,9 +215,9 @@
|
||||
},
|
||||
"../../govoplan-committee/webui": {
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -231,9 +231,9 @@
|
||||
},
|
||||
"../../govoplan-dashboard/webui": {
|
||||
"name": "@govoplan/dashboard-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -247,9 +247,9 @@
|
||||
},
|
||||
"../../govoplan-dataflow/webui": {
|
||||
"name": "@govoplan/dataflow-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -265,9 +265,9 @@
|
||||
},
|
||||
"../../govoplan-datasources/webui": {
|
||||
"name": "@govoplan/datasources-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -282,9 +282,9 @@
|
||||
},
|
||||
"../../govoplan-dist-lists/webui": {
|
||||
"name": "@govoplan/dist-lists-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -299,9 +299,9 @@
|
||||
},
|
||||
"../../govoplan-docs/webui": {
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -318,9 +318,9 @@
|
||||
},
|
||||
"../../govoplan-encryption/webui": {
|
||||
"name": "@govoplan/encryption-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -333,9 +333,9 @@
|
||||
},
|
||||
"../../govoplan-files/webui": {
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -352,9 +352,9 @@
|
||||
},
|
||||
"../../govoplan-forms-runtime/webui": {
|
||||
"name": "@govoplan/forms-runtime-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -368,9 +368,9 @@
|
||||
},
|
||||
"../../govoplan-forms/webui": {
|
||||
"name": "@govoplan/forms-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -383,9 +383,9 @@
|
||||
},
|
||||
"../../govoplan-identity-trust/webui": {
|
||||
"name": "@govoplan/identity-trust-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -398,9 +398,9 @@
|
||||
},
|
||||
"../../govoplan-idm/webui": {
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -417,12 +417,12 @@
|
||||
},
|
||||
"../../govoplan-mail/webui": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -436,9 +436,9 @@
|
||||
},
|
||||
"../../govoplan-notifications/webui": {
|
||||
"name": "@govoplan/notifications-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -455,9 +455,9 @@
|
||||
},
|
||||
"../../govoplan-ops/webui": {
|
||||
"name": "@govoplan/ops-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -474,9 +474,9 @@
|
||||
},
|
||||
"../../govoplan-organizations/webui": {
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -493,9 +493,9 @@
|
||||
},
|
||||
"../../govoplan-policy/webui": {
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -509,9 +509,9 @@
|
||||
},
|
||||
"../../govoplan-portal/webui": {
|
||||
"name": "@govoplan/portal-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -525,9 +525,9 @@
|
||||
},
|
||||
"../../govoplan-postbox/webui": {
|
||||
"name": "@govoplan/postbox-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -541,9 +541,9 @@
|
||||
},
|
||||
"../../govoplan-projects/webui": {
|
||||
"name": "@govoplan/projects-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -557,9 +557,9 @@
|
||||
},
|
||||
"../../govoplan-reporting/webui": {
|
||||
"name": "@govoplan/reporting-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -573,9 +573,9 @@
|
||||
},
|
||||
"../../govoplan-risk-compliance/webui": {
|
||||
"name": "@govoplan/risk-compliance-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -589,9 +589,9 @@
|
||||
},
|
||||
"../../govoplan-scheduling/webui": {
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -608,9 +608,9 @@
|
||||
},
|
||||
"../../govoplan-search/webui": {
|
||||
"name": "@govoplan/search-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -624,9 +624,9 @@
|
||||
},
|
||||
"../../govoplan-templates/webui": {
|
||||
"name": "@govoplan/templates-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -641,9 +641,9 @@
|
||||
},
|
||||
"../../govoplan-tenancy/webui": {
|
||||
"name": "@govoplan/tenancy-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -656,9 +656,9 @@
|
||||
},
|
||||
"../../govoplan-views/webui": {
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -672,9 +672,9 @@
|
||||
},
|
||||
"../../govoplan-voting/webui": {
|
||||
"name": "@govoplan/voting-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
@@ -687,9 +687,9 @@
|
||||
},
|
||||
"../../govoplan-workflow/webui": {
|
||||
"name": "@govoplan/workflow-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.15",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.15",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.15",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.15",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.15",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.15",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.15",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.15",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.15",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.15",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.15",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.15",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.15",
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.16",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.16",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.16",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.16",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.16",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.16",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.16",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.16",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.16",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.16",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.16",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.16",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.16",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
@@ -753,10 +753,10 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@govoplan/access-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#794fcf90f8d759eb140d3b5b0a880482912a79be",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#a7ed28f6a3cf9e3f5f549db9b0e0785910a8c41a",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -769,10 +769,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/admin-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#57e2a34c894e20afa46558b83d167e36a1db26c0",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#3842987a4ed2b550a3e3c6932db52fa09995e5b4",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -785,10 +785,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/audit-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#6f095eb56293fa4bc3f3ca5a52d36491c1713b00",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#4613b54c06863689eade1012632bd2398d15fef1",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -801,10 +801,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/calendar-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#f6faaf196da1891997c5ad7065bbba69aacd018d",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#128c78597edbc6a6fcac844fe1c08b2f9917a872",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -820,13 +820,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/campaign-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#ea0efe661f10474e0a8df0bffde75f3a78577f27",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#2db99eaf6a36b433093d37f53cef0125f4b98599",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -839,10 +839,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/dashboard-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#c2c7ba8fb2090ea0e6abcb2f3cd46916a9016b1e",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#77924e7a2c8366be39c9305477e085802a578d0b",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -855,10 +855,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/docs-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#1f5050530589da651a49e8d617bf0825e6332388",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#8cbab781bf9126062efc6127c89c7eed308955de",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -874,10 +874,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/files-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#0293e49defd34a958584c9665ba006d22db497e1",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#edee29af51fba34c569defc98a977a0ed80c5718",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -893,10 +893,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/idm-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#bd72a3f277ce4f214906e276ee1553f4fd23364d",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#6e9393c9c7606e91ced908dceef69fd18cf2abe7",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -912,10 +912,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/mail-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#622f63b4b5fd427128b1a6a9df0ecbe357059d69",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#a040de4fe5d9af4a5f434a87d2c5fa8b79e6a818",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -928,10 +928,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/ops-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#ce074044056827f253eaa9bae920136ebdd8ada8",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#02e5d91d565bf7d42cc1b550f18e73621a93a6fa",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -947,10 +947,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/organizations-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#aab7f25e866b9738390e2d3026e784d3ad9c3a54",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#18e4df348a3cf69602b2d2491e6ae28c26551b59",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -966,10 +966,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/policy-webui": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#44d72914aea27a74511fd50f445c450217e3cd5f",
|
||||
"version": "0.1.16",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#107cc3b654b7b97ec441650bcc3bac6a7accc8a0",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -2059,9 +2059,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.400",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz",
|
||||
"integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==",
|
||||
"version": "1.5.401",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.401.tgz",
|
||||
"integrity": "sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
@@ -2330,9 +2330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -26,19 +26,19 @@
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.15",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.15",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.15",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.15",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.15",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.15",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.15",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.15",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.15",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.15",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.15",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.15",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.15",
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.16",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.16",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.16",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.16",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.16",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.16",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.16",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.16",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.16",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.16",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.16",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.16",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.16",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
|
||||
@@ -12,7 +12,11 @@ const credentials = read("src/components/CredentialEnvelopeManager.tsx");
|
||||
const iconRail = read("src/layout/IconRail.tsx");
|
||||
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx");
|
||||
const titlebar = read("src/layout/Titlebar.tsx");
|
||||
const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx");
|
||||
const helpMenu = read("src/layout/HelpMenu.tsx");
|
||||
const helpContext = read("src/utils/helpContext.ts");
|
||||
const layoutStyles = read("src/styles/layout.css");
|
||||
const authGateStyles = read("src/styles/auth-gate.css");
|
||||
|
||||
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
|
||||
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state");
|
||||
@@ -33,10 +37,19 @@ assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1
|
||||
assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed");
|
||||
assert.match(layoutStyles, /\.icon-rail-bottom \{[^}]*flex: 0 0 auto;/, "the rail utility controls remain fixed");
|
||||
|
||||
assert.match(titlebar, /className="titlebar-status-pattern"/, "shell state uses a titlebar background pattern");
|
||||
assert.match(titlebar, /className="titlebar-global-search"/, "global search retains its dedicated titlebar grid cell");
|
||||
assert.doesNotMatch(titlebar, /titlebar-status-pattern|TriangleAlert|WifiOff/, "shell state uses the readable text warnings rather than icon or background decoration");
|
||||
assert.doesNotMatch(titlebar, /titlebar-global-search|has-global-search/, "global search no longer reserves a centered titlebar grid cell");
|
||||
assert.match(titlebar, /<GlobalSearch[\s\S]*<LanguageMenu \/>[\s\S]*<ViewSelector[\s\S]*<TemporalDataMenu \/>/, "search is the first command before language, View, and temporal selectors");
|
||||
assert.match(titlebar, /className="account-pill"[\s\S]*aria-label=\{displayUserName\}[\s\S]*aria-haspopup="menu"/, "the compact account menu retains an accessible name and menu state");
|
||||
assert.doesNotMatch(layoutStyles, /\.maintenance-topbar-link[^}]*position: absolute;/, "maintenance state does not occupy the centered search position");
|
||||
assert.match(temporalDataMenu, /Calendar, CalendarOff/, "the temporal selector distinguishes bounded and all-validity modes");
|
||||
assert.match(temporalDataMenu, /useUnsavedChanges[\s\S]*requestNavigation/, "changing temporal context cannot silently discard a dirty page");
|
||||
assert.match(temporalDataMenu, /recordedAt/, "valid time and recorded time remain independently selectable");
|
||||
assert.match(layoutStyles, /\.maintenance-topbar-link,[\s\S]*\.backend-offline-topbar-alert \{[^}]*top: 50%;[^}]*transform: translate\(-50%, -50%\);/, "maintenance and offline warnings use their established centered titlebar placement");
|
||||
assert.match(authGateStyles, /\.titlebar-link,[\s\S]*\.account-pill \{[^}]*margin: 0;/, "text and account titlebar controls use the same action spacing as icon controls");
|
||||
assert.match(helpMenu, /helpContextForTarget\(routeHelpContext, event\.target, modules\)/, "F1 resolves the currently focused interface item");
|
||||
assert.match(helpContext, /function moduleRouteContext/, "context help covers contributed module routes");
|
||||
assert.match(helpContext, /function sectionContext/, "context help covers contributed administration and settings sections");
|
||||
assert.match(helpContext, /data-help-context-id/, "context help honors explicit control metadata");
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S]*grid-template-rows: 104px 51px minmax\(0, 1fr\);/, "the narrow shell reserves two non-overlapping titlebar rows");
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions");
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text");
|
||||
|
||||
+22
-3
@@ -11,6 +11,8 @@ import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PlatformViewProvider } from "./platform/ViewContext";
|
||||
import { PlatformTemporalProvider } from "./platform/TemporalContext";
|
||||
import { PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT } from "./platform/temporal";
|
||||
import {
|
||||
PLATFORM_VIEW_CHANGED_EVENT,
|
||||
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
|
||||
@@ -53,6 +55,7 @@ export default function App() {
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
const [baseViewProjection, setBaseViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||
const [workflowViewProjection, setWorkflowViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||
const [temporalRevision, setTemporalRevision] = useState(0);
|
||||
const viewProjection = workflowViewProjection ?? baseViewProjection;
|
||||
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
@@ -71,6 +74,20 @@ export default function App() {
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
useEffect(() => {
|
||||
function reloadTemporalData() {
|
||||
setTemporalRevision((current) => current + 1);
|
||||
}
|
||||
window.addEventListener(
|
||||
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
|
||||
reloadTemporalData
|
||||
);
|
||||
return () => window.removeEventListener(
|
||||
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
|
||||
reloadTemporalData
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth || !viewsRuntime) {
|
||||
setBaseViewProjection(null);
|
||||
@@ -518,12 +535,13 @@ export default function App() {
|
||||
onLanguageChange={persistLanguagePreference}
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformTemporalProvider scopeKey={`${auth.user?.id ?? "account"}:${(auth.active_tenant ?? auth.tenant).id}`}>
|
||||
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
|
||||
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<ModuleLoadBoundary resetKey={location.pathname} loading={webModulesLoading}>
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
|
||||
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||
{publicRoutes.map((route) =>
|
||||
@@ -567,6 +585,7 @@ export default function App() {
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</DocumentationHelpProvider>
|
||||
</PlatformTemporalProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
@@ -625,7 +644,7 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
||||
principal,
|
||||
available_languages: response.available_languages,
|
||||
enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
|
||||
default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" : undefined,
|
||||
default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "de" : undefined,
|
||||
profile_loaded: profileLoaded,
|
||||
roles_loaded: rolesLoaded,
|
||||
groups_loaded: groupsLoaded
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import { temporalRequestHeaders } from "../platform/temporal";
|
||||
|
||||
const STORAGE_KEY = "govoplan.apiSettings";
|
||||
const LEGACY_STORAGE_KEYS: string[] = [];
|
||||
@@ -341,6 +342,9 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
for (const [key, value] of authHeaders(settings)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
|
||||
if (!headers.has(key)) headers.set(key, value);
|
||||
}
|
||||
|
||||
const csrf = csrfToken();
|
||||
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
|
||||
@@ -433,7 +437,11 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
|
||||
|
||||
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
|
||||
const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" });
|
||||
const headers = authHeaders(settings);
|
||||
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
const response = await fetch(apiUrl(settings, path), { headers, credentials: "include" });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
|
||||
@@ -7,7 +7,20 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInte
|
||||
disabledReason?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) {
|
||||
const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpContextId, helpTopicId, children, ...props }: ButtonProps) {
|
||||
const button = (
|
||||
<button
|
||||
data-help-scope="action"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={typeof children === "string" ? children : undefined}
|
||||
className={`btn btn-${variant} ${className}`}
|
||||
disabled={disabled || Boolean(disabledReason)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
type CardProps = {
|
||||
type CardProps = PlatformInterfaceIdentityProps & {
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
@@ -38,7 +39,7 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
|
||||
// localStorage may be unavailable in private or restricted contexts.
|
||||
}}
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) {
|
||||
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true, interfaceId, helpContextId, helpTopicId }: CardProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
|
||||
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
|
||||
@@ -59,7 +60,14 @@ export default function Card({ title, children, actions, collapsible = false, co
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
|
||||
<section
|
||||
className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}
|
||||
data-help-scope="interface"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={typeof title === "string" ? title : undefined}
|
||||
>
|
||||
{hasHeader &&
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
@@ -85,4 +93,4 @@ export default function Card({ title, children, actions, collapsible = false, co
|
||||
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
|
||||
</section>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
|
||||
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
||||
}
|
||||
|
||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
|
||||
const selectedDate = parseDate(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||
@@ -94,7 +94,15 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`date-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`date-field ${className}`.trim()}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={props["aria-label"] ?? placeholder}
|
||||
>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
@@ -146,7 +154,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
|
||||
|
||||
}
|
||||
|
||||
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -159,7 +167,14 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
|
||||
}, [value, min, max]);
|
||||
|
||||
return (
|
||||
<div className={`time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<div
|
||||
className={`time-field ${className}`.trim()}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={props["aria-label"] ?? placeholder}
|
||||
>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
@@ -175,7 +190,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
|
||||
|
||||
}
|
||||
|
||||
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
|
||||
const parts = datePartsFromDateTime(value);
|
||||
const minParts = datePartsFromDateTime(min || "");
|
||||
const maxParts = datePartsFromDateTime(max || "");
|
||||
@@ -189,7 +204,14 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`date-time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<div
|
||||
className={`date-time-field ${className}`.trim()}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={props["aria-label"]}
|
||||
>
|
||||
<DateField
|
||||
{...props}
|
||||
value={parts.date}
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
registerDialog,
|
||||
type DialogStackId
|
||||
} from "./dialogStack";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
export type DialogProps = {
|
||||
export type DialogProps = PlatformInterfaceIdentityProps & {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -56,7 +57,10 @@ export default function Dialog({
|
||||
footerClassName = "",
|
||||
portal = false,
|
||||
panelStyle,
|
||||
backdropStyle
|
||||
backdropStyle,
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const canClose = Boolean(onClose) && !closeDisabled;
|
||||
@@ -134,6 +138,11 @@ export default function Dialog({
|
||||
role={role}
|
||||
aria-modal="true"
|
||||
data-dialog-stack-state="topmost"
|
||||
data-help-scope="dialog"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={typeof title === "string" ? title : undefined}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
|
||||
@@ -12,11 +12,19 @@ type FormFieldProps = PlatformInterfaceIdentityProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function FormField({ label, help, documentation, children, interfaceId, helpTopicId }: FormFieldProps) {
|
||||
export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpTopicId }: FormFieldProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
return (
|
||||
<label className="form-field" data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<label
|
||||
className="form-field"
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId ?? documentation?.contextId}
|
||||
data-help-topic-id={helpTopicId ?? documentation?.topicId}
|
||||
data-help-documentation-type={documentation?.documentationType}
|
||||
data-help-key={typeof label === "string" ? label : undefined}
|
||||
>
|
||||
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
|
||||
{children}
|
||||
</label>
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function SearchableSelect({
|
||||
debounceMs = 200,
|
||||
className = "",
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: SearchableSelectProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
@@ -297,8 +298,11 @@ export default function SearchableSelect({
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={rootClassName}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={ariaLabel}
|
||||
onBlur={closeOnFocusLeave}
|
||||
>
|
||||
<div className="searchable-select-control">
|
||||
|
||||
@@ -14,7 +14,7 @@ type ToggleSwitchProps = PlatformInterfaceIdentityProps & {
|
||||
help?: ReactNode;
|
||||
};
|
||||
|
||||
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpTopicId }: ToggleSwitchProps) {
|
||||
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpContextId, helpTopicId }: ToggleSwitchProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
@@ -22,7 +22,14 @@ export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checke
|
||||
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
|
||||
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
|
||||
return (
|
||||
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<label
|
||||
className={`toggle-switch-row ${disabled ? "disabled" : ""}`}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={typeof label === "string" ? label : undefined}
|
||||
>
|
||||
<input
|
||||
className="toggle-switch-input"
|
||||
type="checkbox"
|
||||
|
||||
@@ -3,8 +3,9 @@ import DismissibleAlert from "../DismissibleAlert";
|
||||
import LoadingFrame from "../LoadingFrame";
|
||||
import PageTitle from "../PageTitle";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../../types";
|
||||
|
||||
type Props = {
|
||||
type Props = PlatformInterfaceIdentityProps & {
|
||||
title: string;
|
||||
description: string;
|
||||
loading?: boolean;
|
||||
@@ -25,11 +26,22 @@ export default function AdminPageLayout({
|
||||
success = "",
|
||||
actions,
|
||||
children,
|
||||
className = ""
|
||||
className = "",
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: Props) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<div className={`admin-section-page ${className}`.trim()}>
|
||||
<div
|
||||
className={`admin-section-page ${className}`.trim()}
|
||||
data-help-scope="page"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-documentation-type="admin"
|
||||
data-help-key={title}
|
||||
>
|
||||
<div className="page-heading split workspace-heading admin-page-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
@@ -44,4 +56,4 @@ export default function AdminPageLayout({
|
||||
</LoadingFrame>
|
||||
</div>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function EmailAddressInput({
|
||||
compact = false,
|
||||
showAddButton,
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpTopicId
|
||||
}: EmailAddressInputProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
@@ -202,7 +203,14 @@ export default function EmailAddressInput({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||
<div
|
||||
className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
data-help-key={emailPlaceholder}
|
||||
>
|
||||
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
|
||||
<div className="email-chip-list" aria-live="polite">
|
||||
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
|
||||
|
||||
@@ -3,6 +3,8 @@ export const HOSTED_DOCUMENTATION_URL = "https://govoplan.add-ideas.de/";
|
||||
export type DocumentationHelpReference = {
|
||||
topicId?: string;
|
||||
contextId?: string;
|
||||
fallbackContextId?: string;
|
||||
moduleId?: string;
|
||||
documentationType?: "user" | "admin";
|
||||
anchorId?: string;
|
||||
};
|
||||
@@ -20,6 +22,10 @@ export function documentationHelpHref(
|
||||
});
|
||||
if (topicId) params.set("topic", topicId);
|
||||
else if (contextId) params.set("context", contextId);
|
||||
const fallbackContextId = reference.fallbackContextId?.trim();
|
||||
if (fallbackContextId && fallbackContextId !== contextId) params.set("fallback_context", fallbackContextId);
|
||||
const moduleId = reference.moduleId?.trim();
|
||||
if (moduleId) params.set("module", moduleId);
|
||||
const anchorId = reference.anchorId?.trim();
|
||||
return `${baseUrl}?${params.toString()}${anchorId ? `#${encodeURIComponent(anchorId)}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -32,14 +32,15 @@ type PlatformLanguageProviderProps = {
|
||||
|
||||
const STORAGE_KEY = "govoplan.language";
|
||||
const PRIMARY_LANGUAGE = "en";
|
||||
const REFERENCE_LANGUAGE = "de";
|
||||
const I18N_MESSAGE_SEPARATOR = "::";
|
||||
const TRANSLATABLE_ATTRIBUTES = ["aria-label", "alt", "placeholder", "title"];
|
||||
const SKIP_TRANSLATION_SELECTOR = "script, style, code, pre, textarea, [data-i18n-skip]";
|
||||
const textNodeOriginals = new WeakMap<Text, string>();
|
||||
|
||||
export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [
|
||||
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" },
|
||||
{ code: "de", label: "i18n:govoplan-core.german.da91388c", nativeLabel: "i18n:govoplan-core.language_native_german" }];
|
||||
{ code: "de", label: "i18n:govoplan-core.german.da91388c", nativeLabel: "i18n:govoplan-core.language_native_german" },
|
||||
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }];
|
||||
|
||||
|
||||
export const DEFAULT_TRANSLATIONS: PlatformTranslations = {
|
||||
@@ -186,13 +187,17 @@ function languagesForCodes(languages: PlatformLanguage[], codes?: string[] | nul
|
||||
|
||||
function preferredLanguage(languages: PlatformLanguage[], preferred?: string | null): string {
|
||||
const codes = new Set(languages.map((item) => item.code));
|
||||
const requested = normalizeLanguageCode(preferred ?? storedLanguage() ?? browserLanguage());
|
||||
if (codes.has(requested)) return requested;
|
||||
const primary = primaryLanguageCode(requested);
|
||||
const primaryMatch = languages.find((item) => primaryLanguageCode(item.code) === primary);
|
||||
if (primaryMatch) return primaryMatch.code;
|
||||
const requestedValue = preferred ?? storedLanguage() ?? browserLanguage();
|
||||
if (requestedValue) {
|
||||
const requested = normalizeLanguageCode(requestedValue);
|
||||
if (codes.has(requested)) return requested;
|
||||
const primary = primaryLanguageCode(requested);
|
||||
const primaryMatch = languages.find((item) => primaryLanguageCode(item.code) === primary);
|
||||
if (primaryMatch) return primaryMatch.code;
|
||||
}
|
||||
if (codes.has(REFERENCE_LANGUAGE)) return REFERENCE_LANGUAGE;
|
||||
if (codes.has(PRIMARY_LANGUAGE)) return PRIMARY_LANGUAGE;
|
||||
return languages[0]?.code ?? PRIMARY_LANGUAGE;
|
||||
return languages[0]?.code ?? REFERENCE_LANGUAGE;
|
||||
}
|
||||
|
||||
function primaryLanguageCode(value: string): string {
|
||||
|
||||
@@ -637,7 +637,27 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Enter a valid HTTP, HTTPS, mail or phone link.",
|
||||
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Enter a valid HTTP, HTTPS, CID or raster data image.",
|
||||
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "This HTML uses markup outside the visual editor. Use HTML source mode to preserve it.",
|
||||
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
|
||||
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive",
|
||||
"i18n:govoplan-core.data_state": "Data state",
|
||||
"i18n:govoplan-core.validity": "Validity",
|
||||
"i18n:govoplan-core.current": "Current",
|
||||
"i18n:govoplan-core.at_time": "At time",
|
||||
"i18n:govoplan-core.all": "All",
|
||||
"i18n:govoplan-core.current_data": "Current data",
|
||||
"i18n:govoplan-core.historical_data_state": "Historical data state",
|
||||
"i18n:govoplan-core.historical_recorded_state": "Historical recorded state",
|
||||
"i18n:govoplan-core.all_validity_periods": "All validity periods",
|
||||
"i18n:govoplan-core.valid_at": "Valid at",
|
||||
"i18n:govoplan-core.valid_at_help": "Select when the data was valid in the represented domain.",
|
||||
"i18n:govoplan-core.recorded_state": "Recorded state",
|
||||
"i18n:govoplan-core.recorded_state_help": "Optionally limit results to what the system had recorded by a point in time.",
|
||||
"i18n:govoplan-core.latest_recorded_state": "Latest recorded state",
|
||||
"i18n:govoplan-core.recorded_by_time": "State recorded by a point in time",
|
||||
"i18n:govoplan-core.recorded_by": "Recorded by",
|
||||
"i18n:govoplan-core.temporal_data_explanation": "Validity controls when a fact applies. Recorded state controls what the system knew. Access permissions are always evaluated now.",
|
||||
"i18n:govoplan-core.apply_data_state": "Apply data state",
|
||||
"i18n:govoplan-core.select_valid_date_time": "Select a valid date and time.",
|
||||
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-core.generate_password.bd5bede8": "Passwort generieren",
|
||||
@@ -1275,6 +1295,26 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Geben Sie einen gültigen HTTP-, HTTPS-, E-Mail- oder Telefon-Link ein.",
|
||||
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Geben Sie eine gültige HTTP-, HTTPS-, CID- oder Rasterdaten-Bildadresse ein.",
|
||||
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "Dieses HTML verwendet Markup außerhalb des visuellen Editors. Verwenden Sie den HTML-Quelltextmodus, um es zu erhalten.",
|
||||
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
|
||||
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive",
|
||||
"i18n:govoplan-core.data_state": "Datenstand",
|
||||
"i18n:govoplan-core.validity": "Gültigkeit",
|
||||
"i18n:govoplan-core.current": "Aktuell",
|
||||
"i18n:govoplan-core.at_time": "Zeitpunkt",
|
||||
"i18n:govoplan-core.all": "Alle",
|
||||
"i18n:govoplan-core.current_data": "Aktuell gültige Daten",
|
||||
"i18n:govoplan-core.historical_data_state": "Historischer Datenstand",
|
||||
"i18n:govoplan-core.historical_recorded_state": "Historischer Erfassungsstand",
|
||||
"i18n:govoplan-core.all_validity_periods": "Alle Gültigkeitszeiträume",
|
||||
"i18n:govoplan-core.valid_at": "Gültig am",
|
||||
"i18n:govoplan-core.valid_at_help": "Wählen Sie, wann die Daten im dargestellten Sachverhalt gültig waren.",
|
||||
"i18n:govoplan-core.recorded_state": "Erfassungsstand",
|
||||
"i18n:govoplan-core.recorded_state_help": "Begrenzt die Ergebnisse optional auf den Stand, den das System bis zu einem Zeitpunkt erfasst hatte.",
|
||||
"i18n:govoplan-core.latest_recorded_state": "Neuester Erfassungsstand",
|
||||
"i18n:govoplan-core.recorded_by_time": "Bis zu einem Zeitpunkt erfasster Stand",
|
||||
"i18n:govoplan-core.recorded_by": "Erfasst bis",
|
||||
"i18n:govoplan-core.temporal_data_explanation": "Die Gültigkeit bestimmt, wann ein Sachverhalt gilt. Der Erfassungsstand bestimmt, was das System wusste. Berechtigungen werden immer aktuell geprüft.",
|
||||
"i18n:govoplan-core.apply_data_state": "Datenstand anwenden",
|
||||
"i18n:govoplan-core.select_valid_date_time": "Wählen Sie ein gültiges Datum und eine Uhrzeit.",
|
||||
"i18n:govoplan-core.temporal_selection_invalid": "Der ausgewählte Datenstand ist ungültig."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ export * from "./platform/ModuleContext";
|
||||
export * from "./platform/moduleEvents";
|
||||
export * from "./platform/ViewContext";
|
||||
export * from "./platform/views";
|
||||
export * from "./platform/temporal";
|
||||
export * from "./platform/TemporalContext";
|
||||
export * from "./platform/wizards";
|
||||
|
||||
export * from "./utils/permissions";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
|
||||
import packageInfo from "../../package.json";
|
||||
@@ -7,7 +7,7 @@ import Dialog from "../components/Dialog";
|
||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||
import { helpContextForPathname, helpContextForTarget, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../types";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
@@ -21,8 +21,12 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const helpContext = helpContextForPathname(location.pathname, location.search);
|
||||
const modules = usePlatformModules();
|
||||
const routeHelpContext = useMemo(
|
||||
() => helpContextForPathname(location.pathname, location.search, modules),
|
||||
[location.pathname, location.search, modules]
|
||||
);
|
||||
const [activeHelpContext, setActiveHelpContext] = useState<HelpContext>(routeHelpContext);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
|
||||
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
|
||||
@@ -35,6 +39,7 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
setActiveHelpContext(helpContextForTarget(routeHelpContext, event.target, modules));
|
||||
setOpen(false);
|
||||
setContextOpen(true);
|
||||
}
|
||||
@@ -49,22 +54,28 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
window.removeEventListener("keydown", openContextHelp, true);
|
||||
window.removeEventListener("mousedown", onPointerDown);
|
||||
};
|
||||
}, []);
|
||||
}, [modules, routeHelpContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextOpen) setActiveHelpContext(routeHelpContext);
|
||||
}, [contextOpen, routeHelpContext]);
|
||||
|
||||
function openHelp() {
|
||||
setActiveHelpContext(routeHelpContext);
|
||||
setOpen(false);
|
||||
setContextOpen(true);
|
||||
}
|
||||
|
||||
function openDocs(type: "user" | "admin") {
|
||||
function openDocs(type: "user" | "admin", context: HelpContext = routeHelpContext) {
|
||||
setOpen(false);
|
||||
setContextOpen(false);
|
||||
if (!docsAvailable) {
|
||||
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
||||
window.open(externalDocsUrl(type, context), "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ type });
|
||||
params.set("context", helpContext.id);
|
||||
const contextParams = new URLSearchParams(helpQueryForContext(context));
|
||||
contextParams.forEach((value, key) => params.set(key, value));
|
||||
navigate(`/docs?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -72,6 +83,9 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
<div className="context-menu-wrap" ref={wrapRef}>
|
||||
<button
|
||||
className="titlebar-link"
|
||||
data-help-context-id="core.contextual-help"
|
||||
data-help-module-id="core"
|
||||
data-help-scope="action"
|
||||
onClick={() => setOpen(!open)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "F1") {
|
||||
@@ -90,11 +104,11 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
|
||||
</button>
|
||||
<hr />
|
||||
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
|
||||
<button className="dropdown-item" onClick={() => openDocs("user", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
|
||||
</button>
|
||||
{adminDocsAvailable &&
|
||||
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||
<button className="dropdown-item" onClick={() => openDocs("admin", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||
</button>
|
||||
}
|
||||
@@ -103,14 +117,16 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
|
||||
</div>
|
||||
}
|
||||
{contextOpen && <ContextHelpModal context={helpContext} onOpenDocs={() => openDocs("user")} onClose={() => setContextOpen(false)} />}
|
||||
{contextOpen && <ContextHelpModal context={activeHelpContext} onOpenDocs={() => openDocs(activeHelpContext.documentationType ?? "user", activeHelpContext)} onClose={() => setContextOpen(false)} />}
|
||||
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
|
||||
const params = new URLSearchParams({ type, context: context.id });
|
||||
const params = new URLSearchParams({ type });
|
||||
const contextParams = new URLSearchParams(helpQueryForContext(context));
|
||||
contextParams.forEach((value, key) => params.set(key, value));
|
||||
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -125,13 +141,12 @@ function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContex
|
||||
|
||||
<div className="help-panel-section" data-help-context={context.id}>
|
||||
<h3>{translateText(context.title)}</h3>
|
||||
{context.parentId &&
|
||||
<p className="muted"><strong>{translateText("i18n:govoplan-core.page.fb06270f")}:</strong> {translateText(context.parentTitle ?? context.parentId)}</p>
|
||||
}
|
||||
<p className="mono-small">{translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id}</p>
|
||||
<p className="muted">{translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} <span className="kbd">{helpQueryForContext(context)}</span> {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")}</p>
|
||||
</div>
|
||||
<div className="help-panel-section">
|
||||
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
|
||||
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
|
||||
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText("i18n:govoplan-core.open_user_documentation.084af515")}</Button>
|
||||
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText(context.documentationType === "admin" ? "i18n:govoplan-core.open_admin_documentation.6adbdae3" : "i18n:govoplan-core.open_user_documentation.084af515")}</Button>
|
||||
</div>
|
||||
</Dialog>);
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@ export default function LanguageMenu() {
|
||||
|
||||
return (
|
||||
<div className="context-menu-wrap language-menu-wrap" ref={menuRef}>
|
||||
<button className="titlebar-link language-menu-button" onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open}>
|
||||
<button
|
||||
className="titlebar-link language-menu-button"
|
||||
data-help-context-id="core.titlebar.language"
|
||||
data-help-module-id="core"
|
||||
data-help-scope="action"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="language-menu-code">{language.toUpperCase()}</span>
|
||||
<span className="tenant-caret">▾</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Calendar, CalendarOff } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Button from "../components/Button";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import FormField from "../components/FormField";
|
||||
import SegmentedControl from "../components/SegmentedControl";
|
||||
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { useTemporalDataContext } from "../platform/TemporalContext";
|
||||
import {
|
||||
normalizeTemporalDataSelection,
|
||||
type TemporalDataSelection,
|
||||
type TemporalValidityMode
|
||||
} from "../platform/temporal";
|
||||
|
||||
|
||||
export default function TemporalDataMenu() {
|
||||
const { selection, applySelection, isDefault } = useTemporalDataContext();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<TemporalDataSelection>(selection);
|
||||
const [error, setError] = useState("");
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
|
||||
}
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
return () => window.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDraft(selection);
|
||||
}, [open, selection]);
|
||||
|
||||
function toggleOpen() {
|
||||
if (!open) {
|
||||
setDraft(selection);
|
||||
setError("");
|
||||
}
|
||||
setOpen(!open);
|
||||
}
|
||||
|
||||
function selectValidityMode(validityMode: TemporalValidityMode) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
validityMode,
|
||||
validAt: validityMode === "at"
|
||||
? current.validAt ?? new Date().toISOString()
|
||||
: null
|
||||
}));
|
||||
}
|
||||
|
||||
function selectRecordedMode(mode: "latest" | "at") {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
recordedAt: mode === "at"
|
||||
? current.recordedAt ?? new Date().toISOString()
|
||||
: null
|
||||
}));
|
||||
}
|
||||
|
||||
function apply() {
|
||||
try {
|
||||
const normalized = normalizeTemporalDataSelection(draft);
|
||||
requestNavigation(() => {
|
||||
applySelection(normalized);
|
||||
setOpen(false);
|
||||
setError("");
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error
|
||||
? caught.message
|
||||
: "i18n:govoplan-core.temporal_selection_invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const iconTitle = selection.validityMode === "all"
|
||||
? "i18n:govoplan-core.all_validity_periods"
|
||||
: selection.validityMode === "at"
|
||||
? "i18n:govoplan-core.historical_data_state"
|
||||
: selection.recordedAt
|
||||
? "i18n:govoplan-core.historical_recorded_state"
|
||||
: "i18n:govoplan-core.current_data";
|
||||
const Icon = selection.validityMode === "all" ? CalendarOff : Calendar;
|
||||
const validDateMissing = draft.validityMode === "at" && !draft.validAt;
|
||||
|
||||
return (
|
||||
<div className="context-menu-wrap temporal-data-menu-wrap" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`titlebar-icon-link${isDefault ? "" : " is-context-active"}`}
|
||||
data-help-context-id="core.temporal-data-context"
|
||||
data-help-module-id="core"
|
||||
data-help-scope="action"
|
||||
onClick={toggleOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={translateText(iconTitle)}
|
||||
title={translateText(iconTitle)}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="dropdown-menu temporal-data-menu"
|
||||
role="dialog"
|
||||
aria-label={translateText("i18n:govoplan-core.data_state")}
|
||||
>
|
||||
<div className="temporal-data-menu-heading">
|
||||
<strong>i18n:govoplan-core.data_state</strong>
|
||||
</div>
|
||||
|
||||
<SegmentedControl<TemporalValidityMode>
|
||||
className="temporal-validity-control"
|
||||
value={draft.validityMode}
|
||||
width="fill"
|
||||
size="equal"
|
||||
ariaLabel={translateText("i18n:govoplan-core.validity")}
|
||||
onChange={selectValidityMode}
|
||||
options={[
|
||||
{ id: "current", label: "i18n:govoplan-core.current" },
|
||||
{ id: "at", label: "i18n:govoplan-core.at_time" },
|
||||
{ id: "all", label: "i18n:govoplan-core.all" }
|
||||
]}
|
||||
/>
|
||||
|
||||
{draft.validityMode === "at" && (
|
||||
<FormField
|
||||
label="i18n:govoplan-core.valid_at"
|
||||
help="i18n:govoplan-core.valid_at_help"
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(draft.validAt)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
validAt: fromLocalInput(event.target.value)
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="temporal-recorded-section">
|
||||
<FormField
|
||||
label="i18n:govoplan-core.recorded_state"
|
||||
help="i18n:govoplan-core.recorded_state_help"
|
||||
>
|
||||
<select
|
||||
value={draft.recordedAt ? "at" : "latest"}
|
||||
onChange={(event) => selectRecordedMode(
|
||||
event.target.value === "at" ? "at" : "latest"
|
||||
)}
|
||||
>
|
||||
<option value="latest">i18n:govoplan-core.latest_recorded_state</option>
|
||||
<option value="at">i18n:govoplan-core.recorded_by_time</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{draft.recordedAt && (
|
||||
<FormField label="i18n:govoplan-core.recorded_by">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(draft.recordedAt)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
recordedAt: fromLocalInput(event.target.value)
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="temporal-data-explanation">
|
||||
i18n:govoplan-core.temporal_data_explanation
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" compact resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<div className="temporal-data-menu-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={apply}
|
||||
disabled={validDateMissing}
|
||||
disabledReason={validDateMissing
|
||||
? "i18n:govoplan-core.select_valid_date_time"
|
||||
: undefined}
|
||||
>
|
||||
i18n:govoplan-core.apply_data_state
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toLocalInput(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const instant = new Date(value);
|
||||
if (!Number.isFinite(instant.getTime())) return "";
|
||||
const local = new Date(instant.getTime() - instant.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function fromLocalInput(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const instant = new Date(value);
|
||||
return Number.isFinite(instant.getTime()) ? instant.toISOString() : null;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Bell, Check, LogOut, Settings, TriangleAlert, UserCircle, WifiOff } from "lucide-react";
|
||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import TemporalDataMenu from "./TemporalDataMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
@@ -48,8 +49,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
hasAnyScope(auth, searchRuntime.anyOf)
|
||||
)
|
||||
);
|
||||
const showGlobalSearch = Boolean(auth && GlobalSearch && canUseGlobalSearch);
|
||||
|
||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
const displayUserName = auth?.user?.display_name || auth?.user?.email || translateText("i18n:govoplan-core.sign_in.ada2e9e9");
|
||||
@@ -62,7 +61,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
);
|
||||
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
||||
const showContextSelectors = Boolean(
|
||||
auth && ((activeTenant && showTenantControl) || ViewSelector || ActingContextSelector)
|
||||
auth && ((activeTenant && showTenantControl) || ActingContextSelector)
|
||||
);
|
||||
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
|
||||
const notificationSummary = useSharedNotificationSummary(settings, {
|
||||
@@ -152,34 +151,27 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}${titlebarState ? ` is-${titlebarState}` : ""}`}>
|
||||
{titlebarState &&
|
||||
<div className="titlebar-status-pattern" aria-hidden="true">
|
||||
{Array.from({ length: 10 }, (_, index) => <span key={index}>{titlebarStateLabel}</span>)}
|
||||
<header className="titlebar">
|
||||
{titlebarState === "offline" &&
|
||||
<div
|
||||
className="backend-offline-topbar-alert"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
title={titlebarStateLabel}>
|
||||
{titlebarStateLabel}
|
||||
</div>
|
||||
}
|
||||
{titlebarState === "maintenance" &&
|
||||
<button
|
||||
type="button"
|
||||
className="maintenance-topbar-link"
|
||||
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
|
||||
onClick={openMaintenanceSettings}>
|
||||
{titlebarStateLabel}
|
||||
</button>
|
||||
}
|
||||
|
||||
<div className="titlebar-leading">
|
||||
{titlebarState === "offline" &&
|
||||
<div
|
||||
className="backend-offline-topbar-alert"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={titlebarStateLabel}
|
||||
title={titlebarStateLabel}>
|
||||
<WifiOff size={18} aria-hidden="true" />
|
||||
</div>
|
||||
}
|
||||
{titlebarState === "maintenance" &&
|
||||
<button
|
||||
type="button"
|
||||
className="maintenance-topbar-link"
|
||||
aria-label={translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
|
||||
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
|
||||
onClick={openMaintenanceSettings}>
|
||||
<TriangleAlert size={18} aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
{auth && showContextSelectors &&
|
||||
<div className="titlebar-context-selectors">
|
||||
{activeTenant && showTenantControl &&
|
||||
@@ -187,7 +179,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
|
||||
{canSwitchTenant ?
|
||||
<>
|
||||
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}>
|
||||
<button className="tenant-name-button" data-help-context-id="tenancy.selector" data-help-module-id="tenancy" onClick={() => setTenantOpen(!tenantOpen)}>
|
||||
<strong>{activeTenant.name}</strong>
|
||||
<span className="tenant-caret">▾</span>
|
||||
</button>
|
||||
@@ -216,9 +208,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
{ActingContextSelector &&
|
||||
<ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} />
|
||||
}
|
||||
@@ -226,18 +215,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
}
|
||||
</div>
|
||||
|
||||
{auth && GlobalSearch && canUseGlobalSearch &&
|
||||
<div className="titlebar-global-search">
|
||||
<GlobalSearch settings={settings} auth={auth} />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="titlebar-actions">
|
||||
{auth && GlobalSearch && canUseGlobalSearch &&
|
||||
<GlobalSearch settings={settings} auth={auth} />
|
||||
}
|
||||
<LanguageMenu />
|
||||
{auth && ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
{auth && <TemporalDataMenu />}
|
||||
<HelpMenu auth={auth} />
|
||||
|
||||
{auth && notificationsAvailable &&
|
||||
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
|
||||
<button className="titlebar-icon-link titlebar-notification-button" data-help-context-id="notifications.route.notifications" data-help-module-id="notifications" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
|
||||
<Bell size={18} />
|
||||
{unreadNotificationCount > 0 &&
|
||||
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
|
||||
@@ -250,6 +240,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
<div className="context-menu-wrap" ref={accountRef}>
|
||||
<button
|
||||
className="account-pill"
|
||||
data-help-context-id="access.account-menu"
|
||||
data-help-module-id="access"
|
||||
aria-label={displayUserName}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountOpen}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_TEMPORAL_DATA_SELECTION,
|
||||
dispatchTemporalContextChanged,
|
||||
normalizeTemporalDataSelection,
|
||||
setActiveTemporalDataSelection,
|
||||
temporalDataSelectionIsDefault,
|
||||
temporalDataSelectionKey,
|
||||
type TemporalDataSelection
|
||||
} from "./temporal";
|
||||
|
||||
|
||||
const TEMPORAL_STORAGE_PREFIX = "govoplan.temporal-data";
|
||||
|
||||
type TemporalContextValue = {
|
||||
selection: TemporalDataSelection;
|
||||
applySelection: (selection: TemporalDataSelection) => void;
|
||||
isDefault: boolean;
|
||||
selectionKey: string;
|
||||
};
|
||||
|
||||
const TemporalContext = createContext<TemporalContextValue>({
|
||||
selection: DEFAULT_TEMPORAL_DATA_SELECTION,
|
||||
applySelection: () => undefined,
|
||||
isDefault: true,
|
||||
selectionKey: temporalDataSelectionKey(DEFAULT_TEMPORAL_DATA_SELECTION)
|
||||
});
|
||||
|
||||
export function PlatformTemporalProvider({
|
||||
scopeKey,
|
||||
children
|
||||
}: {
|
||||
scopeKey: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [selection, setSelection] = useState<TemporalDataSelection>(() =>
|
||||
loadSelection(scopeKey)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadSelection(scopeKey);
|
||||
setActiveTemporalDataSelection(stored);
|
||||
setSelection(stored);
|
||||
dispatchTemporalContextChanged(stored, scopeKey);
|
||||
return () => {
|
||||
setActiveTemporalDataSelection(DEFAULT_TEMPORAL_DATA_SELECTION);
|
||||
};
|
||||
}, [scopeKey]);
|
||||
|
||||
const applySelection = useCallback((next: TemporalDataSelection) => {
|
||||
const normalized = normalizeTemporalDataSelection(next);
|
||||
setActiveTemporalDataSelection(normalized);
|
||||
storeSelection(scopeKey, normalized);
|
||||
setSelection(normalized);
|
||||
dispatchTemporalContextChanged(normalized, scopeKey);
|
||||
}, [scopeKey]);
|
||||
|
||||
const value = useMemo<TemporalContextValue>(() => ({
|
||||
selection,
|
||||
applySelection,
|
||||
isDefault: temporalDataSelectionIsDefault(selection),
|
||||
selectionKey: temporalDataSelectionKey(selection)
|
||||
}), [applySelection, selection]);
|
||||
|
||||
return (
|
||||
<TemporalContext.Provider value={value}>
|
||||
{children}
|
||||
</TemporalContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTemporalDataContext(): TemporalContextValue {
|
||||
return useContext(TemporalContext);
|
||||
}
|
||||
|
||||
function storageKey(scopeKey: string): string {
|
||||
return `${TEMPORAL_STORAGE_PREFIX}.${scopeKey}`;
|
||||
}
|
||||
|
||||
function loadSelection(scopeKey: string): TemporalDataSelection {
|
||||
if (typeof sessionStorage === "undefined") {
|
||||
return DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
}
|
||||
const stored = sessionStorage.getItem(storageKey(scopeKey));
|
||||
if (!stored) return DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
try {
|
||||
return normalizeTemporalDataSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
sessionStorage.removeItem(storageKey(scopeKey));
|
||||
return DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
}
|
||||
}
|
||||
|
||||
function storeSelection(
|
||||
scopeKey: string,
|
||||
selection: TemporalDataSelection
|
||||
): void {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
if (temporalDataSelectionIsDefault(selection)) {
|
||||
sessionStorage.removeItem(storageKey(scopeKey));
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(storageKey(scopeKey), JSON.stringify(selection));
|
||||
}
|
||||
@@ -194,6 +194,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
routes: routesWithServerMetadata(module, info),
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
viewSurfaces: mergeViewSurfaces(module, info),
|
||||
helpContexts: info.help_contexts ?? module.helpContexts,
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -218,6 +219,7 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||
dependencies: [],
|
||||
optional_dependencies: [],
|
||||
enabled: true,
|
||||
help_contexts: info.help_contexts,
|
||||
runtime_ui_capabilities: [],
|
||||
nav: [],
|
||||
frontend: {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
export type TemporalValidityMode = "current" | "at" | "all";
|
||||
|
||||
export type TemporalDataSelection = {
|
||||
validityMode: TemporalValidityMode;
|
||||
validAt: string | null;
|
||||
recordedAt: string | null;
|
||||
};
|
||||
|
||||
export type TemporalContextChangedEventDetail = {
|
||||
selection: TemporalDataSelection;
|
||||
scopeKey: string;
|
||||
};
|
||||
|
||||
export const PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT =
|
||||
"govoplan:temporal-context-changed";
|
||||
|
||||
export const DEFAULT_TEMPORAL_DATA_SELECTION: TemporalDataSelection = {
|
||||
validityMode: "current",
|
||||
validAt: null,
|
||||
recordedAt: null
|
||||
};
|
||||
|
||||
let activeSelection = DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
|
||||
export function normalizeTemporalDataSelection(
|
||||
value: Partial<TemporalDataSelection> | null | undefined
|
||||
): TemporalDataSelection {
|
||||
const validityMode = ["current", "at", "all"].includes(
|
||||
String(value?.validityMode)
|
||||
)
|
||||
? value?.validityMode as TemporalValidityMode
|
||||
: "current";
|
||||
const validAt = validityMode === "at"
|
||||
? normalizeTimestamp(value?.validAt)
|
||||
: null;
|
||||
if (validityMode === "at" && !validAt) {
|
||||
throw new Error("i18n:govoplan-core.select_valid_date_time");
|
||||
}
|
||||
return {
|
||||
validityMode,
|
||||
validAt,
|
||||
recordedAt: normalizeTimestamp(value?.recordedAt)
|
||||
};
|
||||
}
|
||||
|
||||
export function temporalDataSelectionIsDefault(
|
||||
value: TemporalDataSelection
|
||||
): boolean {
|
||||
return value.validityMode === "current" && !value.recordedAt;
|
||||
}
|
||||
|
||||
export function temporalDataSelectionKey(value: TemporalDataSelection): string {
|
||||
return [value.validityMode, value.validAt ?? "", value.recordedAt ?? ""].join(":");
|
||||
}
|
||||
|
||||
export function setActiveTemporalDataSelection(
|
||||
value: TemporalDataSelection
|
||||
): void {
|
||||
activeSelection = normalizeTemporalDataSelection(value);
|
||||
}
|
||||
|
||||
export function activeTemporalDataSelection(): TemporalDataSelection {
|
||||
return activeSelection;
|
||||
}
|
||||
|
||||
export function temporalRequestHeaders(): Record<string, string> {
|
||||
const selection = activeTemporalDataSelection();
|
||||
if (temporalDataSelectionIsDefault(selection)) return {};
|
||||
const headers: Record<string, string> = {
|
||||
"X-Govoplan-Validity-Mode": selection.validityMode
|
||||
};
|
||||
if (selection.validAt) headers["X-Govoplan-Valid-At"] = selection.validAt;
|
||||
if (selection.recordedAt) {
|
||||
headers["X-Govoplan-Recorded-At"] = selection.recordedAt;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function dispatchTemporalContextChanged(
|
||||
selection: TemporalDataSelection,
|
||||
scopeKey: string
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<TemporalContextChangedEventDetail>(
|
||||
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
|
||||
{ detail: { selection, scopeKey } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string | null | undefined): string | null {
|
||||
const clean = String(value || "").trim();
|
||||
if (!clean) return null;
|
||||
const instant = new Date(clean);
|
||||
if (!Number.isFinite(instant.getTime())) return null;
|
||||
return instant.toISOString();
|
||||
}
|
||||
@@ -124,7 +124,7 @@
|
||||
.titlebar-link,
|
||||
.account-pill {
|
||||
padding: 8px 10px;
|
||||
margin: -8px -10px;
|
||||
margin: 0;
|
||||
border-radius: 7px;
|
||||
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
|
||||
cursor: pointer;
|
||||
|
||||
+17
-27
@@ -28,17 +28,8 @@
|
||||
.icon-rail.compact { width: 58px; }
|
||||
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
|
||||
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
|
||||
.titlebar.is-maintenance { background: var(--warning-bg); border-bottom-color: var(--warning-border-soft); }
|
||||
.titlebar.is-offline { background: var(--danger-bg); border-bottom-color: var(--danger-border-deep); }
|
||||
.titlebar > :not(.titlebar-status-pattern) { position: relative; z-index: 1; }
|
||||
.titlebar-status-pattern { position: absolute; inset: 0; display: flex; align-items: center; gap: 34px; overflow: hidden; padding: 0 14px; pointer-events: none; white-space: nowrap; }
|
||||
.titlebar-status-pattern span { flex: 0 0 auto; color: var(--warning-text); font-size: 11px; font-weight: 800; opacity: .16; }
|
||||
.titlebar.is-offline .titlebar-status-pattern span { color: var(--danger-text); opacity: .18; }
|
||||
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); }
|
||||
.titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; }
|
||||
.titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; }
|
||||
.titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; }
|
||||
.titlebar.has-global-search .titlebar-actions { grid-column: 3; }
|
||||
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
|
||||
.acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); }
|
||||
.acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; }
|
||||
@@ -53,20 +44,31 @@
|
||||
.titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
|
||||
.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
|
||||
.titlebar-icon-link:hover, .titlebar-link:hover { background: var(--titlebar-hover-bg); color: var(--text-strong); }
|
||||
.titlebar-icon-link.is-context-active { background: var(--accent-hover-bg); color: var(--accent); }
|
||||
.titlebar-icon-link.is-context-active:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
|
||||
.titlebar-notification-button { position: relative; }
|
||||
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
|
||||
.account-pill { color: var(--text); }
|
||||
.maintenance-topbar-link,
|
||||
.backend-offline-topbar-alert { width: 34px; height: 34px; flex: 0 0 auto; box-sizing: border-box; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; padding: 0; font: inherit; box-shadow: 0 1px 2px var(--hover-tint); }
|
||||
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--surface); color: var(--warning-text); cursor: pointer; }
|
||||
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); min-height: 32px; max-width: min(560px, calc(100vw - 24px)); box-sizing: border-box; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; padding: 0 14px; overflow: hidden; font: inherit; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; }
|
||||
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
|
||||
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
||||
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--surface); color: var(--danger-text); }
|
||||
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
|
||||
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
||||
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.language-menu { min-width: 210px; }
|
||||
.language-menu .dropdown-item { justify-content: flex-start; }
|
||||
.language-menu .dropdown-item svg { margin-left: auto; }
|
||||
.language-option-code { width: 34px; color: var(--muted); }
|
||||
.temporal-data-menu { width: min(360px, calc(100vw - 20px)); box-sizing: border-box; display: grid; gap: 12px; padding: 14px; }
|
||||
.temporal-data-menu-heading { display: flex; align-items: center; min-height: 24px; }
|
||||
.temporal-validity-control .segmented-control-option { padding-inline: 10px; }
|
||||
.temporal-recorded-section { display: grid; gap: 10px; padding-top: 12px; border-top: var(--border-line); }
|
||||
.temporal-data-menu .form-field { gap: 5px; }
|
||||
.temporal-data-menu input,
|
||||
.temporal-data-menu select { width: 100%; min-width: 0; box-sizing: border-box; }
|
||||
.temporal-data-explanation { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
|
||||
.temporal-data-menu-actions { display: flex; justify-content: flex-end; }
|
||||
.api-mini { display: flex; gap: 6px; }
|
||||
.api-mini input { width: 155px; height: 30px; border: var(--border-line); border-radius: var(--radius-sm); padding: 0 8px; }
|
||||
.breadcrumb-bar { background: var(--bar); border-bottom: var(--border-line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: var(--shadow-chrome); z-index: 90; }
|
||||
@@ -219,7 +221,6 @@
|
||||
.code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; }
|
||||
@media (max-width: 900px) {
|
||||
.api-mini { display: none; }
|
||||
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) 34px auto; }
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.section-sidebar { display: none; }
|
||||
.wizard-card { grid-template-columns: 1fr; }
|
||||
@@ -344,9 +345,8 @@
|
||||
grid-template-rows: 104px 51px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.titlebar,
|
||||
.titlebar.has-global-search {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
.titlebar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 42px 42px;
|
||||
column-gap: 6px;
|
||||
row-gap: 4px;
|
||||
@@ -367,22 +367,12 @@
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.titlebar-global-search {
|
||||
.titlebar-actions {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.titlebar-actions,
|
||||
.titlebar.has-global-search .titlebar-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.titlebar:not(.has-global-search) .titlebar-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.language-menu-button {
|
||||
min-width: 40px;
|
||||
padding-inline: 4px;
|
||||
|
||||
@@ -337,6 +337,8 @@ export type AdminSectionContribution = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: AdminSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -361,6 +363,8 @@ export type SettingsSectionContribution = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: SettingsSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -374,12 +378,16 @@ export type PlatformRouteContribution = {
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContribution = {
|
||||
path: string;
|
||||
order?: number;
|
||||
helpContextId?: string;
|
||||
helpTopicId?: string;
|
||||
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -388,6 +396,8 @@ export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
export type PlatformInterfaceIdentityProps = {
|
||||
/** Stable control-plane identity; use a module-namespaced value. */
|
||||
interfaceId?: string;
|
||||
/** Stable contextual-help identifier associated with the control. */
|
||||
helpContextId?: string;
|
||||
/** Optional stable documentation/help topic associated with the control. */
|
||||
helpTopicId?: string;
|
||||
};
|
||||
@@ -414,6 +424,7 @@ export type PlatformWebModule = {
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
viewSurfaces?: PlatformViewSurface[];
|
||||
helpContexts?: PlatformDocumentationHelpContext[];
|
||||
};
|
||||
|
||||
export type EffectiveViewOption = {
|
||||
@@ -1040,10 +1051,18 @@ export type PlatformFrontendModuleInfo = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PlatformDocumentationHelpContext = {
|
||||
id: string;
|
||||
topic_id: string;
|
||||
title: string;
|
||||
documentation_types: Array<"user" | "admin">;
|
||||
};
|
||||
|
||||
export type PlatformPublicModuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
help_contexts?: PlatformDocumentationHelpContext[];
|
||||
frontend: Pick<
|
||||
PlatformFrontendModuleInfo,
|
||||
| "module_id"
|
||||
@@ -1090,6 +1109,7 @@ export type PlatformModuleInfo = {
|
||||
dependencies: string[];
|
||||
optional_dependencies: string[];
|
||||
enabled: boolean;
|
||||
help_contexts?: PlatformDocumentationHelpContext[];
|
||||
architecture?: {
|
||||
contract_version: string;
|
||||
layer: string;
|
||||
@@ -1104,6 +1124,16 @@ export type PlatformModuleInfo = {
|
||||
target_tested_providers: string[];
|
||||
documentation: Record<string, string[]>;
|
||||
} | null;
|
||||
information_governance?: {
|
||||
contract_version: string;
|
||||
current_authorization_for_historical_reads: boolean;
|
||||
dimensions: Record<string, {
|
||||
adoption: "not_applicable" | "contract_only" | "partial" | "enforced";
|
||||
object_types: string[];
|
||||
evidence: string[];
|
||||
limitation?: string | null;
|
||||
}>;
|
||||
};
|
||||
external_providers?: Array<{
|
||||
contract_version: string;
|
||||
id: string;
|
||||
|
||||
+385
-45
@@ -1,74 +1,414 @@
|
||||
import type { PlatformWebModule } from "../types";
|
||||
|
||||
export type HelpContextKind = "page" | "field" | "action" | "dialog" | "interface";
|
||||
|
||||
export type HelpContext = {
|
||||
id: string;
|
||||
title: string;
|
||||
route: string;
|
||||
kind: HelpContextKind;
|
||||
moduleId?: string;
|
||||
parentId?: string;
|
||||
parentTitle?: string;
|
||||
documentationTopicId?: string;
|
||||
documentationType?: "user" | "admin";
|
||||
};
|
||||
|
||||
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
|
||||
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
|
||||
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80" },
|
||||
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06" },
|
||||
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
|
||||
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
|
||||
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
"global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
|
||||
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
|
||||
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
|
||||
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
|
||||
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
|
||||
reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
|
||||
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c" },
|
||||
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76" }
|
||||
type ContextDefinition = Omit<HelpContext, "route" | "kind">;
|
||||
|
||||
const campaignSectionContexts: Record<string, ContextDefinition> = {
|
||||
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
|
||||
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
|
||||
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80", moduleId: "campaigns" },
|
||||
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06", moduleId: "campaigns" },
|
||||
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
|
||||
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
|
||||
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24", moduleId: "campaigns" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10", moduleId: "campaigns" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
|
||||
mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
|
||||
"global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849", moduleId: "campaigns" },
|
||||
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849", moduleId: "campaigns" },
|
||||
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
|
||||
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
|
||||
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303", moduleId: "campaigns" },
|
||||
reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303", moduleId: "campaigns" },
|
||||
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c", moduleId: "campaigns" },
|
||||
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76", moduleId: "campaigns" }
|
||||
};
|
||||
|
||||
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
dashboard: { id: "app.dashboard", title: "i18n:govoplan-core.dashboard.d87f47b4" },
|
||||
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
|
||||
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
|
||||
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
|
||||
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" },
|
||||
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
|
||||
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
|
||||
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
|
||||
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
|
||||
const topLevelContexts: Record<string, ContextDefinition> = {
|
||||
dashboard: { id: "dashboard.page", title: "i18n:govoplan-core.dashboard.d87f47b4", moduleId: "dashboard" },
|
||||
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28", moduleId: "campaigns" },
|
||||
templates: { id: "templates.page", title: "i18n:govoplan-core.templates.f25b700e", moduleId: "templates" },
|
||||
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512", moduleId: "files" },
|
||||
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb", moduleId: "mail" },
|
||||
"address-book": { id: "addresses.page", title: "i18n:govoplan-core.address_book.f6327f59", moduleId: "addresses" },
|
||||
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5", moduleId: "core", documentationType: "user" },
|
||||
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc", moduleId: "admin", documentationType: "admin" }
|
||||
};
|
||||
|
||||
export function helpContextForPathname(pathname: string, search = ""): HelpContext {
|
||||
export function helpContextForPathname(
|
||||
pathname: string,
|
||||
search = "",
|
||||
modules: readonly PlatformWebModule[] = []
|
||||
): HelpContext {
|
||||
const route = pathname || "/";
|
||||
const routeWithSearch = `${route}${search}`;
|
||||
const segments = route.split("/").filter(Boolean);
|
||||
|
||||
if (segments[0] === "settings") {
|
||||
const section = new URLSearchParams(search).get("section") || "";
|
||||
if (section === "mail-profiles") {
|
||||
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` };
|
||||
if (segments[0] === "settings" || segments[0] === "admin") {
|
||||
const area = segments[0];
|
||||
const requestedSection = new URLSearchParams(search).get("section");
|
||||
const section = requestedSection || (area === "admin" ? "overview" : "interface");
|
||||
if (area === "settings" && section === "mail-profiles") {
|
||||
return pageContext(
|
||||
{ id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", moduleId: "mail" },
|
||||
routeWithSearch,
|
||||
modules
|
||||
);
|
||||
}
|
||||
const surfaceContext = sectionContext(modules, section, area);
|
||||
if (surfaceContext) return pageContext(surfaceContext, routeWithSearch, modules);
|
||||
const fallback = topLevelContexts[area];
|
||||
return pageContext({
|
||||
...fallback,
|
||||
id: `${fallback.id}.${stableSlug(section)}`,
|
||||
title: humanize(section),
|
||||
documentationType: area === "admin" ? "admin" : "user"
|
||||
}, routeWithSearch, modules);
|
||||
}
|
||||
|
||||
if (segments[0] === "campaigns" && segments[1]) {
|
||||
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
|
||||
if (segments[1] === "queue") {
|
||||
return pageContext({ id: "campaign.operator-queue", title: "Operator queue", moduleId: "campaigns" }, routeWithSearch, modules);
|
||||
}
|
||||
if (segments[1] === "reports") {
|
||||
return pageContext({ id: "campaign.report", title: "i18n:govoplan-core.reports.88bc3fe3", moduleId: "campaigns" }, routeWithSearch, modules);
|
||||
}
|
||||
if (!segments[2]) {
|
||||
return pageContext({ id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", moduleId: "campaigns" }, routeWithSearch, modules);
|
||||
}
|
||||
if (segments[2] === "wizard") {
|
||||
const step = segments[3] || "create";
|
||||
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route };
|
||||
return pageContext({ id: `campaign.wizard.${stableSlug(step)}`, title: `${humanize(step)} wizard`, moduleId: "campaigns" }, routeWithSearch, modules);
|
||||
}
|
||||
const context = campaignSectionContexts[segments[2]];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", route };
|
||||
if (context) return pageContext(context, routeWithSearch, modules);
|
||||
return pageContext({ id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", moduleId: "campaigns" }, routeWithSearch, modules);
|
||||
}
|
||||
|
||||
const context = topLevelContexts[segments[0] || "campaigns"];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "app.general", title: "i18n:govoplan-core.application.b291beb8", route };
|
||||
if (segments.length <= 1) {
|
||||
const context = topLevelContexts[segments[0] || "campaigns"];
|
||||
if (context) return pageContext(context, routeWithSearch, modules);
|
||||
}
|
||||
|
||||
const matchedRoute = moduleRouteContext(route, modules);
|
||||
if (matchedRoute) return pageContext(matchedRoute, routeWithSearch, modules);
|
||||
|
||||
const root = stableSlug(segments[0] || "application");
|
||||
return pageContext({
|
||||
id: root === "application" ? "app.general" : `${root}.page`,
|
||||
title: root === "application" ? "i18n:govoplan-core.application.b291beb8" : humanize(segments[segments.length - 1] || root),
|
||||
moduleId: root === "application" ? "core" : root
|
||||
}, routeWithSearch, modules);
|
||||
}
|
||||
|
||||
export function helpContextForTarget(
|
||||
base: HelpContext,
|
||||
target: EventTarget | null,
|
||||
modules: readonly PlatformWebModule[] = []
|
||||
): HelpContext {
|
||||
if (typeof Element === "undefined" || !(target instanceof Element)) return base;
|
||||
|
||||
const explicit = target.closest<HTMLElement>(
|
||||
"[data-help-context-id], [data-help-context], [data-help-topic-id], [data-interface-id]"
|
||||
);
|
||||
if (explicit) {
|
||||
const contextId = explicit.dataset.helpContextId || explicit.dataset.helpContext;
|
||||
const topicId = explicit.dataset.helpTopicId;
|
||||
const interfaceId = explicit.dataset.interfaceId;
|
||||
const title = helpLabel(explicit) || base.title;
|
||||
const kind = helpKind(explicit, target);
|
||||
if (contextId || topicId || interfaceId) {
|
||||
return withDeclaredDocumentation(childContext(base, {
|
||||
id: contextId || interfaceId || topicId || base.id,
|
||||
title,
|
||||
kind,
|
||||
moduleId: explicit.dataset.helpModuleId || base.moduleId,
|
||||
documentationTopicId: topicId || undefined,
|
||||
documentationType: documentationType(explicit) ?? base.documentationType
|
||||
}), modules);
|
||||
}
|
||||
}
|
||||
|
||||
const field = target.closest<HTMLElement>(
|
||||
".form-field, .toggle-switch-row, .searchable-select, .email-address-input, .date-field, .time-field, .date-time-field"
|
||||
);
|
||||
if (field) {
|
||||
const label = helpLabel(field) || helpLabel(target) || "Field";
|
||||
return withDeclaredDocumentation(derivedChildContext(base, field, label, "field"), modules);
|
||||
}
|
||||
|
||||
const control = target.closest<HTMLElement>(
|
||||
"button, a, input, select, textarea, [role='button'], [role='checkbox'], [role='combobox'], [role='menuitem'], [role='option'], [role='radio'], [role='switch'], [role='tab']"
|
||||
);
|
||||
const dialog = target.closest<HTMLElement>("[role='dialog'], [role='alertdialog'], [data-help-scope='dialog']");
|
||||
if (control) {
|
||||
const isField = control.matches("input, select, textarea, [role='checkbox'], [role='combobox'], [role='radio'], [role='switch']");
|
||||
const label = helpLabel(control) || (isField ? "Field" : "Action");
|
||||
const slug = stableHelpKey(control, label);
|
||||
if (dialog && ["close", "cancel"].includes(slug)) {
|
||||
const dialogLabel = helpLabel(dialog) || "Dialog";
|
||||
return withDeclaredDocumentation(derivedChildContext(base, dialog, dialogLabel, "dialog"), modules);
|
||||
}
|
||||
return withDeclaredDocumentation(derivedChildContext(base, control, label, isField ? "field" : "action"), modules);
|
||||
}
|
||||
|
||||
if (dialog) {
|
||||
return withDeclaredDocumentation(derivedChildContext(base, dialog, helpLabel(dialog) || "Dialog", "dialog"), modules);
|
||||
}
|
||||
|
||||
const contextualRegion = target.closest<HTMLElement>("[data-help-scope], .card, .admin-section-page");
|
||||
if (contextualRegion) {
|
||||
return withDeclaredDocumentation(derivedChildContext(base, contextualRegion, helpLabel(contextualRegion) || base.title, "interface"), modules);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function helpQueryForContext(context: HelpContext): string {
|
||||
return `context=${encodeURIComponent(context.id)}`;
|
||||
const params = new URLSearchParams();
|
||||
if (context.documentationTopicId) params.set("topic", context.documentationTopicId);
|
||||
else params.set("context", context.id);
|
||||
if (context.parentId && context.parentId !== context.id) params.set("fallback_context", context.parentId);
|
||||
if (context.moduleId) params.set("module", context.moduleId);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
function pageContext(
|
||||
definition: ContextDefinition,
|
||||
route: string,
|
||||
modules: readonly PlatformWebModule[]
|
||||
): HelpContext {
|
||||
return withDeclaredDocumentation({ ...definition, route, kind: "page" }, modules);
|
||||
}
|
||||
|
||||
function childContext(
|
||||
base: HelpContext,
|
||||
child: Pick<HelpContext, "id" | "title" | "kind"> & Partial<HelpContext>
|
||||
): HelpContext {
|
||||
return {
|
||||
...child,
|
||||
route: base.route,
|
||||
moduleId: child.moduleId ?? base.moduleId,
|
||||
parentId: child.parentId ?? base.id,
|
||||
parentTitle: child.parentTitle ?? base.title,
|
||||
documentationType: child.documentationType ?? base.documentationType
|
||||
};
|
||||
}
|
||||
|
||||
function withDeclaredDocumentation(
|
||||
context: HelpContext,
|
||||
modules: readonly PlatformWebModule[]
|
||||
): HelpContext {
|
||||
if (context.documentationTopicId) return context;
|
||||
const candidates = context.moduleId
|
||||
? modules.filter((module) => module.id === context.moduleId)
|
||||
: modules;
|
||||
const aliases = [
|
||||
context.id,
|
||||
context.id.replace(".section.", "."),
|
||||
context.id.replace(".route.", ".")
|
||||
];
|
||||
for (const module of candidates) {
|
||||
const match = module.helpContexts?.find((item) => aliases.includes(item.id));
|
||||
if (!match) continue;
|
||||
const documentationType = context.documentationType
|
||||
?? (match.documentation_types.includes("user") ? "user" : match.documentation_types[0]);
|
||||
return {
|
||||
...context,
|
||||
id: match.id,
|
||||
title: context.title || match.title,
|
||||
moduleId: module.id,
|
||||
documentationTopicId: match.topic_id,
|
||||
documentationType
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function derivedChildContext(base: HelpContext, element: HTMLElement, label: string, kind: HelpContextKind): HelpContext {
|
||||
const prefix = contextPrefix(base);
|
||||
const key = stableHelpKey(element, label);
|
||||
return childContext(base, {
|
||||
id: `${prefix}.${kind === "field" ? "field" : kind === "action" ? "action" : kind}.${key}`,
|
||||
title: label,
|
||||
kind,
|
||||
documentationType: documentationType(element) ?? base.documentationType
|
||||
});
|
||||
}
|
||||
|
||||
function contextPrefix(context: HelpContext): string {
|
||||
const idPrefix = context.id.split(".", 1)[0];
|
||||
if (idPrefix && !["app", "admin"].includes(idPrefix)) return idPrefix;
|
||||
return stableSlug(context.moduleId || idPrefix || "app");
|
||||
}
|
||||
|
||||
function sectionContext(
|
||||
modules: readonly PlatformWebModule[],
|
||||
section: string,
|
||||
area: "settings" | "admin"
|
||||
): ContextDefinition | null {
|
||||
const normalizedSection = stableSlug(section);
|
||||
let best: { score: number; definition: ContextDefinition } | null = null;
|
||||
for (const module of modules) {
|
||||
for (const surface of module.viewSurfaces ?? []) {
|
||||
if (surface.kind !== "section") continue;
|
||||
const normalizedId = surface.id.replace(/_/g, "-").toLowerCase();
|
||||
const normalizedLabel = stableSlug(surface.label);
|
||||
let score = 0;
|
||||
if (normalizedId.includes(`.${area}.${normalizedSection}`)) score = 120;
|
||||
else if (normalizedId.includes(`.section.${normalizedSection}`)) score = 110;
|
||||
else if (normalizedId.endsWith(`.${normalizedSection}`)) score = 100;
|
||||
else if (normalizedLabel === normalizedSection) score = 80;
|
||||
if (!score || (best && best.score >= score)) continue;
|
||||
best = {
|
||||
score,
|
||||
definition: {
|
||||
id: surface.id,
|
||||
title: surface.label,
|
||||
moduleId: surface.moduleId || module.id,
|
||||
documentationType: area === "admin" ? "admin" : "user"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return best?.definition ?? null;
|
||||
}
|
||||
|
||||
function moduleRouteContext(pathname: string, modules: readonly PlatformWebModule[]): ContextDefinition | null {
|
||||
let best: { score: number; definition: ContextDefinition } | null = null;
|
||||
for (const module of modules) {
|
||||
for (const route of [...(module.routes ?? []), ...(module.publicRoutes ?? [])]) {
|
||||
const score = routeMatchScore(route.path, pathname);
|
||||
if (score < 0 || (best && best.score >= score)) continue;
|
||||
const surfaceId = "surfaceId" in route ? route.surfaceId : undefined;
|
||||
const surface = surfaceId ? module.viewSurfaces?.find((item) => item.id === surfaceId) : undefined;
|
||||
const navItem = module.navItems?.find((item) => item.to === route.path || item.to === pathname);
|
||||
best = {
|
||||
score,
|
||||
definition: {
|
||||
id: route.helpContextId || surfaceId || `${module.id}.page.${stableSlug(route.path)}`,
|
||||
title: surface?.label || navItem?.label || module.label || humanize(pathname),
|
||||
moduleId: module.id,
|
||||
documentationTopicId: route.helpTopicId
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return best?.definition ?? null;
|
||||
}
|
||||
|
||||
function routeMatchScore(pattern: string, pathname: string): number {
|
||||
const patternSegments = pattern.split("/").filter(Boolean);
|
||||
const pathSegments = pathname.split("/").filter(Boolean);
|
||||
let score = 0;
|
||||
let wildcard = false;
|
||||
for (let index = 0; index < patternSegments.length; index += 1) {
|
||||
const expected = patternSegments[index];
|
||||
if (expected === "*") {
|
||||
wildcard = true;
|
||||
score += 1;
|
||||
break;
|
||||
}
|
||||
const actual = pathSegments[index];
|
||||
if (actual === undefined) return -1;
|
||||
if (expected.startsWith(":")) score += 10;
|
||||
else if (expected === actual) score += 100;
|
||||
else return -1;
|
||||
}
|
||||
if (!wildcard && patternSegments.length !== pathSegments.length) return -1;
|
||||
return score + patternSegments.length;
|
||||
}
|
||||
|
||||
function helpKind(element: HTMLElement, target: Element): HelpContextKind {
|
||||
const explicit = element.dataset.helpScope as HelpContextKind | undefined;
|
||||
if (explicit && ["page", "field", "action", "dialog", "interface"].includes(explicit)) return explicit;
|
||||
if (element.matches(".form-field, .toggle-switch-row, .searchable-select, .email-address-input, .date-field, .time-field, .date-time-field")) return "field";
|
||||
if (target.closest("[role='dialog'], [role='alertdialog']")) return "dialog";
|
||||
return "interface";
|
||||
}
|
||||
|
||||
function helpLabel(element: Element): string {
|
||||
const own = element as HTMLElement;
|
||||
const direct = own.dataset?.helpLabel || own.getAttribute("aria-label") || own.getAttribute("title");
|
||||
if (direct?.trim()) return direct.trim();
|
||||
const labelledBy = own.getAttribute("aria-labelledby");
|
||||
if (labelledBy && typeof document !== "undefined") {
|
||||
const text = labelledBy
|
||||
.split(/\s+/)
|
||||
.map((id) => document.getElementById(id)?.textContent?.trim() || "")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (text) return text;
|
||||
}
|
||||
const fieldLabel = own.querySelector<HTMLElement>(".form-label, .toggle-switch-label");
|
||||
if (fieldLabel?.textContent?.trim()) return fieldLabel.textContent.trim();
|
||||
const dialogTitle = own.querySelector<HTMLElement>(".dialog-title");
|
||||
if (dialogTitle?.textContent?.trim()) return dialogTitle.textContent.trim();
|
||||
const heading = own.querySelector<HTMLElement>("h1, h2, h3");
|
||||
if (heading?.textContent?.trim()) return heading.textContent.trim();
|
||||
const associatedLabel = own.id && typeof document !== "undefined"
|
||||
? document.querySelector<HTMLLabelElement>(`label[for="${cssEscape(own.id)}"]`)
|
||||
: null;
|
||||
if (associatedLabel?.textContent?.trim()) return associatedLabel.textContent.trim();
|
||||
return own.textContent?.replace(/\s+/g, " ").trim().slice(0, 120) || "";
|
||||
}
|
||||
|
||||
function stableHelpKey(element: HTMLElement, label: string): string {
|
||||
const key = element.dataset.helpKey
|
||||
|| element.getAttribute("name")
|
||||
|| stableElementId(element.id)
|
||||
|| element.getAttribute("aria-label")
|
||||
|| element.getAttribute("title")
|
||||
|| label;
|
||||
return stableSlug(key || "item");
|
||||
}
|
||||
|
||||
function stableElementId(value: string): string {
|
||||
if (!value || /(?:^|[-_])(?:r\d+|\d{3,}|[0-9a-f]{8,})(?:$|[-_])/i.test(value)) return "";
|
||||
return value;
|
||||
}
|
||||
|
||||
function documentationType(element: HTMLElement): "user" | "admin" | undefined {
|
||||
return element.dataset.helpDocumentationType === "admin" ? "admin"
|
||||
: element.dataset.helpDocumentationType === "user" ? "user"
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function stableSlug(value: string): string {
|
||||
const i18nMatch = value.match(/^i18n:[^.]+\.([^.]+)(?:\.[0-9a-f]{8})?$/i);
|
||||
const source = i18nMatch?.[1] || value;
|
||||
return source
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.replace(/:[^/]+/g, "item")
|
||||
.replace(/\*/g, "all")
|
||||
.replace(/[_\s/]+/g, "-")
|
||||
.replace(/[^a-zA-Z0-9.-]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase() || "item";
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase()) || "Application";
|
||||
}
|
||||
|
||||
function cssEscape(value: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
|
||||
return value.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ assert(
|
||||
"/docs?type=admin&context=campaign.review-send",
|
||||
"context references retain the requested audience projection"
|
||||
);
|
||||
assert(
|
||||
documentationHelpHref({ contextId: "calendar.field.title", fallbackContextId: "calendar.page", moduleId: "calendar" }) ===
|
||||
"/docs?type=user&context=calendar.field.title&fallback_context=calendar.page&module=calendar",
|
||||
"focused controls retain their page and module documentation fallback"
|
||||
);
|
||||
assert(
|
||||
documentationHelpHref({ topicId: "topic", anchorId: "details" }) ===
|
||||
"/docs?type=user&topic=topic#details",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { helpContextForPathname } from "../src/utils/helpContext";
|
||||
import { helpContextForPathname, helpQueryForContext } from "../src/utils/helpContext";
|
||||
import type { PlatformWebModule } from "../src/types";
|
||||
|
||||
function assertEqual(actual: string, expected: string, message: string): void {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
@@ -16,3 +17,65 @@ assertEqual(
|
||||
"campaign.attachments",
|
||||
"Campaign attachment context"
|
||||
);
|
||||
assertEqual(helpContextForPathname("/address-book").id, "addresses.page", "Address book context");
|
||||
|
||||
const modules: PlatformWebModule[] = [
|
||||
{
|
||||
id: "calendar",
|
||||
label: "Calendar",
|
||||
version: "1",
|
||||
routes: [{ path: "/calendar", surfaceId: "calendar.page", render: () => null }],
|
||||
viewSurfaces: [{ id: "calendar.page", moduleId: "calendar", kind: "route", label: "Calendar" }],
|
||||
helpContexts: [{
|
||||
id: "calendar.page",
|
||||
topic_id: "calendar.manage-calendars-and-events",
|
||||
title: "Use calendars and events",
|
||||
documentation_types: ["user"]
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: "cases",
|
||||
label: "Cases",
|
||||
version: "1",
|
||||
routes: [
|
||||
{ path: "/cases", surfaceId: "cases.list", render: () => null },
|
||||
{ path: "/cases/:caseId", surfaceId: "cases.detail", render: () => null }
|
||||
],
|
||||
viewSurfaces: [
|
||||
{ id: "cases.list", moduleId: "cases", kind: "route", label: "Cases" },
|
||||
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "Case details" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: "Administration",
|
||||
version: "1",
|
||||
viewSurfaces: [
|
||||
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "Modules" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
assertEqual(helpContextForPathname("/calendar", "", modules).id, "calendar.page", "Contributed route context");
|
||||
if (!helpQueryForContext(helpContextForPathname("/calendar", "", modules)).includes("topic=calendar.manage-calendars-and-events")) {
|
||||
throw new Error("Declared route help context did not resolve its documentation topic");
|
||||
}
|
||||
assertEqual(helpContextForPathname("/cases/case-1", "", modules).id, "cases.detail", "Dynamic route context");
|
||||
assertEqual(
|
||||
helpContextForPathname("/admin", "?section=system-modules", modules).id,
|
||||
"admin.section.system-modules",
|
||||
"Contributed admin section context"
|
||||
);
|
||||
assertEqual(helpContextForPathname("/future-module").id, "future-module.page", "Unknown route fallback context");
|
||||
|
||||
const focusedContextQuery = helpQueryForContext({
|
||||
id: "calendar.field.title",
|
||||
title: "Title",
|
||||
route: "/calendar",
|
||||
kind: "field",
|
||||
moduleId: "calendar",
|
||||
parentId: "calendar.page"
|
||||
});
|
||||
if (!focusedContextQuery.includes("context=calendar.field.title")) throw new Error("Focused context query is missing the control context");
|
||||
if (!focusedContextQuery.includes("fallback_context=calendar.page")) throw new Error("Focused context query is missing the page fallback");
|
||||
if (!focusedContextQuery.includes("module=calendar")) throw new Error("Focused context query is missing the owning module");
|
||||
|
||||
Reference in New Issue
Block a user