From b75ca342955a44bc980c9390cd4d9342928876c1 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:35:05 +0200 Subject: [PATCH] feat: consolidate shared UI and harden browser authority for release --- ...c58a2d7e9f10_ownership_decision_history.py | 21 + ...c58a2d7e9f10_ownership_decision_history.py | 37 ++ docs/API_CLIENT_CACHE_CONTRACT.md | 35 ++ docs/DATAGRID_SIZING_CONTRACT.md | 71 ++- docs/DEPLOYMENT_OPERATOR_GUIDE.md | 34 ++ docs/INTERFACE_PATTERN_MIGRATION.md | 39 +- docs/MODULE_ARCHITECTURE.md | 10 + docs/NAVIGATION_LAYOUT_CONTRACT.md | 64 +++ docs/PAGE_LAYOUT_USAGE_GUIDELINES.md | 60 +- docs/THEMING.md | 6 + docs/UI_UX_DECISION_LEDGER.md | 2 +- docs/WEBUI_BUNDLE_BUDGETS.md | 15 + docs/migration-release-baselines.json | 497 +++++++++++++++++ pyproject.toml | 2 +- src/govoplan_core/api/v1/schemas.py | 8 + src/govoplan_core/celery_app.py | 5 +- .../core/configuration_control.py | 18 + .../core/configuration_safety.py | 29 + src/govoplan_core/core/navigation.py | 64 ++- src/govoplan_core/server/platform.py | 3 + src/govoplan_core/settings.py | 3 + tests/test_api_smoke.py | 31 ++ tests/test_navigation_preferences.py | 41 ++ tests/test_ownership_history_migration.py | 79 +++ webui/conformance/AddressExplorerScenario.tsx | 25 + .../CampaignAttachmentsScenario.tsx | 34 ++ .../CampaignBulkReviewScenario.tsx | 31 ++ .../CampaignDeliveryPolicyScenario.tsx | 10 + .../CampaignDeliveryProgressScenario.tsx | 19 + .../CampaignMailSettingsScenario.tsx | 25 + .../CampaignRecipientOrderScenario.tsx | 66 +++ webui/conformance/CampaignReportScenario.tsx | 23 + .../CampaignReviewDetailsScenario.tsx | 20 + webui/conformance/CampaignReviewScenario.tsx | 24 + webui/conformance/CampaignSavingScenario.tsx | 44 ++ .../conformance/CampaignWorkspaceScenario.tsx | 28 + webui/conformance/ConformanceApp.tsx | 101 ++++ .../CredentialReferencesScenario.tsx | 18 + webui/conformance/DataGridLayoutScenario.tsx | 66 +++ webui/conformance/DialogLayoutScenario.tsx | 48 ++ webui/conformance/FilesToolbarScenario.tsx | 28 + .../conformance/FormControlLayoutScenario.tsx | 52 ++ webui/conformance/HelpCenterScenario.tsx | 9 + .../MailCredentialPolicyScenario.tsx | 16 + .../MailFolderExplorerScenario.tsx | 19 + webui/conformance/MailToolbarScenario.tsx | 23 + webui/conformance/ManagedArchiveScenario.tsx | 22 + webui/conformance/ModuleLayoutScenario.tsx | 49 ++ .../conformance/MultiSelectFilterScenario.tsx | 34 ++ .../conformance/NavigationLayoutScenario.tsx | 33 ++ .../NotificationFilterScenario.tsx | 28 + webui/conformance/QuickAccessCoreFacade.ts | 99 +++- webui/conformance/SearchFiltersScenario.tsx | 39 ++ webui/conformance/main.tsx | 2 +- .../tests/address-explorer.spec.ts | 100 ++++ .../tests/campaign-attachments.spec.ts | 84 +++ .../tests/campaign-bulk-review.spec.ts | 103 ++++ .../tests/campaign-delivery-policy.spec.ts | 107 ++++ .../tests/campaign-delivery-progress.spec.ts | 187 +++++++ .../tests/campaign-mail-settings.spec.ts | 196 +++++++ .../tests/campaign-recipient-order.spec.ts | 119 ++++ .../conformance/tests/campaign-report.spec.ts | 270 +++++++++ .../tests/campaign-review-details.spec.ts | 55 ++ .../conformance/tests/campaign-review.spec.ts | 321 +++++++++++ .../conformance/tests/campaign-saving.spec.ts | 145 +++++ .../tests/campaign-workspace.spec.ts | 127 +++++ .../tests/credential-references.spec.ts | 177 ++++++ .../tests/data-grid-layout.spec.ts | 141 +++++ webui/conformance/tests/dialog-layout.spec.ts | 52 ++ .../tests/files-toolbar-race.spec.ts | 62 +++ webui/conformance/tests/files-toolbar.spec.ts | 199 +++++++ .../tests/form-control-layout.spec.ts | 49 ++ webui/conformance/tests/help-center.spec.ts | 72 +++ .../tests/mail-credential-policy.spec.ts | 215 ++++++++ .../tests/mail-folder-explorer.spec.ts | 48 ++ webui/conformance/tests/mail-toolbar.spec.ts | 424 ++++++++++++++ .../conformance/tests/managed-archive.spec.ts | 296 ++++++++++ .../conformance/tests/module-layouts.spec.ts | 116 ++++ .../tests/multi-select-filter.spec.ts | 70 +++ .../tests/navigation-layout.spec.ts | 86 +++ .../tests/notification-filter.spec.ts | 112 ++++ .../conformance/tests/search-filters.spec.ts | 266 +++++++++ .../snapshots/shared-ui-light-narrow.png | Bin 232627 -> 233937 bytes .../conformance/tests/ui-conformance.spec.ts | 8 +- .../tests/wysiwyg-lifecycle.spec.ts | 56 ++ webui/package-lock.json | 520 +++++++++--------- webui/package-lock.release.json | 4 +- webui/package.json | 17 +- webui/package.release.json | 84 ++- .../scripts/test-core-interface-patterns.mjs | 52 ++ webui/scripts/test-theme-contract.mjs | 38 +- webui/scripts/test-vite-cache-isolation.mjs | 41 ++ .../test-workspace-collection-actions.mjs | 83 +++ webui/src/App.tsx | 37 +- webui/src/api/client.ts | 126 +++-- webui/src/api/mailContracts.ts | 1 + .../components/AppearanceOverridesEditor.tsx | 141 +---- webui/src/components/Card.tsx | 5 +- .../components/CredentialEnvelopeManager.tsx | 30 +- webui/src/components/ExplorerTree.tsx | 4 + webui/src/components/FormField.tsx | 5 +- webui/src/components/ListSelectionFilter.tsx | 47 ++ webui/src/components/LoadingFrame.tsx | 16 +- webui/src/components/MultiSelectFilter.tsx | 129 +++++ .../components/NavigationPreferenceEditor.tsx | 204 +++---- webui/src/components/PageActionBar.tsx | 8 +- webui/src/components/WysiwygEditor.tsx | 14 +- webui/src/components/appearanceOverrides.ts | 142 +++++ .../components/navigationPreferenceLayout.ts | 67 +++ webui/src/components/table/DataGrid.tsx | 285 +++++++--- webui/src/components/table/dataGridSizing.ts | 21 +- webui/src/components/wysiwygEditorUpdates.ts | 9 + webui/src/features/settings/SettingsPage.tsx | 7 +- webui/src/i18n/generatedTranslations.ts | 24 +- .../src/i18n/navigationEditorTranslations.ts | 47 ++ webui/src/index.ts | 7 +- webui/src/layout/IconRail.tsx | 3 +- webui/src/platform/moduleLoading.ts | 12 + webui/src/platform/modules.ts | 16 +- webui/src/platform/productAreas.ts | 44 ++ webui/src/platform/productSurfaces.ts | 20 +- webui/src/styles/components.css | 45 +- webui/src/styles/dialogs.css | 24 +- webui/src/styles/forms.css | 6 + webui/src/styles/layout.css | 21 +- webui/src/styles/tables.css | 56 +- webui/src/styles/tokens.css | 4 + webui/src/types.ts | 18 + webui/src/utils/emailAddresses.ts | 7 +- webui/tests/api-client-cache.test.mjs | 214 +++++++ webui/tests/data-grid-actions.test.tsx | 11 + webui/tests/data-grid-sizing.test.ts | 10 +- webui/tests/dependency-security.test.mjs | 27 + webui/tests/docs-discovery.test.mjs | 36 ++ webui/tests/layout-primitives.test.tsx | 28 +- webui/tests/mail-components.test.tsx | 19 + webui/tests/module-capabilities.test.ts | 39 ++ webui/tests/module-loading.test.ts | 36 ++ webui/tests/wysiwyg-editor-utils.test.ts | 6 + webui/tsconfig.conformance.json | 1 + webui/tsconfig.module-tests.json | 2 + webui/vite.config.ts | 20 +- webui/vite.conformance.config.ts | 4 + 143 files changed, 8664 insertions(+), 752 deletions(-) create mode 100644 alembic/dev_versions/c58a2d7e9f10_ownership_decision_history.py create mode 100644 alembic/versions/c58a2d7e9f10_ownership_decision_history.py create mode 100644 docs/API_CLIENT_CACHE_CONTRACT.md create mode 100644 docs/NAVIGATION_LAYOUT_CONTRACT.md create mode 100644 tests/test_ownership_history_migration.py create mode 100644 webui/conformance/AddressExplorerScenario.tsx create mode 100644 webui/conformance/CampaignAttachmentsScenario.tsx create mode 100644 webui/conformance/CampaignBulkReviewScenario.tsx create mode 100644 webui/conformance/CampaignDeliveryPolicyScenario.tsx create mode 100644 webui/conformance/CampaignDeliveryProgressScenario.tsx create mode 100644 webui/conformance/CampaignMailSettingsScenario.tsx create mode 100644 webui/conformance/CampaignRecipientOrderScenario.tsx create mode 100644 webui/conformance/CampaignReportScenario.tsx create mode 100644 webui/conformance/CampaignReviewDetailsScenario.tsx create mode 100644 webui/conformance/CampaignReviewScenario.tsx create mode 100644 webui/conformance/CampaignSavingScenario.tsx create mode 100644 webui/conformance/CampaignWorkspaceScenario.tsx create mode 100644 webui/conformance/CredentialReferencesScenario.tsx create mode 100644 webui/conformance/DataGridLayoutScenario.tsx create mode 100644 webui/conformance/DialogLayoutScenario.tsx create mode 100644 webui/conformance/FilesToolbarScenario.tsx create mode 100644 webui/conformance/FormControlLayoutScenario.tsx create mode 100644 webui/conformance/HelpCenterScenario.tsx create mode 100644 webui/conformance/MailCredentialPolicyScenario.tsx create mode 100644 webui/conformance/MailFolderExplorerScenario.tsx create mode 100644 webui/conformance/MailToolbarScenario.tsx create mode 100644 webui/conformance/ManagedArchiveScenario.tsx create mode 100644 webui/conformance/ModuleLayoutScenario.tsx create mode 100644 webui/conformance/MultiSelectFilterScenario.tsx create mode 100644 webui/conformance/NavigationLayoutScenario.tsx create mode 100644 webui/conformance/NotificationFilterScenario.tsx create mode 100644 webui/conformance/SearchFiltersScenario.tsx create mode 100644 webui/conformance/tests/address-explorer.spec.ts create mode 100644 webui/conformance/tests/campaign-attachments.spec.ts create mode 100644 webui/conformance/tests/campaign-bulk-review.spec.ts create mode 100644 webui/conformance/tests/campaign-delivery-policy.spec.ts create mode 100644 webui/conformance/tests/campaign-delivery-progress.spec.ts create mode 100644 webui/conformance/tests/campaign-mail-settings.spec.ts create mode 100644 webui/conformance/tests/campaign-recipient-order.spec.ts create mode 100644 webui/conformance/tests/campaign-report.spec.ts create mode 100644 webui/conformance/tests/campaign-review-details.spec.ts create mode 100644 webui/conformance/tests/campaign-review.spec.ts create mode 100644 webui/conformance/tests/campaign-saving.spec.ts create mode 100644 webui/conformance/tests/campaign-workspace.spec.ts create mode 100644 webui/conformance/tests/credential-references.spec.ts create mode 100644 webui/conformance/tests/data-grid-layout.spec.ts create mode 100644 webui/conformance/tests/dialog-layout.spec.ts create mode 100644 webui/conformance/tests/files-toolbar-race.spec.ts create mode 100644 webui/conformance/tests/files-toolbar.spec.ts create mode 100644 webui/conformance/tests/form-control-layout.spec.ts create mode 100644 webui/conformance/tests/help-center.spec.ts create mode 100644 webui/conformance/tests/mail-credential-policy.spec.ts create mode 100644 webui/conformance/tests/mail-folder-explorer.spec.ts create mode 100644 webui/conformance/tests/mail-toolbar.spec.ts create mode 100644 webui/conformance/tests/managed-archive.spec.ts create mode 100644 webui/conformance/tests/module-layouts.spec.ts create mode 100644 webui/conformance/tests/multi-select-filter.spec.ts create mode 100644 webui/conformance/tests/navigation-layout.spec.ts create mode 100644 webui/conformance/tests/notification-filter.spec.ts create mode 100644 webui/conformance/tests/search-filters.spec.ts create mode 100644 webui/conformance/tests/wysiwyg-lifecycle.spec.ts create mode 100644 webui/scripts/test-vite-cache-isolation.mjs create mode 100644 webui/scripts/test-workspace-collection-actions.mjs create mode 100644 webui/src/components/ListSelectionFilter.tsx create mode 100644 webui/src/components/MultiSelectFilter.tsx create mode 100644 webui/src/components/appearanceOverrides.ts create mode 100644 webui/src/components/navigationPreferenceLayout.ts create mode 100644 webui/src/components/wysiwygEditorUpdates.ts create mode 100644 webui/src/i18n/navigationEditorTranslations.ts create mode 100644 webui/src/platform/moduleLoading.ts create mode 100644 webui/tests/api-client-cache.test.mjs create mode 100644 webui/tests/dependency-security.test.mjs create mode 100644 webui/tests/docs-discovery.test.mjs create mode 100644 webui/tests/module-loading.test.ts diff --git a/alembic/dev_versions/c58a2d7e9f10_ownership_decision_history.py b/alembic/dev_versions/c58a2d7e9f10_ownership_decision_history.py new file mode 100644 index 0000000..a0073d3 --- /dev/null +++ b/alembic/dev_versions/c58a2d7e9f10_ownership_decision_history.py @@ -0,0 +1,21 @@ +"""Development-track wrapper for the ownership history repair.""" + +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" / "c58a2d7e9f10_ownership_decision_history.py" +_spec = spec_from_file_location("govoplan_ownership_decision_history_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 diff --git a/alembic/versions/c58a2d7e9f10_ownership_decision_history.py b/alembic/versions/c58a2d7e9f10_ownership_decision_history.py new file mode 100644 index 0000000..3d49baf --- /dev/null +++ b/alembic/versions/c58a2d7e9f10_ownership_decision_history.py @@ -0,0 +1,37 @@ +"""repair decision history on previously upgraded ownership tables + +Revision ID: c58a2d7e9f10 +Revises: b47e6f809a13 +Create Date: 2026-09-07 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c58a2d7e9f10" +down_revision = "b47e6f809a13" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # The original ownership migration gained this column after some databases + # had already applied it. create_all/checkfirst cannot upgrade those tables. + # Fresh installations already have it; never replace their audit evidence. + columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns( + "core_ownership_transfers" + )} + if "decisions" not in columns: + op.add_column( + "core_ownership_transfers", + sa.Column("decisions", sa.JSON(), nullable=False, server_default=sa.text("'[]'")), + ) + + +def downgrade() -> None: + # Older installations and fresh installations at the preceding revision + # differ. Keep the additive column and any subsequently recorded evidence. + pass diff --git a/docs/API_CLIENT_CACHE_CONTRACT.md b/docs/API_CLIENT_CACHE_CONTRACT.md new file mode 100644 index 0000000..de9bed2 --- /dev/null +++ b/docs/API_CLIENT_CACHE_CONTRACT.md @@ -0,0 +1,35 @@ +# Shared API client cache and authority boundaries + +All optional WebUI modules use the Core API client. Its bounded in-memory caches +are an optimization, never an authorization mechanism. The backend must check +the current principal, tenant, and permissions even for conditional GETs. + +- Identical simultaneous safe requests can share one network request. Requests + with caller-owned cancellation are independent. +- Responses allowing reuse have at most a 750 ms recent-response window. + `no-store` and `Vary: *` responses are not retained. `no-cache` and zero-age + responses require a server check; permitted ETags retain conditional GET + support without bypassing authorization. This follows the relevant + [HTTP cache-control semantics](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2). +- Explicit `cache: "no-store"`, `"reload"`, or `"no-cache"` reads bypass older + response data and supersede older requests for that resource. Reload is not a + mutation. Owning read helpers must pass these options through pagination. +- Writes invalidate caches before execution and again on settlement, including + failures whose server outcome may be uncertain. Reads started before or + during the write cannot seed reusable data after it finishes. +- The shell calls `clearApiReadCache()` before explicit auth updates and when + refreshing authoritative session data. API-settings changes, clearing the + token, authentication expiry, and changes to the paired session/CSRF cookie + also invalidate both stored and in-flight reuse. Cookie observation also + covers sign-in/out in another tab; it does not read the HttpOnly session token. +- Interactive sign-in and sign-out clear a previously saved automation key. + Explicit key-based connection settings still select the key's identity; + profile-only updates preserve settings identity to avoid reload loops. +- Expired responses from superseded reads or downloads do not trigger a login + prompt in a newer session. +- Every completion (including 304) must still own its cache slot and generation + before storing anything. An old caller may receive its own result, so feature + components must continue guarding displayed state against obsolete requests. + +Regression coverage: `npm run test:api-client-cache` uses the real client and +isolated network fixtures. No live API or account data is involved. diff --git a/docs/DATAGRID_SIZING_CONTRACT.md b/docs/DATAGRID_SIZING_CONTRACT.md index c943543..c78ce23 100644 --- a/docs/DATAGRID_SIZING_CONTRACT.md +++ b/docs/DATAGRID_SIZING_CONTRACT.md @@ -1,8 +1,14 @@ # DataGrid Sizing Contract +Auto-height grids reserve no empty vertical scrollbar gutter. The table fills +its card to the right edge; an actual constrained vertical scrollbar still +occupies its normal space. `Card bodyLayout="table"` provides an explicit +zero-inset surface, including with loading wrappers and padded notices. The +Organizations/IDM browser fixtures assert row geometry, not just outer shells. + `DataGrid` turns every declared track into a deterministic pixel layout after its container has a measurable width. The same contract is used on initial -layout, container resize, persisted-layout restore, and pointer resize. +layout, container resize, persisted-layout restore, and pointer/keyboard resize. ## Column Declarations @@ -17,6 +23,54 @@ layout, container resize, persisted-layout restore, and pointer resize. - `maxWidth` bounds direct user growth and free/constrained compensation. In a cover layout it is a preferred maximum: passive tracks may exceed it when that is necessary to keep the table flush with its container. +- `columnType: "actions"` marks a custom action/control column. Canonical + `TableActionGroup` content is recognized automatically, even in existing + column declarations. Use `sticky: "end"` for the normal row-action surface. + +## Action Visibility and Constrained Containers + +Action tracks reserve the width of actual buttons, disabled-action wrappers, +reserved empty-state slots, gaps, and cell padding. A historic `width: 72` +preference therefore cannot clip a four-button action group. Ordinary data text +does not participate in this content measurement; long field values do not +silently widen all tracks. Changes to the rendered action set are remeasured. + +`TableActionButton` remains a compact 36 px control, including the Add action +in an empty grid. Never stretch it with a last-column `.btn { width: 100% }` +rule. Its shared maximum width and fixed flex basis protect against broad +consumer button rules, which can otherwise feed stretched widths back into +action-track measurement and consume the data area. + +When the full group needs more than half the scroll viewport, its measured +minimum is capped at half the viewport and the group wraps. Explicit hard +minima remain authoritative. Custom action groups should use wrapping-capable +flex layouts and semantic groups, preferably composing `TableActionGroup`. + +The grid's physical width matches its pixel tracks, including horizontal +overflow, so right-sticky actions remain inside the correct scroll bounds. +If explicitly wide or persisted sticky tracks would obscure the readable data +area, horizontal stickiness is released until space returns. No columns or +actions are hidden: the labelled scroll region is focusable and supports native +keyboard scrolling. Vertical header stickiness remains available. + +## Resizing Controls + +Drag a resize handle with a mouse, pen, or touch pointer. Pointer capture keeps +the drag active when it leaves the handle. Escape or pointer cancellation +restores the layout before that drag; releasing the pointer commits it. Losing +window focus ends a drag without leaving the table stuck in resizing mode. + +Each handle is a focusable vertical separator exposing its current and allowed +widths. Left/Right changes its width by 10 px; Shift+Left/Right uses 40 px. Enter +or a double-click resets that column's explicit override to the declared sizing +rules. Other columns retain their preferences, so cover/compensation constraints +still apply. These operations only change personal browser layout, never rows. + +Deutsch: Spalten lassen sich mit Maus, Stift oder Touch ziehen. Escape verwirft +den laufenden Ziehvorgang. Am fokussierten Trenner ändern Links/Rechts die Breite +um 10 px, mit Umschalt um 40 px. Eingabe oder Doppelklick setzt die persönliche +Breite dieser Spalte zurück. Schmale Aktionenspalten umbrechen ihre Schaltflächen; +breite Tabellen bleiben horizontal scrollbar. ## Layout Modes @@ -39,7 +93,7 @@ the column remains stopped until the pointer crosses the same boundary again. Only the pixel layout resulting from an explicit user resize is persisted, together with the container width at which the user selected it. Persisted widths are keyed by a signature containing column IDs, declared -widths and bounds, resize affordances, sticky placement, initial fit, and resize +widths and bounds, sort/filter/resize affordances, column type, sticky placement, initial fit, and resize behavior. A changed signature discards the old override and recomputes the declared layout. @@ -51,7 +105,10 @@ contracts, persisted tracks may shrink toward their hard minima. The layout retains only the amount of horizontal overflow deliberately created by the user; an exact-cover layout therefore remains exact-cover at narrower widths. Legacy snapshots from the former hard-pixel persistence contract are discarded -once and recomputed from the declared column layout. +once and recomputed from the declared column layout. The current `v3` signature +also discards old snapshots that predate action and header-control minima; +sort/filter preferences remain intact. Measured action widths are not included +in the signature, so changing rows does not erase user sizing intent. ## Regression Matrix @@ -72,3 +129,11 @@ once and recomputed from the declared column layout. `webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover shape and guards against reintroducing a synthetic buffer cell. + +`webui/conformance/tests/data-grid-layout.spec.ts` exercises the real rendered +grid with deliberately undersized action preferences, constrained containers, +horizontal scrolling, changing/empty action sets, keyboard and pointer resizing, +Escape cancellation, remount persistence, responsive contraction and restoration, +free/content mode, and constrained compensation. Run with +`npm run test:conformance -- data-grid-layout.spec.ts`; its isolated test server +is stopped automatically afterwards. diff --git a/docs/DEPLOYMENT_OPERATOR_GUIDE.md b/docs/DEPLOYMENT_OPERATOR_GUIDE.md index ce6a497..7a27d64 100644 --- a/docs/DEPLOYMENT_OPERATOR_GUIDE.md +++ b/docs/DEPLOYMENT_OPERATOR_GUIDE.md @@ -74,6 +74,19 @@ Operator rule: take a database backup before applying migrations or destructive module retirement. For non-SQLite databases, configure deployment-specific backup/restore hooks for the module installer. +#### Ownership-history upgrade repair + +Core revision `c58a2d7e9f10` repairs existing ownership-transfer tables that +predate the `decisions` column. Such installations can otherwise return HTTP +500 from `/api/v1/ownership/transfers`, including Campaign Settings. Apply the +normal forward migrations after taking a backup; do not stamp a revision or +recreate the table. The repair is available on both migration tracks, adds only +the missing non-null JSON column, and initializes old rows with an empty list. +It preserves owners, approvals, transfer states, revisions, timestamps, and any +existing decision history. Historical decisions are not reconstructed or +invented. Downgrading this repair retains the additive column and its evidence. +Verify that ownership-transfer listing and Campaign Settings load after upgrade. + ### PostgreSQL Production Target PostgreSQL is the primary development and production target. SQLite remains @@ -408,6 +421,27 @@ To stop PostgreSQL and Redis when the launcher exits: GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 tools/launch/launch-production-like-dev.sh ``` +## Development WebUI Dependency Caches + +The application and browser-conformance harness share installed JavaScript +packages but must not share Vite's optimized-dependency cache. The application +uses `webui/node_modules/.vite/govoplan-app`; the conformance harness uses +`webui/node_modules/.vite/govoplan-conformance`. Keep these explicit sibling +directories when adding development or test configurations. Setting a different +Vite `root` alone does not isolate this cache. + +A shared cache can make otherwise healthy Workflow, Dataflow or deferred editors +show “The resource could not be loaded.” The browser then reports an asset such +as `@xyflow_react.js` with HTTP 504 `Outdated Optimize Dep`, while the corresponding +API still returns HTTP 200. This is not a missing workflow permission or a reason +to rerun a pipeline. Preserve unsaved work, let the existing development server +reload the corrected configuration (or restart that WebUI server), then reload +the browser. Do not clear application data, change grants or restart delivery +workers to repair a frontend dependency cache. + +Run `npm run test:vite-cache-isolation` in `govoplan-core/webui` to verify the real +resolved Vite configurations without starting servers or overwriting caches. + ## Module Install/Uninstall Operations Use Admin > System > Modules for planning. The running API server validates and diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index c82811c..c2bf67d 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -17,7 +17,7 @@ domain modules own their compositions. | Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty | | Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting | | Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check | -| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` | +| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders guarded Reload in the right-aligned group immediately before Create/primary actions; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` | | Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow | | Content and form grids | Equal-column content, field, and native-form geometry | Explicit 1–4 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check | | Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow | @@ -27,9 +27,46 @@ domain modules own their compositions. | Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates | | Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks | | 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 | +| Measured operation feedback | `LoadingFrame` over existing content | Use `indicator="none"` with native measured progress for long-running operations; `progress={null}` means unknown, never a synthetic percentage. Keep dialog content inert and close controls disabled until success or error. Existing consumers retain their loading indicator. | Files archive inspection/extraction, layout primitive tests, managed-archive browser conformance | ## Boundary +Side-rail customization uses the shared `NavigationPreferenceEditor` for system, +tenant, personal, and View layouts. Modules and labelled separators share one +ordered list, with pointer drag-and-drop, keyboard reordering, and explicit +add/remove actions. Consumers retain persistence and dirty-state ownership; +mounting the editor does not create a draft change. See +`NAVIGATION_LAYOUT_CONTRACT.md` for inheritance, locked items, optional-module +preservation, and collapsed-rail grouping. + +Action columns use `TableActionGroup` or declare `columnType: "actions"` when +their composition differs. DataGrid owns measured action minima, initial column +allocation, persistent resizing, and local horizontal scrolling; consumers must +not compensate with clipped overflow or copied fixed widths. See +`DATAGRID_SIZING_CONTRACT.md`. Dialog forms use `DialogForm` and `FormGrid` inside +the shared size-bounded dialog. Do not add a content minimum wider than the +panel's padded interior. Genuinely wide content, such as a table, owns its own +local scroller instead of making the entire dialog scroll horizontally. + +In `FormGrid` and `FormLayout`, direct `FormField` and `ToggleSwitch` items +align their controls at the row's lower edge. A single control inside a +`GridItem` follows the same rule. Labels may wrap without shifting adjacent +switches up into the label row. Do not add per-module top margins or empty +labels; single-column layouts must not retain a phantom label spacer. + +Credential editors resolve public reference labels when opened. A failed +save displays its error inside the dialog and keeps the entered draft for an +explicit retry. While a write is pending, repeated submission, edits, and +dialog dismissal are disabled; no configured secret is read back from storage. + +The shared rich-text editor emits content changes only for actual document +edits. Mounting, read-only changes, loading a saved value, and switching between +visual and source inspection must preserve the controlled HTML without marking +the owning page dirty. This is especially important for legacy Campaign HTML: +merely visiting Template must not normalize it or require a save on leaving. +The WYSIWYG lifecycle browser conformance covers both visual and legacy-source +initial content, as well as genuine typing. + Files and Mail are the first two external consumers of the layered server/credential/policy pattern. Their own repositories retain provider discovery, transport behavior, authorization, and migration evidence. Remaining diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 57a6c22..dca15ca 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -1635,6 +1635,16 @@ Unsigned/unhashed remote bundles are skipped. This keeps remote loading a controlled deployment option rather than a replacement for release package builds. +A failed local WebUI package import receives one automatic retry after 250 ms. +Descriptor validation still fails closed; it is not bypassed by the retry. +If an enabled local module still cannot load, the signed-in shell warns that its +screens and integrations may be unavailable and identifies the module. This is +a loading failure, not an uninstall. Save other drafts before manually reloading +the page; there is no automatic page reload or persistent retry loop. A verified +remote fallback that successfully loads clears that module's warning. Packages +absent from the optional build graph remain absent, and effective View filtering +continues to control which loaded UI capabilities are exposed. + ## Maintenance Mode Maintenance mode is the required operating state for package install/uninstall diff --git a/docs/NAVIGATION_LAYOUT_CONTRACT.md b/docs/NAVIGATION_LAYOUT_CONTRACT.md new file mode 100644 index 0000000..5c0e958 --- /dev/null +++ b/docs/NAVIGATION_LAYOUT_CONTRACT.md @@ -0,0 +1,64 @@ +# Shared navigation layout contract + +Core owns `NavigationPreferenceEditor`, ordered layout resolution, and rail +rendering. Admin, Tenancy, personal Settings and Views reuse this editor. They +own loading, authorization, Save, Reload and dirty-state guards; the editor +emits a draft only after a real edit. A drag onto the same position, keyboard +pickup/drop without movement, and opening inherited settings do not save or +create an override. + +## Stored document and precedence + +The version-1 navigation document retains `order`, `hidden` and `locked` and +adds optional `separators`, each containing a stable `separator:`-prefixed ID +and an optional plain-text label of at most 120 characters. Separator IDs and +module navigation IDs occupy the same `order` list. Separators are presentation +metadata and never become routes, modules, permissions or authorized surfaces. + +Omitting `separators` or using null preserves inherited grouping. An explicit +empty array removes grouping. Resetting the entire navigation document to null +removes that scope's override. Existing order-only documents remain readable; +the editor materializes group markers into a draft only when edited. Unknown +optional-module order IDs remain stored when currently visible items move, so +uninstalling or temporarily disabling a module does not destroy its preference. + +User order and visibility take precedence over tenant and system preferences. +System and tenant visibility locks accumulate; lower scopes cannot hide those +destinations, but may move them. Views may supply a navigation presentation +inside the already authorized and View-filtered destination set. An explicit +personal order/layout or visibility preference takes precedence over that +presentation, not over authorization or the View's surface restrictions. +Views cannot introduce locks. When multiple modules contribute one product +entry, it inherits the earliest effective rail position/section and all +authorized contributors' locks; this does not change operational route +selection. An owner alias in View layout refers to that composed entry. + +## Interaction and reuse + +Drag the handle to move either a module or separator before/after another row. +The handle also supports Space to pick up, arrow keys to move, Enter to drop, +and Escape to restore the pre-drag draft. Up/down buttons offer the same moves. +Add module restores an available hidden entry; Remove only hides navigation, +never uninstalls a module or deletes records. Add separator inserts a new +optional group label. Remove separator changes grouping only. + +Expanded rails display group labels without divider lines. Collapsed rails +replace these labels with horizontal group dividers; empty groups are not +rendered. Both modes use the same resolved order. +The editor receives product-area metadata to show inherited grouping and uses +container-responsive rows rather than a fixed dialog/page width. Its English +and German labels load with the editor, not the initial shell bundle. +Give the ordered list a full-span `GridItem` when a settings page contains +multiple cards; do not squeeze the entire editor into an otherwise half-empty +two-column settings grid. Central spacing tokens provide real row padding and +separation at both wide and narrow sizes, covered by computed-style assertions. + +## Verification + +Core navigation unit tests and HTTP settings/profile tests cover persistence, +separator inheritance, explicit flat layouts, reset and locks. Module-capability +tests cover View aliases, composed destinations and personal precedence. The +browser conformance suite tests all four editor scopes, pointer/keyboard moves, +no-op cleanliness, unavailable-module preservation, collapsed dividers and a +German narrow read-only layout. Use the same shared component for future +navigation-definition surfaces rather than implementing another sortable list. diff --git a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md index c4ec49d..d9d30f6 100644 --- a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md +++ b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md @@ -69,11 +69,61 @@ page or pane action bar. | Page kind | Leading group | Trailing group | | --- | --- | --- | -| Overview | Reload when refreshable, then context | Help, then ordinary primary actions | -| Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right | -| Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group | -| Editor | Reload only when refresh is a distinct safe operation, then context | Dirty state, Help, ordinary primary actions, separated destructive actions, Discard, then Save at the far right | -| Workspace | Reload when the coordinated projection can become stale, then task context | Help, ordinary primary actions, then a separated destructive group | +| Overview | Context | Help, Reload when refreshable, then ordinary primary actions | +| Collection | Collection context such as export | Help, Reload when refreshable, then Create at the far right | +| Detail | Object context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group | +| Editor | Context | Dirty state, Help, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right | +| Workspace | Task context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group | + +Reload and Create belong to the same right-aligned group, in that order. A +collection-wide toolbar stays above its workspace, not inside the left tree or +conditionally inside an editor. Changing selection or opening an editor must +not remove it. Permission-blocked creation remains visible with an explanation. +On narrow screens the trailing group wraps while retaining right alignment and +the same DOM/keyboard order. + +Use `Card bodyLayout="table"` for table surfaces, including tables wrapped by +`LoadingFrame`. This removes body padding explicitly, without relying on the +number of children or negative margins. Place any meaningful explanation or +warning in a padded `ContentSection`; do not add a redundant tagline to every +table. Use `ContentGrid` for sibling cards so spacing does not depend on fragments. + +Use `MultiSelectFilter` for standalone list facets. It and DataGrid share the +same checkbox body and Select all / Deselect all behavior. `null` means no +restriction, `[]` means no matches, and multiple values mean OR within a facet. +Apply remote filters before server pagination/limits and discard stale reads. +Do not replace this with rows of toggles or implement a second checkbox menu. +The dropdown's body portal escapes clipped containers. Inside Core dialogs it +joins the existing dialog stack: Tab/Shift+Tab stay in the filter, Space toggles +the focused checkbox, and Escape closes only the filter and restores its +trigger. Long option labels wrap without widening the popup. + +Keep facet definitions, URL serialization and request cancellation in one owning +module adapter when the same search appears on a page and in an overlay. Do not +translate an explicit empty selection into an unrestricted backend query. Keep +legacy API meanings at the adapter boundary; retain scope and unrelated URL +parameters when clearing filters. A query, context, account or tenant change +invalidates both initial and cursor requests, including results still visible +during a debounce interval. + +Explorer workspaces keep collection commands in a persistent header. Files +uses Reload, Create folder, then primary Upload; frequent selected-item actions +stay near the list. Group less-common selection and connection operations in +labelled domain dialogs using `Dialog`, `FormSection` and shared action bars, +with an explicit destructive section. Do not move an overloaded toolbar into +another ungrouped row. Mail's read-only workspace has one Reload for its current +profile, folder, index and preview; narrower refreshes belong in Mailbox tools. +Do not invent a New or Save button on a workspace that owns neither workflow. +Reload must not become import, synchronization, delivery or another mutation. +Explicit Reload reads must bypass short-lived client response reuse (for +example, pass `cache: "no-store"` through the owning read API), including each +page of a refreshed listing. Routine navigation may retain normal deduplication. +Conformance must observe a fresh request, not just an enabled Reload button. + +Tree icons/disclosure controls expand and collapse; labels select. A module's +`ExplorerTree.onOpen` must not toggle expansion. Use occurrence-specific node +IDs when the same semantic record appears in multiple branches; selection and +ancestor expansion must follow the clicked occurrence, not every copy. Reload means re-fetch or re-evaluate the current surface. A page declaring `refreshable` must provide it, and a non-refreshable page must not use Reload as diff --git a/docs/THEMING.md b/docs/THEMING.md index 96ce6c1..2588aa4 100644 --- a/docs/THEMING.md +++ b/docs/THEMING.md @@ -54,3 +54,9 @@ but their surrounding controls must still use the shared tokens. custom-override validation/application, and representative Campaign, Calendar, Files, and Mail token consumption. The check runs before a production WebUI build. + +Runtime validation and token application live in the dependency-free +`webui/src/components/appearanceOverrides.ts`; both the shell and the shared +editor use it. The shell must not import the editor to apply an existing theme: +settings controls load with their route, while valid saved colors apply +synchronously and invalid documents still fail closed before any token is set. diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index 6eb5d30..16adce9 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -57,7 +57,7 @@ contestability, responsibility, and traceability at the point of action. | 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 | -| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: a refreshable page must provide Reload in the leading slot; collections keep Create far right; read-only pages do not invent Save. | Accepted | Core and all module WebUIs | +| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: refreshable pages provide Reload in the right-aligned trailing group immediately before Create/primary actions; collections keep Create far right; read-only pages do not invent Save. The trailing placement supersedes the earlier leading-Reload rule (2026-09-07, Core #295). | Accepted | Core and all module WebUIs | | UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs | ## Confirmed Implementation Decisions diff --git a/docs/WEBUI_BUNDLE_BUDGETS.md b/docs/WEBUI_BUNDLE_BUDGETS.md index 1099edd..17b7110 100644 --- a/docs/WEBUI_BUNDLE_BUDGETS.md +++ b/docs/WEBUI_BUNDLE_BUDGETS.md @@ -59,6 +59,14 @@ The first budgeted full-product build reported: ## Verification +Development startup explicitly prebundles the Excel reader's browser/universal +entrypoints and the lazy rich-text editor's Tiptap dependencies. These are +Core-installed vendor dependencies, not eager optional-module imports. This +avoids first-time Campaign/Template navigation triggering a second dependency +optimization and page reload. Module descriptors and pages remain lazy, and +production bundle budgets remain unchanged. The Core interface-pattern check +verifies this include list and keeps optional GovOPlaN modules excluded. + ```bash cd /mnt/DATA/git/govoplan-core/webui npm run build @@ -69,3 +77,10 @@ npm run test:module-permutations The build gate also catches accidental eager imports: a page pulled into the entry closure consumes the initial budget, while an oversized page or module descriptor consumes the asynchronous chunk budget. + +The startup shell imports appearance validation/application from the pure +`appearanceOverrides.ts` runtime. Settings-only color controls, JSON import/export, +and previews remain in `AppearanceOverridesEditor.tsx` behind the existing lazy +Settings route. Importing a runtime helper from a module that also owns editor +components can accidentally pull the entire editor into the startup chunk. +Public helper exports remain compatible; theme application is still synchronous. diff --git a/docs/migration-release-baselines.json b/docs/migration-release-baselines.json index 2d3c47b..1383552 100644 --- a/docs/migration-release-baselines.json +++ b/docs/migration-release-baselines.json @@ -2271,6 +2271,503 @@ "release": "0.1.18", "squash_policy": "reviewed-manual", "track": "release" + }, + { + "heads": [ + { + "owner": "govoplan-notifications", + "revision": "6e2f91ab4c70" + }, + { + "owner": "govoplan-poll", + "revision": "6e7f8a9b0c1d" + }, + { + "owner": "govoplan-dashboard", + "revision": "7b9d2f4a6c8e" + }, + { + "owner": "govoplan-tasks", + "revision": "7c4d9a2e1f30" + }, + { + "owner": "govoplan-records", + "revision": "8a6c4e2f1b3d" + }, + { + "owner": "govoplan-voting", + "revision": "8b9c0d1e2f3a" + }, + { + "owner": "govoplan-tickets", + "revision": "8d1f4b7a2c5e" + }, + { + "owner": "govoplan-workflow-engine", + "revision": "8d5a2f7c1b4e" + }, + { + "owner": "govoplan-quick-access", + "revision": "9a4e6c2d8f10" + }, + { + "owner": "govoplan-helpdesk", + "revision": "9e2a5c8f1b4d" + }, + { + "owner": "govoplan-files", + "revision": "a2b3c4d5e6f8" + }, + { + "owner": "govoplan-files", + "revision": "a2b3c4d5e6f9" + }, + { + "owner": "govoplan-dataflow", + "revision": "a3d7f1c5e9b2" + }, + { + "owner": "govoplan-templates", + "revision": "a3f7c9d2e1b4" + }, + { + "owner": "govoplan-mail", + "revision": "a4c5d6e7f809" + }, + { + "owner": "govoplan-organizations", + "revision": "a61e4d9c72b8" + }, + { + "owner": "govoplan-wiki", + "revision": "a7c2e9f4b1d6" + }, + { + "owner": "govoplan-cases", + "revision": "a7c4e2f9b1d6" + }, + { + "owner": "govoplan-mandates", + "revision": "a8b1c2d3e4f5" + }, + { + "owner": "govoplan-approvals", + "revision": "a91c4e72b5d8" + }, + { + "owner": "govoplan-policy", + "revision": "a9c4e7b2d5f8" + }, + { + "owner": "govoplan-search", + "revision": "b2c3d4e5f607" + }, + { + "owner": "govoplan-tenancy", + "revision": "b3d8e1f4a6c2" + }, + { + "owner": "govoplan-risk-compliance", + "revision": "b9c0d1e2f3a4" + }, + { + "owner": "govoplan-services", + "revision": "b9c2d3e4f5a6" + }, + { + "owner": "govoplan-audit", + "revision": "b9e2f5a8c3d6" + }, + { + "owner": "govoplan-parties", + "revision": "c0d3e4f5a6b7" + }, + { + "owner": "govoplan-connectors", + "revision": "c0f1a2b3c4d5" + }, + { + "owner": "govoplan-idm", + "revision": "c2d3e4f5a6b7" + }, + { + "owner": "govoplan-identity-trust", + "revision": "c3f5a7b9d1e2" + }, + { + "owner": "govoplan-projects", + "revision": "c4a1e8f2d6b9" + }, + { + "owner": "govoplan-core", + "revision": "c58a2d7e9f10" + }, + { + "owner": "govoplan-views", + "revision": "c6f2a9d4e7b1" + }, + { + "owner": "govoplan-reporting", + "revision": "c8d5e2f6a9b3" + }, + { + "owner": "govoplan-datasources", + "revision": "d1a7c3e9f5b2" + }, + { + "owner": "govoplan-decisions", + "revision": "d1e4f5a6b7c8" + }, + { + "owner": "govoplan-calendar", + "revision": "d24e5f607182" + }, + { + "owner": "govoplan-docs", + "revision": "d3e7a1c5f9b2" + }, + { + "owner": "govoplan-forms-runtime", + "revision": "d6a8b0c2e4f6" + }, + { + "owner": "govoplan-addresses", + "revision": "d6e8f9a0b1c2" + }, + { + "owner": "govoplan-scheduling", + "revision": "d7a4c1e8f205" + }, + { + "owner": "govoplan-postbox", + "revision": "d8b4f1a6c9e2" + }, + { + "owner": "govoplan-committee", + "revision": "d8b9f0a1c2e3" + }, + { + "owner": "govoplan-access", + "revision": "d8f1b4e7a0c3" + }, + { + "owner": "govoplan-encryption", + "revision": "e5b7c9d1f3a4" + }, + { + "owner": "govoplan-payments", + "revision": "e7b9c1d3f5a7" + }, + { + "owner": "govoplan-dist-lists", + "revision": "e7c3a9d1b5f2" + }, + { + "owner": "govoplan-campaign", + "revision": "f3c7a9d2e6b1" + } + ], + "owner_heads": [ + { + "owner": "govoplan-access", + "revisions": [ + "d8f1b4e7a0c3" + ] + }, + { + "owner": "govoplan-addresses", + "revisions": [ + "d6e8f9a0b1c2" + ] + }, + { + "owner": "govoplan-approvals", + "revisions": [ + "a91c4e72b5d8" + ] + }, + { + "owner": "govoplan-audit", + "revisions": [ + "b9e2f5a8c3d6" + ] + }, + { + "owner": "govoplan-calendar", + "revisions": [ + "d24e5f607182" + ] + }, + { + "owner": "govoplan-campaign", + "revisions": [ + "f3c7a9d2e6b1" + ] + }, + { + "owner": "govoplan-cases", + "revisions": [ + "a7c4e2f9b1d6" + ] + }, + { + "owner": "govoplan-committee", + "revisions": [ + "d8b9f0a1c2e3" + ] + }, + { + "owner": "govoplan-connectors", + "revisions": [ + "c0f1a2b3c4d5" + ] + }, + { + "owner": "govoplan-core", + "revisions": [ + "c58a2d7e9f10" + ] + }, + { + "owner": "govoplan-dashboard", + "revisions": [ + "7b9d2f4a6c8e" + ] + }, + { + "owner": "govoplan-dataflow", + "revisions": [ + "a3d7f1c5e9b2" + ] + }, + { + "owner": "govoplan-datasources", + "revisions": [ + "d1a7c3e9f5b2" + ] + }, + { + "owner": "govoplan-decisions", + "revisions": [ + "d1e4f5a6b7c8" + ] + }, + { + "owner": "govoplan-dist-lists", + "revisions": [ + "e7c3a9d1b5f2" + ] + }, + { + "owner": "govoplan-docs", + "revisions": [ + "d3e7a1c5f9b2" + ] + }, + { + "owner": "govoplan-encryption", + "revisions": [ + "e5b7c9d1f3a4" + ] + }, + { + "owner": "govoplan-files", + "revisions": [ + "a2b3c4d5e6f8", + "a2b3c4d5e6f9" + ] + }, + { + "owner": "govoplan-forms", + "revisions": [ + "e1f2a3b4c5d6" + ] + }, + { + "owner": "govoplan-forms-runtime", + "revisions": [ + "d6a8b0c2e4f6" + ] + }, + { + "owner": "govoplan-helpdesk", + "revisions": [ + "9e2a5c8f1b4d" + ] + }, + { + "owner": "govoplan-identity", + "revisions": [ + "5c6d7e8f9a10" + ] + }, + { + "owner": "govoplan-identity-trust", + "revisions": [ + "c3f5a7b9d1e2" + ] + }, + { + "owner": "govoplan-idm", + "revisions": [ + "c2d3e4f5a6b7" + ] + }, + { + "owner": "govoplan-mail", + "revisions": [ + "a4c5d6e7f809" + ] + }, + { + "owner": "govoplan-mandates", + "revisions": [ + "a8b1c2d3e4f5" + ] + }, + { + "owner": "govoplan-notifications", + "revisions": [ + "6e2f91ab4c70" + ] + }, + { + "owner": "govoplan-organizations", + "revisions": [ + "a61e4d9c72b8" + ] + }, + { + "owner": "govoplan-parties", + "revisions": [ + "c0d3e4f5a6b7" + ] + }, + { + "owner": "govoplan-payments", + "revisions": [ + "e7b9c1d3f5a7" + ] + }, + { + "owner": "govoplan-policy", + "revisions": [ + "a9c4e7b2d5f8" + ] + }, + { + "owner": "govoplan-poll", + "revisions": [ + "6e7f8a9b0c1d" + ] + }, + { + "owner": "govoplan-postbox", + "revisions": [ + "d8b4f1a6c9e2" + ] + }, + { + "owner": "govoplan-projects", + "revisions": [ + "c4a1e8f2d6b9" + ] + }, + { + "owner": "govoplan-quick-access", + "revisions": [ + "9a4e6c2d8f10" + ] + }, + { + "owner": "govoplan-records", + "revisions": [ + "8a6c4e2f1b3d" + ] + }, + { + "owner": "govoplan-reporting", + "revisions": [ + "c8d5e2f6a9b3" + ] + }, + { + "owner": "govoplan-risk-compliance", + "revisions": [ + "b9c0d1e2f3a4" + ] + }, + { + "owner": "govoplan-scheduling", + "revisions": [ + "d7a4c1e8f205" + ] + }, + { + "owner": "govoplan-search", + "revisions": [ + "b2c3d4e5f607" + ] + }, + { + "owner": "govoplan-services", + "revisions": [ + "b9c2d3e4f5a6" + ] + }, + { + "owner": "govoplan-tasks", + "revisions": [ + "7c4d9a2e1f30" + ] + }, + { + "owner": "govoplan-templates", + "revisions": [ + "a3f7c9d2e1b4" + ] + }, + { + "owner": "govoplan-tenancy", + "revisions": [ + "b3d8e1f4a6c2" + ] + }, + { + "owner": "govoplan-tickets", + "revisions": [ + "8d1f4b7a2c5e" + ] + }, + { + "owner": "govoplan-views", + "revisions": [ + "c6f2a9d4e7b1" + ] + }, + { + "owner": "govoplan-voting", + "revisions": [ + "8b9c0d1e2f3a" + ] + }, + { + "owner": "govoplan-wiki", + "revisions": [ + "a7c2e9f4b1d6" + ] + }, + { + "owner": "govoplan-workflow-engine", + "revisions": [ + "8d5a2f7c1b4e" + ] + } + ], + "recorded_at": "2026-09-07T23:29:20Z", + "release": "0.1.45", + "squash_policy": "reviewed-manual", + "track": "release" } ], "version": 1 diff --git a/pyproject.toml b/pyproject.toml index 08c923e..496ee9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.44" +version = "0.1.45" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py index 2f59d5f..9d2fcbe 100644 --- a/src/govoplan_core/api/v1/schemas.py +++ b/src/govoplan_core/api/v1/schemas.py @@ -95,6 +95,13 @@ class TenantMembershipInfo(TenantInfo): is_active: bool = True +class NavigationSeparatorPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str = Field(pattern=r"^separator:[a-zA-Z0-9_.:-]+$", max_length=255) + label: str = Field(default="", max_length=120, pattern=r"^[^\x00-\x1f]*$") + + class NavigationPreferencesPayload(BaseModel): model_config = ConfigDict(extra="forbid") @@ -102,6 +109,7 @@ class NavigationPreferencesPayload(BaseModel): order: list[str] = Field(default_factory=list, max_length=256) hidden: list[str] = Field(default_factory=list, max_length=256) locked: list[str] = Field(default_factory=list, max_length=256) + separators: list[NavigationSeparatorPayload] | None = Field(default=None, max_length=256) class AppearanceModeOverrides(BaseModel): diff --git a/src/govoplan_core/celery_app.py b/src/govoplan_core/celery_app.py index c8046f4..f45be64 100644 --- a/src/govoplan_core/celery_app.py +++ b/src/govoplan_core/celery_app.py @@ -780,8 +780,9 @@ def send_email(self, job_id: str): """Send one explicitly queued campaign job. SMTP failures are persisted but are not retried implicitly. A worker-loss - redelivery is safe because the delivery service converts an unfinished - SMTP attempt into ``outcome_unknown`` instead of transmitting again. + redelivery leaves an active delivery claim unchanged instead of transmitting + again. Explicit fenced recovery requires stopped-owner evidence before an + abandoned attempt can become ``outcome_unknown`` for reconciliation. """ from govoplan_core.db.session import get_database diff --git a/src/govoplan_core/core/configuration_control.py b/src/govoplan_core/core/configuration_control.py index e9c565e..0088c95 100644 --- a/src/govoplan_core/core/configuration_control.py +++ b/src/govoplan_core/core/configuration_control.py @@ -371,6 +371,24 @@ def _approval_count(request: dict[str, Any]) -> int: def _sanitize_value(key: str, value: object) -> object: + if key == "campaign_archive_encryption_policy": + if not isinstance(value, dict): + return "" + # These are format/channel names, never passwords. Preserve only the + # exact public enum lists so rollback history remains useful without + # exempting arbitrary password-named fields from secret redaction. + allowed_values = { + "allowed_password_encryption_methods": frozenset({"aes", "zip_standard"}), + "allowed_password_delivery_channels": frozenset({"separate_mail", "sms", "letter", "phone", "in_person"}), + } + return { + name: list(items) + if name in allowed_values + and isinstance(items, list) + and all(isinstance(item, str) and item in allowed_values[name] for item in items) + else "" + for name, items in value.items() + } field = classify_configuration_field(key) if field is not None and field.secret_handling in {"reference_only", "env_only"}: return _redact_secrets(value) diff --git a/src/govoplan_core/core/configuration_safety.py b/src/govoplan_core/core/configuration_safety.py index 78d8dd8..e0a676c 100644 --- a/src/govoplan_core/core/configuration_safety.py +++ b/src/govoplan_core/core/configuration_safety.py @@ -107,6 +107,20 @@ class _ConfigurationChangeSafetyState: _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = ( + ConfigurationFieldSafety( + key="campaign_delivery_policy.system", label="System Campaign synchronous delivery limit", + owner_module="campaigns", scope="system", storage="system_settings", ui_managed=True, + risk="medium", required_scopes=("system:settings:write",), + audit_event="campaign.delivery_policy_updated", rollback_history_required=True, + notes="A bounded 0–500 recipient-job maximum for one interactive Send now request. Explicit deployment ceilings remain authoritative; saving never delivers mail or changes review evidence.", + ), + ConfigurationFieldSafety( + key="campaign_delivery_policy.tenant", label="Tenant Campaign synchronous delivery limit", + owner_module="campaigns", scope="tenant", storage="tenant_settings", ui_managed=True, + risk="medium", required_scopes=("admin:policies:write",), + audit_event="campaign.delivery_policy_updated", rollback_history_required=True, + notes="Tenant policy may only narrow the inherited system/deployment recipient-job maximum; clearing an override restores inheritance. Changes retain before/after history.", + ), ConfigurationFieldSafety( key="module_management.desired_enabled", label="Enabled modules", @@ -171,6 +185,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = ( rollback_history_required=True, notes="Maintenance mode controls platform availability and gates dangerous operations.", ), + ConfigurationFieldSafety( + key="campaign_archive_encryption_policy", + label="Campaign archive encryption policy", + owner_module="policy", + scope="system", + storage="policy_overrides", + ui_managed=True, + risk="high", + required_scopes=("system:settings:write", "admin:policies:write"), + validation_required=True, + policy_explanation_required=True, + audit_event="campaign_archive_encryption_policy.updated", + rollback_history_required=True, + notes="Explicit system ceiling for Campaign archive methods and separate password-delivery channels. Policy validates allowed values and retains before/after history; lower scopes may only narrow. Legacy use additionally requires the dedicated Campaign permission and reasoned weak-encryption acknowledgement, so saving policy alone never enables or sends an archive.", + ), ConfigurationFieldSafety( key="privacy_retention_policy", label="Privacy retention policy", diff --git a/src/govoplan_core/core/navigation.py b/src/govoplan_core/core/navigation.py index 34e5cb9..a05a1b4 100644 --- a/src/govoplan_core/core/navigation.py +++ b/src/govoplan_core/core/navigation.py @@ -8,11 +8,22 @@ NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1" _MAX_ITEMS = 256 +@dataclass(frozen=True, slots=True) +class NavigationSeparator: + id: str + label: str = "" + + def as_dict(self) -> dict[str, str]: + return {"id": self.id, "label": self.label} + + @dataclass(frozen=True, slots=True) class NavigationPreferences: order: tuple[str, ...] = () hidden: tuple[str, ...] = () locked: tuple[str, ...] = () + # None preserves inherited grouping; an empty tuple explicitly removes it. + separators: tuple[NavigationSeparator, ...] | None = None def as_dict(self) -> dict[str, object]: return { @@ -20,6 +31,7 @@ class NavigationPreferences: "order": list(self.order), "hidden": list(self.hidden), "locked": list(self.locked), + **({"separators": [item.as_dict() for item in self.separators]} if self.separators is not None else {}), } @@ -32,6 +44,9 @@ class EffectiveNavigationItem: order_source: str visibility_source: str lock_source: str | None = None + section: NavigationSeparator | None = None + custom_layout: bool = False + layout_source: str = "module" def as_dict(self) -> dict[str, object]: return { @@ -42,6 +57,9 @@ class EffectiveNavigationItem: "navigation_order_source": self.order_source, "navigation_visibility_source": self.visibility_source, "navigation_lock_source": self.lock_source, + "navigation_section": self.section.as_dict() if self.section else None, + "navigation_custom_layout": self.custom_layout, + "navigation_layout_source": self.layout_source, } @@ -63,6 +81,7 @@ def navigation_preferences_from_mapping( order=_ids(raw.get("order")), hidden=_ids(raw.get("hidden")), locked=_ids(raw.get("locked")), + separators=_separators(raw.get("separators")), ) @@ -95,6 +114,9 @@ def resolve_navigation_preferences( visibility = {item_id: True for item_id in ordered} visibility_source = {item_id: "module" for item_id in ordered} locks: dict[str, str] = {} + separators: dict[str, NavigationSeparator] = {} + custom_layout = False + layout_source = "module" for source, preferences, may_lock in ( ("system", system, True), @@ -103,7 +125,13 @@ def resolve_navigation_preferences( ): if preferences is None: continue - requested_order = [item_id for item_id in preferences.order if item_id in available] + if preferences.separators is not None: + separators = {item.id: item for item in preferences.separators if item.id not in available} + ordered = [item_id for item_id in ordered if item_id in available or item_id in separators] + ordered.extend(item_id for item_id in separators if item_id not in ordered) + custom_layout = True + layout_source = source + requested_order = list(dict.fromkeys(item_id for item_id in preferences.order if item_id in available or item_id in separators)) if requested_order: requested = set(requested_order) ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)] @@ -127,8 +155,13 @@ def resolve_navigation_preferences( visibility[item_id] = True visibility_source[item_id] = source - return { - item_id: EffectiveNavigationItem( + result: dict[str, EffectiveNavigationItem] = {} + section: NavigationSeparator | None = None + for index, item_id in enumerate(ordered): + if item_id in separators: + section = separators[item_id] + continue + result[item_id] = EffectiveNavigationItem( id=item_id, order=index, visible=visibility[item_id], @@ -136,9 +169,29 @@ def resolve_navigation_preferences( order_source=order_source[item_id], visibility_source=visibility_source[item_id], lock_source=locks.get(item_id), + section=section, + custom_layout=custom_layout, + layout_source=layout_source, ) - for index, item_id in enumerate(ordered) - } + return result + + +def _separators(value: object) -> tuple[NavigationSeparator, ...] | None: + if not isinstance(value, (list, tuple)): + return None + items: dict[str, NavigationSeparator] = {} + for raw in value[:_MAX_ITEMS]: + if not isinstance(raw, Mapping): + continue + item_id = _clean_id(raw.get("id")) + label = raw.get("label", "") + if not item_id.startswith("separator:") or not isinstance(label, str): + continue + label = label.strip()[:120] + if any(ord(character) < 32 for character in label): + continue + items[item_id] = NavigationSeparator(item_id, label) + return tuple(items.values()) def _ids(value: object) -> tuple[str, ...]: @@ -168,6 +221,7 @@ __all__ = [ "NAVIGATION_PREFERENCES_CONTRACT_VERSION", "NAVIGATION_PREFERENCES_KEY", "NavigationPreferences", + "NavigationSeparator", "navigation_preferences_from_mapping", "navigation_preferences_from_settings", "resolve_navigation_preferences", diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index 965557f..9d34aa0 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -200,6 +200,9 @@ def _nav_item_payload( "visible": scoped.visible, "locked": scoped.locked, "lock_source": scoped.lock_source, + "section": scoped.section.as_dict() if scoped.section else None, + "custom_layout": scoped.custom_layout, + "layout_source": scoped.layout_source, } for scope in ("module", "system", "tenant") if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 11fee99..6804173 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -164,6 +164,9 @@ class Settings(BaseSettings): ge=60, alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS", ) + file_archive_work_root: str | None = Field(default=None, alias="FILE_ARCHIVE_WORK_ROOT") + file_archive_staged_max_bytes: int = Field(default=2 * 1024 ** 3, ge=1, alias="FILE_ARCHIVE_STAGED_MAX_BYTES") + file_archive_staged_per_actor: int = Field(default=4, ge=1, le=64, alias="FILE_ARCHIVE_STAGED_PER_ACTOR") auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME") auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME") diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 19b8316..94b855a 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -5271,6 +5271,36 @@ class ApiSmokeTests(unittest.TestCase): visible_key = next(item for item in visible_revoked_delta.json()["api_keys"] if item["id"] == key_id) self.assertIsNotNone(visible_key["revoked_at"]) + def test_navigation_separator_layout_survives_system_tenant_and_personal_saves(self) -> None: + headers, _ = self._login() + system = self.client.get("/api/v1/admin/system/settings", headers=headers).json() + layout = {"contract_version": "1", "order": ["files.navigation.files", "separator:mail", "mail.navigation.mail"], "hidden": [], "locked": ["mail.navigation.mail"], "separators": [{"id": "separator:mail", "label": "Nachrichten"}]} + saved = self.client.patch("/api/v1/admin/system/settings", headers=headers, json={ + **{key: system[key] for key in ("default_locale", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys")}, "navigation": layout, + }) + self.assertEqual(200, saved.status_code, saved.text) + self.assertEqual(layout, saved.json()["navigation"]) + tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json() + tenant_layout = {**layout, "locked": []} + saved = self.client.patch("/api/v1/admin/tenant/settings", headers=headers, json={"default_locale": tenant["default_locale"], "navigation": tenant_layout}) + self.assertEqual(200, saved.status_code, saved.text) + self.assertEqual(tenant_layout, saved.json()["navigation"]) + personal_layout = {**tenant_layout, "order": ["mail.navigation.mail", "separator:personal", "files.navigation.files"], "hidden": ["mail.navigation.mail"], "separators": [{"id": "separator:personal", "label": "Meine Arbeit"}]} + saved = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": personal_layout}}) + self.assertEqual(200, saved.status_code, saved.text) + self.assertEqual(personal_layout, saved.json()["user"]["ui_preferences"]["navigation"]) + loaded = self.client.get("/api/v1/auth/profile", headers=headers) + self.assertEqual(personal_layout, loaded.json()["user"]["ui_preferences"]["navigation"]) + from govoplan_core.core.navigation import navigation_preferences_from_mapping, resolve_navigation_preferences + # HTTP persistence is checked above; scope projection stays independently deterministic. + resolved = resolve_navigation_preferences(("files.navigation.files", "mail.navigation.mail"), system=navigation_preferences_from_mapping(layout), tenant=navigation_preferences_from_mapping(tenant_layout), user=navigation_preferences_from_mapping(personal_layout)) + self.assertTrue(resolved["mail.navigation.mail"].visible) + self.assertTrue(resolved["mail.navigation.mail"].locked) + self.assertEqual("Meine Arbeit", resolved["files.navigation.files"].section.label) + reset = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": None}}) + self.assertEqual(200, reset.status_code, reset.text) + self.assertIsNone(reset.json()["user"]["ui_preferences"]["navigation"]) + def test_settings_deltas_track_sections_and_system_language_dependency(self) -> None: headers, _ = self._login() @@ -6860,6 +6890,7 @@ class ApiSmokeTests(unittest.TestCase): "order": ["files.navigation.files", "mail.navigation.mail"], "hidden": ["mail.navigation.mail"], "locked": [], + "separators": None, }, }, ) diff --git a/tests/test_navigation_preferences.py b/tests/test_navigation_preferences.py index c03bbc3..b46edb9 100644 --- a/tests/test_navigation_preferences.py +++ b/tests/test_navigation_preferences.py @@ -4,6 +4,7 @@ import unittest from govoplan_core.core.navigation import ( NavigationPreferences, + NavigationSeparator, navigation_preferences_from_settings, resolve_navigation_preferences, update_navigation_preferences, @@ -11,6 +12,46 @@ from govoplan_core.core.navigation import ( class NavigationPreferenceTests(unittest.TestCase): + def test_separator_order_and_labels_round_trip_across_scopes(self) -> None: + separator = NavigationSeparator("separator:work", "Arbeit") + preferences = NavigationPreferences(order=("dashboard", separator.id, "files", "mail"), separators=(separator,)) + stored = navigation_preferences_from_settings(update_navigation_preferences({}, preferences)) + self.assertEqual(preferences, stored) + resolved = resolve_navigation_preferences(("dashboard", "files", "mail"), system=stored) + self.assertIsNone(resolved["dashboard"].section) + self.assertEqual(separator, resolved["files"].section) + self.assertEqual(separator, resolved["mail"].section) + self.assertTrue(resolved["dashboard"].custom_layout) + self.assertEqual("system", resolved["files"].layout_source) + self.assertNotIn(separator.id, resolved) # Never an authorized destination. + + def test_personal_separator_override_and_flat_reset_preserve_locks(self) -> None: + system = NavigationPreferences(order=("separator:system", "files", "mail"), separators=(NavigationSeparator("separator:system", "System"),), locked=("files",)) + user = NavigationPreferences(order=("mail", "separator:user", "files"), hidden=("files",), separators=(NavigationSeparator("separator:user", "Persönlich"),)) + resolved = resolve_navigation_preferences(("files", "mail"), system=system, user=user) + self.assertEqual("user", resolved["files"].layout_source) + self.assertEqual("Persönlich", resolved["files"].section.label) + self.assertTrue(resolved["files"].visible) + self.assertIsNone(resolved["mail"].section) + flat = resolve_navigation_preferences(("files", "mail"), system=system, user=NavigationPreferences(separators=())) + self.assertTrue(flat["files"].custom_layout) + self.assertIsNone(flat["files"].section) + self.assertTrue(flat["files"].locked) + + def test_legacy_preferences_inherit_separator_layout(self) -> None: + separator = NavigationSeparator("separator:work", "Work") + resolved = resolve_navigation_preferences(("files", "mail"), system=NavigationPreferences(order=(separator.id, "files", "mail"), separators=(separator,)), user=NavigationPreferences(hidden=("mail",))) + self.assertEqual(separator, resolved["files"].section) + self.assertEqual("system", resolved["files"].layout_source) + self.assertFalse(resolved["mail"].visible) + + def test_separator_schema_rejects_unsafe_and_unknown_fields(self) -> None: + from pydantic import ValidationError + from govoplan_core.api.v1.schemas import NavigationPreferencesPayload + for separator in ({"id": "files", "label": "Invalid"}, {"id": "separator:ok", "label": "bad\nlabel"}, {"id": "separator:ok", "route": "/admin"}): + with self.subTest(separator=separator), self.assertRaises(ValidationError): + NavigationPreferencesPayload.model_validate({"separators": [separator]}) + def test_user_order_overrides_tenant_and_system_order(self) -> None: resolved = resolve_navigation_preferences( ("dashboard", "files", "mail", "campaign"), diff --git a/tests/test_ownership_history_migration.py b/tests/test_ownership_history_migration.py new file mode 100644 index 0000000..684c289 --- /dev/null +++ b/tests/test_ownership_history_migration.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from alembic import command +from sqlalchemy import create_engine, inspect, select, text +from sqlalchemy.orm import Session + +from govoplan_core.core.ownership import OwnershipTransfer +from govoplan_core.db.migrations import alembic_config + + +class OwnershipHistoryMigrationTests(unittest.TestCase): + def test_upgrade_existing_databases_without_rewriting_ownership(self) -> None: + for track in ("release", "dev"): + for legacy in (True, False): + with self.subTest(track=track, legacy=legacy): + self._verify_upgrade(track, legacy=legacy) + + def _verify_upgrade(self, track: str, *, legacy: bool) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-ownership-upgrade-") as directory: + url = f"sqlite:///{Path(directory) / 'upgrade.db'}" + config = alembic_config(database_url=url, enabled_modules=(), migration_track=track) + command.upgrade(config, "b47e6f809a13") + engine = create_engine(url) + try: + evidence = [{"sequence": 1, "action": "requested"}] + now = datetime.now(timezone.utc) + with Session(engine) as session: + session.add(OwnershipTransfer( + id="transfer-1", tenant_id="tenant-1", resource_module="campaigns", + resource_type="campaign", resource_id="campaign-1", kind="owner_initiated", + status="awaiting_target_acceptance", current_owner_type="user", + current_owner_id="owner-1", target_owner_type="user", target_owner_id="owner-2", + initiated_by_type="user", initiated_by_id="owner-1", reason="Existing request", + approvals=[{"actor_id": "owner-1"}], decisions=evidence, + idempotency_key="request-1", canonical_request_hash="a" * 64, + expires_at=now + timedelta(days=7), revision=3, metadata_={"retained": True}, + created_at=now, updated_at=now, + )) + session.commit() + with engine.begin() as connection: + if legacy: + connection.execute(text("ALTER TABLE core_ownership_transfers DROP COLUMN decisions")) + before = dict(connection.execute(text( + "SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'" + )).mappings().one()) + command.upgrade(config, "c58a2d7e9f10") + command.upgrade(config, "c58a2d7e9f10") + with engine.connect() as connection: + columns = {column["name"]: column for column in inspect(connection).get_columns( + "core_ownership_transfers" + )} + self.assertFalse(columns["decisions"]["nullable"]) + after = dict(connection.execute(text( + "SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'" + )).mappings().one()) + self.assertEqual({key: after[key] for key in before}, before) + with Session(engine) as session: + transfer = session.scalars(select(OwnershipTransfer)).one() + self.assertEqual(transfer.decisions, [] if legacy else evidence) + self.assertEqual(transfer.approvals, [{"actor_id": "owner-1"}]) + transfer.decisions = [*transfer.decisions, {"action": "accepted"}] + session.commit() + command.downgrade(config, "b47e6f809a13") + command.upgrade(config, "c58a2d7e9f10") + with Session(engine) as session: + self.assertEqual(session.get(OwnershipTransfer, "transfer-1").decisions[-1], { + "action": "accepted" + }) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/conformance/AddressExplorerScenario.tsx b/webui/conformance/AddressExplorerScenario.tsx new file mode 100644 index 0000000..b927ef3 --- /dev/null +++ b/webui/conformance/AddressExplorerScenario.tsx @@ -0,0 +1,25 @@ +import AddressBookPage from "../../../govoplan-addresses/webui/src/features/addressbook/AddressBookPage"; +import { generatedTranslations } from "../../../govoplan-addresses/webui/src/i18n/generatedTranslations"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import type { AuthInfo } from "../src/types"; +import "../../../govoplan-addresses/webui/src/styles/addresses.css"; + +const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" }; + +export default function AddressExplorerScenario() { + const params = new URLSearchParams(window.location.search); + const auth: AuthInfo = { + user: { id: "address-fixture-user", account_id: "address-fixture-account", email: "address-fixture@example.test" }, + tenant: { id: "address-fixture-tenant", name: "Address fixture", slug: "address-fixture" }, + scopes: params.has("read-only") ? ["addresses:contact:read"] : [ + "addresses:contact:read", "addresses:contact:write", "addresses:contact:delete", + "addresses:address_book:write", "addresses:address_book:delete", + "addresses:address_list:write", "addresses:address_list:delete", + "addresses:sync:read", "addresses:sync:write", "addresses:governance:read" + ], + roles: [], groups: [], profile_loaded: true, groups_loaded: true, roles_loaded: true + }; + return + + ; +} diff --git a/webui/conformance/CampaignAttachmentsScenario.tsx b/webui/conformance/CampaignAttachmentsScenario.tsx new file mode 100644 index 0000000..d13976a --- /dev/null +++ b/webui/conformance/CampaignAttachmentsScenario.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage"; +import { generatedTranslations as campaignTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import ManagedFileChooser from "../../../govoplan-files/webui/src/features/files/components/ManagedFileChooser"; +import { listFileSpaces } from "../../../govoplan-files/webui/src/api/files"; +import { generatedTranslations as filesTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations"; +import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { PlatformModulesProvider } from "../src/platform/ModuleContext"; +import type { AuthInfo, PlatformWebModule } from "../src/types"; +import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css"; +import "../../../govoplan-files/webui/src/styles/file-manager.css"; + +const auth: AuthInfo = { user: { id: "user-1", account_id: "account-1", email: "fixture@example.test" }, + tenant: { id: "tenant-1", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:write", "files:file:read"], + roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true }; + +export default function CampaignAttachmentsScenario() { + const [available, setAvailable] = useState(true); + const modules: PlatformWebModule[] = [ + { id: "campaigns", label: "Campaign", version: "fixture" }, + { id: "files", label: "Files", version: "fixture", uiCapabilities: available ? { + "files.fileExplorer": { ManagedFileChooser, listFileSpaces } + } : {} } + ]; + return + + + + + + + ; +} diff --git a/webui/conformance/CampaignBulkReviewScenario.tsx b/webui/conformance/CampaignBulkReviewScenario.tsx new file mode 100644 index 0000000..e2ba193 --- /dev/null +++ b/webui/conformance/CampaignBulkReviewScenario.tsx @@ -0,0 +1,31 @@ +import { useState } from "react"; +import { apiPostJson } from "../src/api/client"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import BulkMessageReviewDialog from "../../../govoplan-campaign/webui/src/features/campaigns/review/BulkMessageReviewDialog"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css"; + +function job(id: string, overrides: Record = {}) { + return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`, subject: `Frozen ${id}`, + review_decision: { eligible: true, category_key: "same-attachment-condition", reason_required: true, issue_codes: ["attachment_match_empty"] }, ...overrides }; +} + +export default function CampaignBulkReviewScenario() { + const params = new URLSearchParams(window.location.search); + const [open, setOpen] = useState(false); + const [buildToken, setBuildToken] = useState("build-one"); + const rows = [...Array.from({ length: params.has("large") ? 205 : 3 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)), + job("reviewed", { reviewed: true }), job("blocked", { validation_status: "blocked", review_decision: { eligible: false } }), + job("expected-exclusion", { validation_status: "excluded", review_decision: { eligible: false } }), + job("allowed-zero", { validation_status: "warning", review_decision: { eligible: false } }), + job("other-category", { review_decision: { eligible: true, category_key: "other-condition", reason_required: false, issue_codes: ["address_warning"] } })]; + return +
+ + {open && setOpen(false)} onAccept={async (selection) => { + await apiPostJson({ apiBaseUrl: "", apiKey: "", accessToken: "" }, "/api/v1/conformance/review-state", selection); + }} />} +
+
; +} diff --git a/webui/conformance/CampaignDeliveryPolicyScenario.tsx b/webui/conformance/CampaignDeliveryPolicyScenario.tsx new file mode 100644 index 0000000..7a77165 --- /dev/null +++ b/webui/conformance/CampaignDeliveryPolicyScenario.tsx @@ -0,0 +1,10 @@ +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import CampaignDeliveryPolicyPanel from "../../../govoplan-campaign/webui/src/features/admin/CampaignDeliveryPolicyPanel"; + +export default function CampaignDeliveryPolicyScenario() { + const params = new URLSearchParams(window.location.search); + return + + ; +} diff --git a/webui/conformance/CampaignDeliveryProgressScenario.tsx b/webui/conformance/CampaignDeliveryProgressScenario.tsx new file mode 100644 index 0000000..dbe221f --- /dev/null +++ b/webui/conformance/CampaignDeliveryProgressScenario.tsx @@ -0,0 +1,19 @@ +import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { PlatformModulesProvider } from "../src/platform/ModuleContext"; +import type { AuthInfo, PlatformWebModule } from "../src/types"; +import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css"; +const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }]; +const auth: AuthInfo = { user: { id: "sender", account_id: "sender-account", email: "sender@example.test" }, + tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:campaign:review", + "campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:campaign:send", "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:control"], + roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true }; +export default function CampaignDeliveryProgressScenario() { + const params = new URLSearchParams(window.location.search); + return + + + + ; +} diff --git a/webui/conformance/CampaignMailSettingsScenario.tsx b/webui/conformance/CampaignMailSettingsScenario.tsx new file mode 100644 index 0000000..42575c6 --- /dev/null +++ b/webui/conformance/CampaignMailSettingsScenario.tsx @@ -0,0 +1,25 @@ +// Real optional-module settings surface with test-owned HTTP responses only. +import MailSettingsPage from "../../../govoplan-campaign/webui/src/features/campaigns/MailSettingsPage"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { PlatformModulesProvider } from "../src/platform/ModuleContext"; +import type { PlatformWebModule } from "../src/types"; +import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css"; + +const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" }; +const modules: PlatformWebModule[] = [ + { id: "campaigns", label: "Campaign", version: "fixture" }, + { id: "mail", label: "Mail", version: "fixture" } +]; + +export default function CampaignMailSettingsScenario() { + const language = new URLSearchParams(window.location.search).get("language") ?? "en"; + return + + + + + + ; +} diff --git a/webui/conformance/CampaignRecipientOrderScenario.tsx b/webui/conformance/CampaignRecipientOrderScenario.tsx new file mode 100644 index 0000000..a84e502 --- /dev/null +++ b/webui/conformance/CampaignRecipientOrderScenario.tsx @@ -0,0 +1,66 @@ +import { useCallback, useState } from "react"; +import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor"; +import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns"; +import { + HeaderAddressEditorDialog, RecipientAddressEditorDialog, entryWithAddressValues, getAddressColumn, + type HeaderAddressValues +} from "../../../govoplan-campaign/webui/src/features/campaigns/recipients/RecipientAddressEditor"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css"; + +const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" }; +const initialAddresses = [ + { name: "Alpha", email: "alpha@example.test" }, + { name: "Beta", email: "beta@example.test" }, + { name: "Zulu", email: "zulu@example.test" } +]; +const initialVersion: CampaignVersionDetail = { + id: "order-version", campaign_id: "order-campaign", version_number: 1, edit_revision: 1, strong_etag: '"order-version:1"', + current_flow: "manual", current_step: "recipients", workflow_state: "editing", is_complete: false, + raw_json: { + campaign: { name: "Recipient ordering" }, server: {}, + recipients: { allow_individual_to: true, to: initialAddresses }, + entries: { defaults: {}, inline: [{ id: "entry-1", name: "Alpha", email: "alpha@example.test", to: initialAddresses }] } + }, editor_state: {}, updated_at: "2026-09-07T10:00:00Z" +}; + +export default function CampaignRecipientOrderScenario() { + return + + ; +} + +function RecipientOrderEditor() { + const [version, setVersion] = useState(initialVersion); + const [error, setError] = useState(""); + const [dialog, setDialog] = useState<"entry" | "header" | null>(null); + const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "order-campaign", "order-version")), []); + const editor = useCampaignDraftEditor({ settings, campaignId: "order-campaign", version, locked: false, + reload, setError, currentStep: "recipients", unsavedTitle: "Unsaved recipient order", unsavedMessage: "Save or discard the campaign draft?" }); + const entries = editor.displayDraft.entries as { defaults: Record; inline: Record[] }; + const recipients = editor.displayDraft.recipients as Record; + const entry = entries?.inline?.[0] ?? {}; + return
+ + + + {JSON.stringify(entry.to)} + {JSON.stringify(recipients?.to)} + {String(entry.email ?? "")} + {String(editor.dirty)} + {editor.localError || error} + {dialog === "entry" && { + // Same owning helper as RecipientDataPage; no test copy of ordering or + // legacy primary-address/merge-field normalization. + editor.patch(["entries", "inline"], [entryWithAddressValues(entry, values, merges)]); + setDialog(null); + }} onClose={() => setDialog(null)} />} + {dialog === "header" && { editor.patch(["recipients", "to"], values.to); setDialog(null); }} onClose={() => setDialog(null)} />} +
; +} diff --git a/webui/conformance/CampaignReportScenario.tsx b/webui/conformance/CampaignReportScenario.tsx new file mode 100644 index 0000000..a62809a --- /dev/null +++ b/webui/conformance/CampaignReportScenario.tsx @@ -0,0 +1,23 @@ +import CampaignReportPage from "../../../govoplan-campaign/webui/src/features/campaigns/CampaignReportPage"; +import { useState } from "react"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { PlatformModulesProvider } from "../src/platform/ModuleContext"; +import type { AuthInfo, PlatformWebModule } from "../src/types"; +import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css"; +const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }]; +export default function CampaignReportScenario() { + const params = new URLSearchParams(window.location.search); + const [account, setAccount] = useState("reporter"); + const read = ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:report:read"]; + const auth: AuthInfo = { user: { id: account, account_id: `${account}-account`, email: "reporter@example.test" }, + tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: params.has("read-only") ? read : + [...read, "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:queue", "campaigns:campaign:reconcile"], + roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true }; + return + + {params.has("switch-account") && } + + + ; +} diff --git a/webui/conformance/CampaignReviewDetailsScenario.tsx b/webui/conformance/CampaignReviewDetailsScenario.tsx new file mode 100644 index 0000000..9bc0062 --- /dev/null +++ b/webui/conformance/CampaignReviewDetailsScenario.tsx @@ -0,0 +1,20 @@ +import ValidationDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/ValidationDetails"; +import RepeatedFilesDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/RepeatedFilesDetails"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { useState } from "react"; + +const issues = Array.from({ length: 12 }, (_, index) => [ + { code: "missing_required_attachment", severity: "warning", path: `/entries/recipient-${index + 1}/attachments/0`, message: `Missing attachment for recipient ${index + 1}.` }, + { code: "missing_attachment_coverage", severity: "info", path: `/entries/recipient-${index + 1}`, message: `Policy excludes recipient ${index + 1} without attachments.` } +]).flat(); +const findings = Array.from({ length: 12 }, (_, index) => ({ file_name: `repeated-file-${index + 1}.pdf`, file_fingerprint: `file-${index}`, use_count: 3, disposition: "allowed" })); + +export default function CampaignReviewDetailsScenario() { + const [count, setCount] = useState(12); + return + +
+
+
; +} diff --git a/webui/conformance/CampaignReviewScenario.tsx b/webui/conformance/CampaignReviewScenario.tsx new file mode 100644 index 0000000..5be1612 --- /dev/null +++ b/webui/conformance/CampaignReviewScenario.tsx @@ -0,0 +1,24 @@ +import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage"; +import { useSearchParams } from "react-router"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { PlatformModulesProvider } from "../src/platform/ModuleContext"; +import type { AuthInfo, PlatformWebModule } from "../src/types"; +import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css"; +const auth: AuthInfo = { user: { id: "reviewer", account_id: "reviewer-account", email: "reviewer@example.test" }, + tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:campaign:review", "campaigns:campaign:build"], + roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true }; +const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }]; +export default function CampaignReviewScenario() { + const params = new URLSearchParams(window.location.search); + const [, setSearchParams] = useSearchParams(); + const fixtureAuth = params.has("read-only") ? { ...auth, scopes: ["campaigns:campaign:read"] } : auth; + return + + {params.has("race") && } + + + ; +} diff --git a/webui/conformance/CampaignSavingScenario.tsx b/webui/conformance/CampaignSavingScenario.tsx new file mode 100644 index 0000000..0bce006 --- /dev/null +++ b/webui/conformance/CampaignSavingScenario.tsx @@ -0,0 +1,44 @@ +import { useCallback, useState } from "react"; +import { Link } from "react-router"; +import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor"; +import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns"; +import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations"; + +const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" }; +const initialVersion: CampaignVersionDetail = { + id: "version-a", campaign_id: "campaign-a", version_number: 1, edit_revision: 1, strong_etag: '"version-a:1"', + current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false, + raw_json: { campaign: { name: "Original" }, template: { subject: "Original subject", text: "" }, server: {} }, + editor_state: {}, updated_at: "2026-09-07T10:00:00Z" +}; + +export default function CampaignSavingScenario() { + return + + ; +} + +function SavingEditor() { + const [version, setVersion] = useState(initialVersion); + const [error, setError] = useState(""); + const [result, setResult] = useState(""); + const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "campaign-a", "version-a")), []); + const editor = useCampaignDraftEditor({ settings, campaignId: "campaign-a", version, locked: false, + reload, setError, currentStep: "template", unsavedTitle: "Unsaved fixture changes", unsavedMessage: "Save or discard the draft?" }); + const subject = (editor.displayDraft.template as Record)?.subject ?? ""; + return
+ + + + + + Other route + {String(editor.saving)} + {String(editor.dirty)} + {editor.saveState} + {editor.localError || error} + {result} +
; +} diff --git a/webui/conformance/CampaignWorkspaceScenario.tsx b/webui/conformance/CampaignWorkspaceScenario.tsx new file mode 100644 index 0000000..7245db1 --- /dev/null +++ b/webui/conformance/CampaignWorkspaceScenario.tsx @@ -0,0 +1,28 @@ +// Exercise the owning Campaign hook, with network responses controlled by tests. +import { useMemo, useState } from "react"; +import { useSearchParams } from "react-router"; +import { useCampaignWorkspaceData } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignWorkspaceData"; + +export default function CampaignWorkspaceScenario() { + const [campaignId, setCampaignId] = useState("campaign-a"); + const [accessToken, setAccessToken] = useState("token-a"); + const [, setSearchParams] = useSearchParams(); + const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]); + const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeVersions: true }); + return
+ + + + + + {data.campaign?.id ?? "none"} + {data.currentVersion?.id ?? "none"} + {data.currentVersion?.edit_revision ?? 0} + {String(loading)} + {error} +
; +} diff --git a/webui/conformance/ConformanceApp.tsx b/webui/conformance/ConformanceApp.tsx index 20e4611..6416290 100644 --- a/webui/conformance/ConformanceApp.tsx +++ b/webui/conformance/ConformanceApp.tsx @@ -1,6 +1,34 @@ import { useMemo, useState } from "react"; import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react"; import { useLocation } from "react-router"; +import DialogLayoutScenario from "./DialogLayoutScenario"; +import DataGridLayoutScenario from "./DataGridLayoutScenario"; +import NavigationLayoutScenario from "./NavigationLayoutScenario"; +import ManagedArchiveScenario from "./ManagedArchiveScenario"; +import FilesToolbarScenario from "./FilesToolbarScenario"; +import CredentialReferencesScenario from "./CredentialReferencesScenario"; +import FormControlLayoutScenario from "./FormControlLayoutScenario"; +import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario"; +import CampaignReportScenario from "./CampaignReportScenario"; +import ModuleLayoutScenario from "./ModuleLayoutScenario"; +import HelpCenterScenario from "./HelpCenterScenario"; +import NotificationFilterScenario from "./NotificationFilterScenario"; +import MultiSelectFilterScenario from "./MultiSelectFilterScenario"; +import SearchFiltersScenario from "./SearchFiltersScenario"; +import AddressExplorerScenario from "./AddressExplorerScenario"; +import MailFolderExplorerScenario from "./MailFolderExplorerScenario"; +import MailToolbarScenario from "./MailToolbarScenario"; +import CampaignDeliveryProgressScenario from "./CampaignDeliveryProgressScenario"; +import CampaignSavingScenario from "./CampaignSavingScenario"; +import CampaignMailSettingsScenario from "./CampaignMailSettingsScenario"; +import CampaignAttachmentsScenario from "./CampaignAttachmentsScenario"; +import CampaignRecipientOrderScenario from "./CampaignRecipientOrderScenario"; +import CampaignReviewScenario from "./CampaignReviewScenario"; +import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario"; +import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario"; +import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario"; +import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario"; +import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout"; import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage"; import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage"; import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage"; @@ -28,6 +56,7 @@ import StatePanel from "../src/components/StatePanel"; import WorkspaceFrame from "../src/components/WorkspaceFrame"; import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; +import WysiwygEditor from "../src/components/WysiwygEditor"; import BreadcrumbBar from "../src/layout/BreadcrumbBar"; import HelpMenu from "../src/layout/HelpMenu"; import IconRail from "../src/layout/IconRail"; @@ -54,6 +83,49 @@ export default function ConformanceApp() { const [editorDirty, setEditorDirty] = useState(true); const [metricDrilldown, setMetricDrilldown] = useState(""); + if (new URLSearchParams(location.search).has("credential-references")) return ; + if (new URLSearchParams(location.search).has("files-toolbar")) return ; + if (new URLSearchParams(location.search).has("form-control-layout")) return ; + if (new URLSearchParams(location.search).has("campaign-workspace")) return ; + if (new URLSearchParams(location.search).has("campaign-report")) return ; + if (new URLSearchParams(location.search).has("module-layouts")) return ; + if (new URLSearchParams(location.search).has("help-center")) return ; + if (new URLSearchParams(location.search).has("notification-filter")) return ; + if (new URLSearchParams(location.search).has("multi-select-filter")) return ; + if (new URLSearchParams(location.search).has("search-filters")) return ; + if (new URLSearchParams(location.search).has("address-explorer")) return ; + if (new URLSearchParams(location.search).has("mail-folder-explorer")) return ; + if (new URLSearchParams(location.search).has("mail-toolbar")) return ; + if (new URLSearchParams(location.search).has("campaign-delivery-progress")) return ; + if (new URLSearchParams(location.search).has("campaign-saving")) return ; + if (new URLSearchParams(location.search).has("campaign-mail-settings")) return ; + if (new URLSearchParams(location.search).has("campaign-attachments")) return ; + if (new URLSearchParams(location.search).has("campaign-recipient-order")) return ; + if (new URLSearchParams(location.search).has("campaign-review")) return ; + if (new URLSearchParams(location.search).has("campaign-bulk-review")) return ; + if (new URLSearchParams(location.search).has("campaign-review-details")) return ; + if (new URLSearchParams(location.search).has("campaign-delivery-policy")) return ; + if (new URLSearchParams(location.search).has("mail-credential-policy")) return ; + + if (new URLSearchParams(location.search).has("data-grid-layout")) return ; + if (new URLSearchParams(location.search).has("managed-archive")) { + const params = new URLSearchParams(location.search); + return ; + } + if (new URLSearchParams(location.search).has("navigation-layout")) { + const params = new URLSearchParams(location.search); + return ; + } + + if (new URLSearchParams(location.search).has("dialog-layout")) { + const params = new URLSearchParams(location.search); + return ; + } + + if (new URLSearchParams(location.search).has("wysiwyg-lifecycle")) { + return ; + } + if (new URLSearchParams(location.search).has("product-navigation")) { return ; } @@ -200,6 +272,35 @@ export default function ConformanceApp() { ); } +function WysiwygLifecycleScenario({ source }: { source: boolean }) { + const initialHtml = source + ? '
Legacy template
' + : "

Legacy template

"; + const [value, setValue] = useState(initialHtml); + const [changeCount, setChangeCount] = useState(0); + const [disabled, setDisabled] = useState(false); + const [mounted, setMounted] = useState(true); + + return
+

Rich-text lifecycle fixture

+ + + + {changeCount} +
{value}
+ {mounted && { + setValue(nextValue); + setChangeCount((current) => current + 1); + }} + />} +
; +} + function ProductNavigationScenario() { const projection = useMemo( () => projectProductNavigation( diff --git a/webui/conformance/CredentialReferencesScenario.tsx b/webui/conformance/CredentialReferencesScenario.tsx new file mode 100644 index 0000000..6614127 --- /dev/null +++ b/webui/conformance/CredentialReferencesScenario.tsx @@ -0,0 +1,18 @@ +// Exercise the real shared credential editor with Mail's optional capability. +// API fixtures supply public metadata only; this never loads or saves secrets. +import CredentialEnvelopeManager from "../src/components/CredentialEnvelopeManager"; +import { PlatformModulesProvider } from "../src/platform/ModuleContext"; +import { mailCredentialReferenceSelectors } from "../../../govoplan-mail/webui/src/features/mail/mailReferenceProviders"; +import type { ApiSettings, PlatformWebModule } from "../src/types"; + +const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" }; +const modules: PlatformWebModule[] = [{ + id: "mail", label: "Mail", version: "1", + uiCapabilities: { "core.credentialReferenceSelectors": mailCredentialReferenceSelectors } +}]; + +export default function CredentialReferencesScenario() { + return + + ; +} diff --git a/webui/conformance/DataGridLayoutScenario.tsx b/webui/conformance/DataGridLayoutScenario.tsx new file mode 100644 index 0000000..cfe4f24 --- /dev/null +++ b/webui/conformance/DataGridLayoutScenario.tsx @@ -0,0 +1,66 @@ +import { useMemo, useState } from "react"; +import { ArrowDown, ArrowUp, Eye, Plus, Trash2 } from "lucide-react"; +import Button from "../src/components/Button"; +import DataGrid, { DataGridEmptyAction, type DataGridColumn, type DataGridResizeBehavior } from "../src/components/table/DataGrid"; +import TableActionGroup from "../src/components/table/TableActionGroup"; + +type Row = { id: string; name: string; detail: string }; +const rows: Row[] = [{ id: "alpha", name: "Alpha", detail: "Long configured field value ".repeat(20) }]; + +/** Genuine shared grid: intentionally undersized legacy action preference. */ +export default function DataGridLayoutScenario() { + const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover"; + const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover"; + const composite = mode === "composite"; + const [width, setWidth] = useState(900); + const [mounted, setMounted] = useState(true); + const [empty, setEmpty] = useState(false); + const [extraAction, setExtraAction] = useState(false); + const [clicked, setClicked] = useState(""); + const columns = useMemo[]>(() => [ + { id: "name", header: "Name", width: 260, minWidth: 180, resizable: true, value: (row) => row.name }, + { id: "detail", header: "Details", width: 360, minWidth: 220, resizable: true, value: (row) => row.detail }, + { + id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72, + minWidth: mode === "oversized" ? 500 : undefined, + columnType: composite ? "actions" : undefined, sticky: behavior === "free" ? undefined : "end", + render: (row) => { + const group = , onClick: () => setClicked(`Inspect ${row.name}`) }, + { id: "up", label: "Move up", icon: , disabledReason: "Already first", onClick: () => undefined }, + { id: "down", label: "Move down", icon: , onClick: () => setClicked("Move down") }, + { id: "remove", label: "Remove row", icon: , onClick: () => setClicked("Remove row") }, + extraAction && { id: "add", label: "Add below", icon: , onClick: () => setClicked("Add below") } + ]} />; + return composite ?
+ + {group} +
: group; + } + } + ], [behavior, composite, extraAction, mode]); + return ( +
+

Data grid layout conformance

+
+ + + + + +
+ {clicked} +
+ {mounted && row.id} + initialFit={behavior === "free" ? "content" : "container"} + resizeBehavior={behavior} + emptyAction={ setClicked("Add first row")} />} + />} +
+
+ ); +} diff --git a/webui/conformance/DialogLayoutScenario.tsx b/webui/conformance/DialogLayoutScenario.tsx new file mode 100644 index 0000000..7a0cbe1 --- /dev/null +++ b/webui/conformance/DialogLayoutScenario.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import TemplatesPage from "../../../govoplan-templates/webui/src/features/templates/TemplatesPage"; +import { generatedTranslations as templateTranslations } from "../../../govoplan-templates/webui/src/i18n/generatedTranslations"; +import "../../../govoplan-templates/webui/src/styles/templates.css"; +import Button from "../src/components/Button"; +import Dialog from "../src/components/Dialog"; +import { DialogForm, DialogSection } from "../src/components/DialogAnatomy"; +import { FormGrid } from "../src/components/ContentGrid"; +import FormField from "../src/components/FormField"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; +import type { AuthInfo } from "../src/types"; + +const fixtureAuth: AuthInfo = { + user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" }, + tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" }, + scopes: ["templates:template:read", "templates:template:write"], + roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true +}; + +export default function DialogLayoutScenario({ templates = false, language = "de" }: { templates?: boolean; language?: string }) { + const [open, setOpen] = useState(true); + if (templates) return + + ; + return
+ + setOpen(false)} + footer={<>} + > + event.preventDefault()}> + + + + + + +