Compare commits

...
3 Commits
Author SHA1 Message Date
zemion b5a4eb177a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:47 +02:00
zemion f1a5be2a93 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:53:46 +02:00
zemion add7a99f6d feat: add temporal context and contextual help 2026-08-05 00:03:31 +02:00
72 changed files with 3736 additions and 351 deletions
@@ -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
+65
View File
@@ -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.
+6
View File
@@ -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. |
+131
View File
@@ -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.
+3
View File
@@ -8,6 +8,9 @@ The shared contract lives in `govoplan_core.core.institutional`.
Core owns reference shapes and provider protocols only. It does not own shared
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
+1 -1
View File
@@ -13,7 +13,7 @@ domain modules own their compositions.
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| 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
+60
View File
@@ -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.
+9 -2
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
# Temporal Data Context
GovOPlaN exposes one read context for data validity and system knowledge. The
calendar control in the authenticated titlebar applies that context to
supported list and detail reads for the current account and tenant.
## Two Independent Axes
- **Valid time** answers when a fact applied in the represented domain.
- **Recorded time** answers what the system had recorded by a particular
instant.
The default is data valid now under the latest recorded state. `At time`
selects a valid-time instant. `All` removes the valid-time interval filter but
still uses the selected recorded state. The optional recorded-state cutoff can
be combined with any valid-time mode, which keeps correction history distinct
from changes in real-world validity.
An interval is half open: `valid_from <= instant < valid_to`. A revision belongs
to a recorded-state snapshot when `recorded_at <= cutoff` and it was not
superseded at or before that cutoff.
## Security And Mutation Rules
The temporal data context is a read projection, not an authorization context.
Authentication, permissions, active delegations, tenant boundaries, module
policy, and maintenance controls are always evaluated under current security
state. A historical projection never restores an expired permission.
The context also does not supply mutation dates. Writes continue to target the
current lifecycle revision and must carry their explicit valid/effective dates,
expected revision, reason, and evidence where the owning contract requires
them. A screen showing historical data must not silently turn a normal edit
into a historical correction.
## HTTP Contract
Core accepts these request headers:
| Header | Meaning |
| --- | --- |
| `X-Govoplan-Validity-Mode` | `current`, `at`, or `all` |
| `X-Govoplan-Valid-At` | Timezone-aware ISO 8601 instant required by `at` |
| `X-Govoplan-Recorded-At` | Optional timezone-aware system-knowledge cutoff |
Invalid or naive timestamps fail with HTTP 400. Responses expose the resolved
mode and evaluated instant. Conditional JSON responses vary by all three
request headers, and the shared WebUI API client includes them in request
deduplication and conditional-cache keys.
## Module Adoption
Revision-owning modules apply
`govoplan_core.db.temporal.apply_temporal_revision_filter` only to read queries
that are meant to follow the platform context. Explicit version references and
explicit resolver `effective_at` arguments take precedence. Current-row
lookups used for optimistic concurrency, authorization, routing, effects, or
other mutations must remain explicit and context-independent.
The initial bitemporal adoption covers Decisions, Mandates, Parties, and
Services. Their immutable revisions have indexed valid, recorded, and
superseded timestamps. Modules with effective-dated security records or
recorded-only revision histories require separate display-query adoption so
the global selector cannot affect current authorization or execution.
The WebUI selection is stored in session storage per account and tenant. A
change remounts the active module route so existing page loaders issue a fresh
request. Returning both axes to their defaults removes the stored selection.
+3 -1
View File
@@ -51,10 +51,12 @@ contestability, responsibility, and traceability at the point of action.
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-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
+804
View File
@@ -1065,6 +1065,810 @@
"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"
},
{
"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-05T18:33:33Z",
"release": "0.1.17",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.15"
version = "0.1.17"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
+1 -2
View File
@@ -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"]
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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,
+10
View File
@@ -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 = {
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal
TemporalValidityMode = Literal["current", "at", "all"]
VALIDITY_MODES = frozenset({"current", "at", "all"})
VALIDITY_MODE_HEADER = "X-Govoplan-Validity-Mode"
VALID_AT_HEADER = "X-Govoplan-Valid-At"
RECORDED_AT_HEADER = "X-Govoplan-Recorded-At"
TEMPORAL_EVALUATED_AT_HEADER = "X-Govoplan-Temporal-Evaluated-At"
TEMPORAL_VARY_HEADERS = (
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
RECORDED_AT_HEADER,
)
class TemporalContextError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class TemporalDataContext:
"""Bitemporal read context.
Valid time answers when a fact applies. Recorded time answers which version
of that fact was known to the system. Authorization remains outside this
context and is always evaluated under the current security state.
"""
validity_mode: TemporalValidityMode = "current"
valid_at: datetime | None = None
recorded_at: datetime | None = None
evaluated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def __post_init__(self) -> None:
if self.validity_mode not in VALIDITY_MODES:
raise TemporalContextError(
f"Unsupported temporal validity mode: {self.validity_mode!r}."
)
for name in ("valid_at", "recorded_at", "evaluated_at"):
value = getattr(self, name)
if value is not None and value.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
if self.validity_mode == "at" and self.valid_at is None:
raise TemporalContextError("Validity mode 'at' requires valid_at.")
if self.validity_mode != "at" and self.valid_at is not None:
raise TemporalContextError(
"valid_at is only permitted when validity mode is 'at'."
)
@property
def validity_instant(self) -> datetime | None:
if self.validity_mode == "all":
return None
if self.validity_mode == "at":
return self.valid_at
return self.evaluated_at
@property
def is_default(self) -> bool:
return self.validity_mode == "current" and self.recorded_at is None
def to_dict(self) -> dict[str, str | None]:
return {
"validity_mode": self.validity_mode,
"valid_at": _datetime_text(self.valid_at),
"recorded_at": _datetime_text(self.recorded_at),
"evaluated_at": _datetime_text(self.evaluated_at),
}
_temporal_context: ContextVar[TemporalDataContext | None] = ContextVar(
"govoplan_temporal_data_context",
default=None,
)
def parse_temporal_data_context(
*,
validity_mode: str | None = None,
valid_at: str | None = None,
recorded_at: str | None = None,
evaluated_at: datetime | None = None,
) -> TemporalDataContext:
clean_mode = (validity_mode or "current").strip().lower()
if clean_mode not in VALIDITY_MODES:
raise TemporalContextError(
"Temporal validity mode must be one of: current, at, all."
)
return TemporalDataContext(
validity_mode=clean_mode, # type: ignore[arg-type]
valid_at=_parse_datetime(valid_at, "valid_at"),
recorded_at=_parse_datetime(recorded_at, "recorded_at"),
evaluated_at=evaluated_at or datetime.now(UTC),
)
def current_temporal_data_context() -> TemporalDataContext:
return _temporal_context.get() or TemporalDataContext()
def bind_temporal_data_context(
context: TemporalDataContext,
) -> Token[TemporalDataContext | None]:
return _temporal_context.set(context)
def reset_temporal_data_context(token: Token[TemporalDataContext | None]) -> None:
_temporal_context.reset(token)
def temporal_revision_matches(
context: TemporalDataContext,
*,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
revision_recorded_at: datetime | None = None,
superseded_at: datetime | None = None,
) -> bool:
cutoff = context.recorded_at
if cutoff is None:
if superseded_at is not None:
return False
else:
if revision_recorded_at is None or revision_recorded_at > cutoff:
return False
if superseded_at is not None and superseded_at <= cutoff:
return False
instant = context.validity_instant
if instant is None:
return True
return (valid_from is None or valid_from <= instant) and (
valid_to is None or valid_to > instant
)
def _parse_datetime(value: str | None, name: str) -> datetime | None:
clean = str(value or "").strip()
if not clean:
return None
if len(clean) > 64:
raise TemporalContextError(f"Temporal {name} is too long.")
normalized = f"{clean[:-1]}+00:00" if clean.endswith(("Z", "z")) else clean
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise TemporalContextError(
f"Temporal {name} must be an ISO 8601 timestamp."
) from exc
if parsed.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
return parsed.astimezone(UTC)
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = [
"RECORDED_AT_HEADER",
"TEMPORAL_EVALUATED_AT_HEADER",
"TEMPORAL_VARY_HEADERS",
"VALIDITY_MODE_HEADER",
"VALID_AT_HEADER",
"TemporalContextError",
"TemporalDataContext",
"TemporalValidityMode",
"bind_temporal_data_context",
"current_temporal_data_context",
"parse_temporal_data_context",
"reset_temporal_data_context",
"temporal_revision_matches",
]
+1 -1
View File
@@ -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()
+4 -1
View File
@@ -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(
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import or_
from govoplan_core.core.temporal import (
TemporalContextError,
TemporalDataContext,
current_temporal_data_context,
)
def apply_temporal_revision_filter(
query: Any,
model: type[Any],
*,
context: TemporalDataContext | None = None,
valid_from: str | None = "valid_from",
valid_to: str | None = "valid_to",
recorded_at: str | None = "recorded_at",
superseded_at: str | None = "superseded_at",
) -> Any:
"""Apply latest/as-recorded and valid-time clauses to a revision query."""
resolved = context or current_temporal_data_context()
clauses: list[Any] = []
superseded_column = _optional_column(model, superseded_at)
recorded_column = _optional_column(model, recorded_at)
if resolved.recorded_at is None:
if superseded_column is not None:
clauses.append(superseded_column.is_(None))
else:
if recorded_column is None or superseded_column is None:
raise TemporalContextError(
f"{model.__name__} does not expose recorded/superseded revision time."
)
clauses.extend(
(
recorded_column <= resolved.recorded_at,
or_(
superseded_column.is_(None),
superseded_column > resolved.recorded_at,
),
)
)
instant = resolved.validity_instant
if instant is not None:
valid_from_column = _optional_column(model, valid_from)
valid_to_column = _optional_column(model, valid_to)
if valid_from_column is not None:
clauses.append(
or_(valid_from_column.is_(None), valid_from_column <= instant)
)
if valid_to_column is not None:
clauses.append(or_(valid_to_column.is_(None), valid_to_column > instant))
return query.filter(*clauses) if clauses else query
def _optional_column(model: type[Any], name: str | None) -> Any | None:
if name is None:
return None
column = getattr(model, name, None)
if column is None:
raise TemporalContextError(
f"{model.__name__} has no temporal column named {name!r}."
)
return column
__all__ = ["apply_temporal_revision_filter"]
+14 -7
View File
@@ -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(
+10 -2
View File
@@ -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)
+2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.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:
+61
View File
@@ -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()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from govoplan_core.core.temporal import (
RECORDED_AT_HEADER,
TEMPORAL_EVALUATED_AT_HEADER,
TEMPORAL_VARY_HEADERS,
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
TemporalContextError,
TemporalDataContext,
bind_temporal_data_context,
current_temporal_data_context,
parse_temporal_data_context,
reset_temporal_data_context,
)
async def temporal_data_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
try:
context = parse_temporal_data_context(
validity_mode=request.headers.get(VALIDITY_MODE_HEADER),
valid_at=request.headers.get(VALID_AT_HEADER),
recorded_at=request.headers.get(RECORDED_AT_HEADER),
evaluated_at=datetime.now(UTC),
)
except TemporalContextError as exc:
return JSONResponse(status_code=400, content={"detail": str(exc)})
request.state.govoplan_temporal_data_context = context
token = bind_temporal_data_context(context)
try:
response = await call_next(request)
finally:
reset_temporal_data_context(token)
response.headers[VALIDITY_MODE_HEADER] = context.validity_mode
response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp(
context.evaluated_at
)
if context.valid_at is not None:
response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at)
if context.recorded_at is not None:
response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at)
_merge_vary(response, TEMPORAL_VARY_HEADERS)
return response
def get_temporal_data_context(request: Request) -> TemporalDataContext:
context = getattr(request.state, "govoplan_temporal_data_context", None)
return context if isinstance(context, TemporalDataContext) else current_temporal_data_context()
def _merge_vary(response: Response, names: tuple[str, ...]) -> None:
current = {
item.strip().lower(): item.strip()
for item in response.headers.get("Vary", "").split(",")
if item.strip()
}
for name in names:
current.setdefault(name.lower(), name)
response.headers["Vary"] = ", ".join(current.values())
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"]
+1 -1
View File
@@ -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)
+18
View File
@@ -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")
+43
View File
@@ -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"
+29
View File
@@ -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()
+68
View File
@@ -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()
+98 -4
View File
@@ -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))
+16
View File
@@ -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(
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from fastapi import APIRouter
from fastapi.testclient import TestClient
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.temporal import (
TemporalContextError,
current_temporal_data_context,
parse_temporal_data_context,
temporal_revision_matches,
)
from govoplan_core.server.fastapi import create_govoplan_app
NOW = datetime(2026, 8, 4, 12, 0, tzinfo=UTC)
class TemporalContextTests(unittest.TestCase):
def test_valid_and_recorded_time_are_independent(self) -> None:
context = parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-03T10:30:00+01:00",
recorded_at="2025-03-01T00:00:00Z",
evaluated_at=NOW,
)
self.assertEqual("at", context.validity_mode)
self.assertEqual(datetime(2025, 2, 3, 9, 30, tzinfo=UTC), context.valid_at)
self.assertEqual(datetime(2025, 3, 1, tzinfo=UTC), context.recorded_at)
self.assertFalse(context.is_default)
def test_at_requires_zoned_valid_at_and_other_modes_reject_it(self) -> None:
with self.assertRaisesRegex(TemporalContextError, "requires valid_at"):
parse_temporal_data_context(validity_mode="at", evaluated_at=NOW)
with self.assertRaisesRegex(TemporalContextError, "include a timezone"):
parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-03T10:30:00",
evaluated_at=NOW,
)
with self.assertRaisesRegex(TemporalContextError, "only permitted"):
parse_temporal_data_context(
validity_mode="all",
valid_at="2025-02-03T10:30:00Z",
evaluated_at=NOW,
)
def test_revision_matching_uses_half_open_valid_and_recorded_intervals(self) -> None:
context = parse_temporal_data_context(
validity_mode="at",
valid_at="2025-02-10T00:00:00Z",
recorded_at="2025-02-15T00:00:00Z",
evaluated_at=NOW,
)
self.assertTrue(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
valid_to=datetime(2025, 3, 1, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC),
superseded_at=datetime(2025, 2, 16, tzinfo=UTC),
)
)
self.assertFalse(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
valid_to=datetime(2025, 2, 10, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC),
)
)
self.assertFalse(
temporal_revision_matches(
context,
valid_from=datetime(2025, 2, 1, tzinfo=UTC),
revision_recorded_at=datetime(2025, 2, 15, tzinfo=UTC)
+ timedelta(microseconds=1),
)
)
def test_request_headers_bind_context_and_invalid_headers_fail_closed(self) -> None:
router = APIRouter()
@router.get("/temporal")
def temporal_payload() -> dict[str, str | None]:
return current_temporal_data_context().to_dict()
app = create_govoplan_app(
title="temporal context test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client:
response = client.get(
"/temporal",
headers={
"X-Govoplan-Validity-Mode": "at",
"X-Govoplan-Valid-At": "2025-02-03T10:30:00Z",
"X-Govoplan-Recorded-At": "2025-03-01T00:00:00Z",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("at", response.json()["validity_mode"])
self.assertEqual("2025-02-03T10:30:00Z", response.json()["valid_at"])
self.assertEqual("at", response.headers["X-Govoplan-Validity-Mode"])
self.assertIn(
"x-govoplan-valid-at",
response.headers.get("vary", "").lower(),
)
invalid = client.get(
"/temporal",
headers={"X-Govoplan-Validity-Mode": "at"},
)
self.assertEqual(400, invalid.status_code, invalid.text)
self.assertIn("requires valid_at", invalid.json()["detail"])
if __name__ == "__main__":
unittest.main()
+23 -1
View File
@@ -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(
+76 -76
View File
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.15",
"version": "0.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.15",
"version": "0.1.17",
"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.17",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"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.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
+60 -60
View File
@@ -1,26 +1,26 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.15",
"version": "0.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.15",
"version": "0.1.17",
"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.17",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.17",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.17",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.17",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.17",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.17",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.17",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.17",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.17",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.17",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.17",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.17",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.17",
"@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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#367ffc45642c4ac7c767d618ffb5e7d66f2e919e",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#2841cd67cc49119b4d7ffd734a860a27e0212e3b",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#1d993b26c83a744c322a924e5fe5019a7fc708a2",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#d42153edc760f5ae678d8ab88179ceb4cde8f27b",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#6562484d32e7f14462d122f892da93372252873e",
"dependencies": {
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#d80e4db3ecf2e8c776c5f0f22a5a8bdca946a2a0",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#f14692a15e4e785179c577d5266fa3a2b5cdc538",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#c9f8e9262ee7dec62302b74443300e151fa75465",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#85b5f80c59e57dec217e1193d7546e185491203f",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#e41d05914a231c6c3a8fd5da9587232e74a40e21",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#b09b653987c5089e6705976a42743cb623af5d45",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#3ebe8c2c99d1719362974de1a56829ea4c4f9d33",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"@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.17",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#0941268f6bf63cb0a713c8952d6483a7460437a2",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.17",
"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
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.15",
"version": "0.1.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+14 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.15",
"version": "0.1.17",
"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.17",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.17",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.17",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.17",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.17",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.17",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.17",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.17",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.17",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.17",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.17",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.17",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.17",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/pm": "^3.29.2",
+16 -3
View File
@@ -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
View File
@@ -11,6 +11,8 @@ import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { 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
+9 -1
View File
@@ -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)) {
+15 -2
View File
@@ -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>;
}
+12 -4
View File
@@ -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>);
}
}
+28 -6
View File
@@ -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}
+11 -2
View File
@@ -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}
>
+10 -2
View File
@@ -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">
+9 -2
View File
@@ -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"
+16 -4
View File
@@ -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)}` : ""}`;
}
+13 -8
View File
@@ -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 {
+42 -2
View File
@@ -637,7 +637,27 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Enter a valid HTTP, HTTPS, mail or phone link.",
"i18n:govoplan-core.enter_a_valid_http_https_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."
}
};
+2
View File
@@ -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";
+31 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router";
import { 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>);
+9 -1
View File
@@ -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>
+220
View File
@@ -0,0 +1,220 @@
import { Calendar, CalendarOff } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import Button from "../components/Button";
import DismissibleAlert from "../components/DismissibleAlert";
import FormField from "../components/FormField";
import SegmentedControl from "../components/SegmentedControl";
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useTemporalDataContext } from "../platform/TemporalContext";
import {
normalizeTemporalDataSelection,
type TemporalDataSelection,
type TemporalValidityMode
} from "../platform/temporal";
export default function TemporalDataMenu() {
const { selection, applySelection, isDefault } = useTemporalDataContext();
const { translateText } = usePlatformLanguage();
const { requestNavigation } = useUnsavedChanges();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<TemporalDataSelection>(selection);
const [error, setError] = useState("");
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
if (!open) setDraft(selection);
}, [open, selection]);
function toggleOpen() {
if (!open) {
setDraft(selection);
setError("");
}
setOpen(!open);
}
function selectValidityMode(validityMode: TemporalValidityMode) {
setDraft((current) => ({
...current,
validityMode,
validAt: validityMode === "at"
? current.validAt ?? new Date().toISOString()
: null
}));
}
function selectRecordedMode(mode: "latest" | "at") {
setDraft((current) => ({
...current,
recordedAt: mode === "at"
? current.recordedAt ?? new Date().toISOString()
: null
}));
}
function apply() {
try {
const normalized = normalizeTemporalDataSelection(draft);
requestNavigation(() => {
applySelection(normalized);
setOpen(false);
setError("");
});
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "i18n:govoplan-core.temporal_selection_invalid"
);
}
}
const iconTitle = selection.validityMode === "all"
? "i18n:govoplan-core.all_validity_periods"
: selection.validityMode === "at"
? "i18n:govoplan-core.historical_data_state"
: selection.recordedAt
? "i18n:govoplan-core.historical_recorded_state"
: "i18n:govoplan-core.current_data";
const Icon = selection.validityMode === "all" ? CalendarOff : Calendar;
const validDateMissing = draft.validityMode === "at" && !draft.validAt;
return (
<div className="context-menu-wrap temporal-data-menu-wrap" ref={menuRef}>
<button
type="button"
className={`titlebar-icon-link${isDefault ? "" : " is-context-active"}`}
data-help-context-id="core.temporal-data-context"
data-help-module-id="core"
data-help-scope="action"
onClick={toggleOpen}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={translateText(iconTitle)}
title={translateText(iconTitle)}
>
<Icon size={18} aria-hidden="true" />
</button>
{open && (
<div
className="dropdown-menu temporal-data-menu"
role="dialog"
aria-label={translateText("i18n:govoplan-core.data_state")}
>
<div className="temporal-data-menu-heading">
<strong>i18n:govoplan-core.data_state</strong>
</div>
<SegmentedControl<TemporalValidityMode>
className="temporal-validity-control"
value={draft.validityMode}
width="fill"
size="equal"
ariaLabel={translateText("i18n:govoplan-core.validity")}
onChange={selectValidityMode}
options={[
{ id: "current", label: "i18n:govoplan-core.current" },
{ id: "at", label: "i18n:govoplan-core.at_time" },
{ id: "all", label: "i18n:govoplan-core.all" }
]}
/>
{draft.validityMode === "at" && (
<FormField
label="i18n:govoplan-core.valid_at"
help="i18n:govoplan-core.valid_at_help"
>
<input
type="datetime-local"
value={toLocalInput(draft.validAt)}
onChange={(event) => setDraft((current) => ({
...current,
validAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
<div className="temporal-recorded-section">
<FormField
label="i18n:govoplan-core.recorded_state"
help="i18n:govoplan-core.recorded_state_help"
>
<select
value={draft.recordedAt ? "at" : "latest"}
onChange={(event) => selectRecordedMode(
event.target.value === "at" ? "at" : "latest"
)}
>
<option value="latest">i18n:govoplan-core.latest_recorded_state</option>
<option value="at">i18n:govoplan-core.recorded_by_time</option>
</select>
</FormField>
{draft.recordedAt && (
<FormField label="i18n:govoplan-core.recorded_by">
<input
type="datetime-local"
value={toLocalInput(draft.recordedAt)}
onChange={(event) => setDraft((current) => ({
...current,
recordedAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
</div>
<p className="temporal-data-explanation">
i18n:govoplan-core.temporal_data_explanation
</p>
{error && (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
)}
<div className="temporal-data-menu-actions">
<Button
type="button"
variant="primary"
onClick={apply}
disabled={validDateMissing}
disabledReason={validDateMissing
? "i18n:govoplan-core.select_valid_date_time"
: undefined}
>
i18n:govoplan-core.apply_data_state
</Button>
</div>
</div>
)}
</div>
);
}
function toLocalInput(value: string | null): string {
if (!value) return "";
const instant = new Date(value);
if (!Number.isFinite(instant.getTime())) return "";
const local = new Date(instant.getTime() - instant.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function fromLocalInput(value: string): string | null {
if (!value) return null;
const instant = new Date(value);
return Number.isFinite(instant.getTime()) ? instant.toISOString() : null;
}
+31 -39
View File
@@ -1,8 +1,9 @@
import { useRef, useState, useEffect } from "react";
import { 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}
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import {
DEFAULT_TEMPORAL_DATA_SELECTION,
dispatchTemporalContextChanged,
normalizeTemporalDataSelection,
setActiveTemporalDataSelection,
temporalDataSelectionIsDefault,
temporalDataSelectionKey,
type TemporalDataSelection
} from "./temporal";
const TEMPORAL_STORAGE_PREFIX = "govoplan.temporal-data";
type TemporalContextValue = {
selection: TemporalDataSelection;
applySelection: (selection: TemporalDataSelection) => void;
isDefault: boolean;
selectionKey: string;
};
const TemporalContext = createContext<TemporalContextValue>({
selection: DEFAULT_TEMPORAL_DATA_SELECTION,
applySelection: () => undefined,
isDefault: true,
selectionKey: temporalDataSelectionKey(DEFAULT_TEMPORAL_DATA_SELECTION)
});
export function PlatformTemporalProvider({
scopeKey,
children
}: {
scopeKey: string;
children: ReactNode;
}) {
const [selection, setSelection] = useState<TemporalDataSelection>(() =>
loadSelection(scopeKey)
);
useEffect(() => {
const stored = loadSelection(scopeKey);
setActiveTemporalDataSelection(stored);
setSelection(stored);
dispatchTemporalContextChanged(stored, scopeKey);
return () => {
setActiveTemporalDataSelection(DEFAULT_TEMPORAL_DATA_SELECTION);
};
}, [scopeKey]);
const applySelection = useCallback((next: TemporalDataSelection) => {
const normalized = normalizeTemporalDataSelection(next);
setActiveTemporalDataSelection(normalized);
storeSelection(scopeKey, normalized);
setSelection(normalized);
dispatchTemporalContextChanged(normalized, scopeKey);
}, [scopeKey]);
const value = useMemo<TemporalContextValue>(() => ({
selection,
applySelection,
isDefault: temporalDataSelectionIsDefault(selection),
selectionKey: temporalDataSelectionKey(selection)
}), [applySelection, selection]);
return (
<TemporalContext.Provider value={value}>
{children}
</TemporalContext.Provider>
);
}
export function useTemporalDataContext(): TemporalContextValue {
return useContext(TemporalContext);
}
function storageKey(scopeKey: string): string {
return `${TEMPORAL_STORAGE_PREFIX}.${scopeKey}`;
}
function loadSelection(scopeKey: string): TemporalDataSelection {
if (typeof sessionStorage === "undefined") {
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
const stored = sessionStorage.getItem(storageKey(scopeKey));
if (!stored) return DEFAULT_TEMPORAL_DATA_SELECTION;
try {
return normalizeTemporalDataSelection(JSON.parse(stored));
} catch {
sessionStorage.removeItem(storageKey(scopeKey));
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
}
function storeSelection(
scopeKey: string,
selection: TemporalDataSelection
): void {
if (typeof sessionStorage === "undefined") return;
if (temporalDataSelectionIsDefault(selection)) {
sessionStorage.removeItem(storageKey(scopeKey));
return;
}
sessionStorage.setItem(storageKey(scopeKey), JSON.stringify(selection));
}
+2
View File
@@ -194,6 +194,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
routes: routesWithServerMetadata(module, info),
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: {
+98
View File
@@ -0,0 +1,98 @@
export type TemporalValidityMode = "current" | "at" | "all";
export type TemporalDataSelection = {
validityMode: TemporalValidityMode;
validAt: string | null;
recordedAt: string | null;
};
export type TemporalContextChangedEventDetail = {
selection: TemporalDataSelection;
scopeKey: string;
};
export const PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT =
"govoplan:temporal-context-changed";
export const DEFAULT_TEMPORAL_DATA_SELECTION: TemporalDataSelection = {
validityMode: "current",
validAt: null,
recordedAt: null
};
let activeSelection = DEFAULT_TEMPORAL_DATA_SELECTION;
export function normalizeTemporalDataSelection(
value: Partial<TemporalDataSelection> | null | undefined
): TemporalDataSelection {
const validityMode = ["current", "at", "all"].includes(
String(value?.validityMode)
)
? value?.validityMode as TemporalValidityMode
: "current";
const validAt = validityMode === "at"
? normalizeTimestamp(value?.validAt)
: null;
if (validityMode === "at" && !validAt) {
throw new Error("i18n:govoplan-core.select_valid_date_time");
}
return {
validityMode,
validAt,
recordedAt: normalizeTimestamp(value?.recordedAt)
};
}
export function temporalDataSelectionIsDefault(
value: TemporalDataSelection
): boolean {
return value.validityMode === "current" && !value.recordedAt;
}
export function temporalDataSelectionKey(value: TemporalDataSelection): string {
return [value.validityMode, value.validAt ?? "", value.recordedAt ?? ""].join(":");
}
export function setActiveTemporalDataSelection(
value: TemporalDataSelection
): void {
activeSelection = normalizeTemporalDataSelection(value);
}
export function activeTemporalDataSelection(): TemporalDataSelection {
return activeSelection;
}
export function temporalRequestHeaders(): Record<string, string> {
const selection = activeTemporalDataSelection();
if (temporalDataSelectionIsDefault(selection)) return {};
const headers: Record<string, string> = {
"X-Govoplan-Validity-Mode": selection.validityMode
};
if (selection.validAt) headers["X-Govoplan-Valid-At"] = selection.validAt;
if (selection.recordedAt) {
headers["X-Govoplan-Recorded-At"] = selection.recordedAt;
}
return headers;
}
export function dispatchTemporalContextChanged(
selection: TemporalDataSelection,
scopeKey: string
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<TemporalContextChangedEventDetail>(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
{ detail: { selection, scopeKey } }
)
);
}
function normalizeTimestamp(value: string | null | undefined): string | null {
const clean = String(value || "").trim();
if (!clean) return null;
const instant = new Date(clean);
if (!Number.isFinite(instant.getTime())) return null;
return instant.toISOString();
}
+1 -1
View File
@@ -124,7 +124,7 @@
.titlebar-link,
.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
View File
@@ -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;
+30
View File
@@ -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
View File
@@ -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",
+64 -1
View File
@@ -1,4 +1,5 @@
import { helpContextForPathname } from "../src/utils/helpContext";
import { helpContextForPathname, helpQueryForContext } from "../src/utils/helpContext";
import type { PlatformWebModule } from "../src/types";
function assertEqual(actual: string, expected: string, message: string): void {
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");