From da3947f41789a4099082a8f426110726e4dbc475 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 14:25:02 +0200 Subject: [PATCH] Migrate Dashboard interface patterns --- docs/INTERFACE_PATTERN_MIGRATION.md | 34 ++++++++ src/govoplan_dashboard/backend/manifest.py | 87 +++++++++++++++++++ .../test_interface_documentation_contract.py | 62 +++++++++++++ .../src/features/dashboard/DashboardPage.tsx | 29 +++++-- .../dashboard/WidgetConfigurationDialog.tsx | 68 +++++++++++++-- .../src/features/dashboard/WidgetLibrary.tsx | 1 + .../features/dashboard/interfacePatterns.ts | 18 ++++ webui/src/i18n/generatedTranslations.ts | 78 +++++++++++++++-- webui/src/module.ts | 49 ++++++++++- 9 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 docs/INTERFACE_PATTERN_MIGRATION.md create mode 100644 tests/test_interface_documentation_contract.py create mode 100644 webui/src/features/dashboard/interfacePatterns.ts diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..a4b6e70 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,34 @@ +# Dashboard Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to the +view-specific summary, widget library, four-column placement grid, and widget +configuration dialog. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/dashboard` | Personal workspace | Inspect current task context | Stable loading, fallback, empty, error, and help states | +| Summary | Metric overview | Inspect module/widget/layout state | Installed, available, placed, active View, and persistence source remain distinguishable | +| Widget library | Capability-filtered catalogue | Add or reset placements | Permission/View filtering, search, capacity reason, drag/click parity, and reversible defaults | +| Dashboard grid | Configurable composition | Reorder, resize, configure, or remove placement | Four-column placement contract, visible drag target, guarded draft, and no provider-data mutation | +| Widget settings | Nested definition editor | Apply presentation/query settings | Guarded local draft, contextual field help, validation reason, reset, apply, and discard | + +## Consequence And Availability Rules + +- Layouts are scoped by tenant, account, and active focused View. A focused + View changes the composition key but never grants access to widget data. +- Configure mode edits a local draft. Save persists the complete layout using + its revision; stale concurrent saves are rejected rather than overwritten. +- Removing a widget affects only its placement. Reset restores the defaults + contributed by active modules and remains reversible until Save layout. +- Widgets are filtered by installed module, focused View surface, and declared + scopes. Every provider still authorizes its own requests. +- When the saved layout is unreachable, the browser or module defaults are + clearly identified as a fallback. Saving promotes the current draft to the + account-backed layout. + +Backend and WebUI manifests publish matching page, summary, library, grid, +settings, and built-in-widget surface identifiers. English and German +catalogues cover Dashboard-owned vocabulary, contextual help resolves from +manifest documentation, and both layout and widget-setting drafts are guarded. diff --git a/src/govoplan_dashboard/backend/manifest.py b/src/govoplan_dashboard/backend/manifest.py index 5c08b44..dd21a34 100644 --- a/src/govoplan_dashboard/backend/manifest.py +++ b/src/govoplan_dashboard/backend/manifest.py @@ -72,11 +72,51 @@ manifest = ModuleManifest( routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),), nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), view_surfaces=( + ViewSurface( + id="dashboard.page", + module_id="dashboard", + kind="route", + label="Dashboard", + order=10, + ), + ViewSurface( + id="dashboard.summary", + module_id="dashboard", + kind="section", + label="Dashboard summary", + parent_id="dashboard.page", + order=10, + ), + ViewSurface( + id="dashboard.library", + module_id="dashboard", + kind="section", + label="Widget library", + parent_id="dashboard.page", + order=20, + ), + ViewSurface( + id="dashboard.grid", + module_id="dashboard", + kind="section", + label="Dashboard grid", + parent_id="dashboard.page", + order=30, + ), + ViewSurface( + id="dashboard.widget-settings", + module_id="dashboard", + kind="action", + label="Widget settings", + parent_id="dashboard.grid", + order=40, + ), ViewSurface( id="dashboard.widget.installed-modules", module_id="dashboard", kind="section", label="Installed modules widget", + parent_id="dashboard.grid", order=10, ), ), @@ -96,6 +136,53 @@ manifest = ModuleManifest( audience=("user", "tenant_admin", "operator"), related_modules=("core", "ops"), order=20, + metadata={ + "help_contexts": [ + "dashboard.page", + "dashboard.summary", + "dashboard.library", + "dashboard.grid", + "dashboard.state.browser-fallback", + "dashboard.state.view-specific", + ], + }, + ), + DocumentationTopic( + id="dashboard.reference.layout-and-widgets", + title="Dashboard layouts and widget consequences", + summary="Per-user, per-tenant, and per-View widget placement, sizing, configuration, availability, and fallback semantics.", + body=( + "A Dashboard layout belongs to the active tenant, account, and focused View. " + "Configuring changes only a local draft until Save layout is selected; Cancel or " + "Discard restores the last saved arrangement. Widget removal removes only the placement, " + "not the module data represented by the widget. Reset restores the defaults announced by " + "currently active modules and remains reversible until save. A widget is offered only when " + "its module, focused View surface, and permission contract are available. Widgets never grant " + "access and providers must authorize every data request. If the server layout is unreachable, " + "the browser or module default can be displayed but is identified as a fallback until saved. " + "Concurrent saves use the layout revision and reject stale updates instead of overwriting them." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("user", "tenant_admin", "operator", "module_admin"), + related_modules=("core", "views", "access", "ops"), + order=21, + metadata={ + "help_contexts": [ + "dashboard.action.configure", + "dashboard.action.save", + "dashboard.action.reset", + "dashboard.action.remove-widget", + "dashboard.field.widget-size", + "dashboard.field.widget-configuration", + ], + "consequence_classes": { + "save_layout": "Persists the complete layout for the active tenant, account, and View revision context.", + "reset_layout": "Replaces the draft with current module defaults and remains reversible until save.", + "remove_widget": "Removes only the placement from the draft; provider data is unchanged.", + "configure_widget": "Changes presentation and query preferences for one widget placement.", + }, + }, ), ), architecture=declared_module_architecture( diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..8f7f27f --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_dashboard.backend.manifest import get_manifest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class DashboardInterfaceDocumentationContractTests(unittest.TestCase): + def test_surface_hierarchy_remains_declared(self) -> None: + frontend = get_manifest().frontend + self.assertIsNotNone(frontend) + surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr] + self.assertEqual( + { + "dashboard.page", + "dashboard.summary", + "dashboard.library", + "dashboard.grid", + "dashboard.widget-settings", + "dashboard.widget.installed-modules", + }, + set(surfaces), + ) + self.assertEqual("dashboard.page", surfaces["dashboard.library"].parent_id) + self.assertEqual("dashboard.page", surfaces["dashboard.grid"].parent_id) + self.assertEqual("dashboard.grid", surfaces["dashboard.widget-settings"].parent_id) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in get_manifest().documentation} + home = topics["dashboard.configurable-home"] + reference = topics["dashboard.reference.layout-and-widgets"] + + self.assertIn("dashboard.state.browser-fallback", home.metadata["help_contexts"]) + self.assertIn("dashboard.field.widget-size", reference.metadata["help_contexts"]) + self.assertIn("save_layout", reference.metadata["consequence_classes"]) + self.assertIn("remove_widget", reference.metadata["consequence_classes"]) + + def test_webui_guards_layout_and_nested_widget_drafts(self) -> None: + page = (REPO_ROOT / "webui/src/features/dashboard/DashboardPage.tsx").read_text( + encoding="utf-8" + ) + dialog = ( + REPO_ROOT + / "webui/src/features/dashboard/WidgetConfigurationDialog.tsx" + ).read_text(encoding="utf-8") + library = ( + REPO_ROOT / "webui/src/features/dashboard/WidgetLibrary.tsx" + ).read_text(encoding="utf-8") + + self.assertIn("DocumentationHelpLink", page) + self.assertIn("useUnsavedDraftGuard", page) + self.assertIn("useUnsavedDraftGuard", dialog) + self.assertIn("DASHBOARD_LAYOUT_DOCUMENTATION", dialog) + self.assertIn("disabled={atCapacity}", library) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/dashboard/DashboardPage.tsx b/webui/src/features/dashboard/DashboardPage.tsx index d0ce2ab..1647c76 100644 --- a/webui/src/features/dashboard/DashboardPage.tsx +++ b/webui/src/features/dashboard/DashboardPage.tsx @@ -13,6 +13,7 @@ import { import { Button, DismissibleAlert, + DocumentationHelpLink, LoadingFrame, MetricCard, PageScrollViewport, @@ -36,6 +37,10 @@ import { import DashboardGrid from "./DashboardGrid"; import WidgetConfigurationDialog from "./WidgetConfigurationDialog"; import WidgetLibrary from "./WidgetLibrary"; +import { + DASHBOARD_DOCUMENTATION, + DASHBOARD_I18N +} from "./interfacePatterns"; import { appendPlacement, DASHBOARD_COLUMN_COUNT, @@ -184,8 +189,8 @@ export default function DashboardPage({ dirty, onSave: persistLayout, onDiscard: discardChanges, - title: "Unsaved Dashboard layout", - message: "Save or discard the Dashboard arrangement before leaving this page." + title: "i18n:govoplan-dashboard.unsaved_layout_title", + message: "i18n:govoplan-dashboard.unsaved_layout_message" }); const activeLayout = configuring ? draftLayout : savedLayout; @@ -446,25 +451,39 @@ export default function DashboardPage({

Personal workspace assembled from installed module widgets.

+ {!configuring && ( <> - - )} {configuring && ( <> - diff --git a/webui/src/features/dashboard/WidgetConfigurationDialog.tsx b/webui/src/features/dashboard/WidgetConfigurationDialog.tsx index b020f04..5f69fb6 100644 --- a/webui/src/features/dashboard/WidgetConfigurationDialog.tsx +++ b/webui/src/features/dashboard/WidgetConfigurationDialog.tsx @@ -2,9 +2,12 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Dialog, + DocumentationHelpLink, FormField, SegmentedControl, ToggleSwitch, + useUnsavedChanges, + useUnsavedDraftGuard, type DashboardWidgetConfiguration, type DashboardWidgetConfigurationField, type DashboardWidgetConfigurationValue, @@ -16,6 +19,10 @@ import { supportedWidgetSizes, type DashboardWidgetPlacement } from "./dashboardLayout"; +import { + DASHBOARD_I18N, + DASHBOARD_LAYOUT_DOCUMENTATION +} from "./interfacePatterns"; type WidgetConfigurationDialogProps = { open: boolean; @@ -32,8 +39,25 @@ export default function WidgetConfigurationDialog({ onClose, onSave }: WidgetConfigurationDialogProps) { + const { requestDiscard } = useUnsavedChanges(); const [size, setSize] = useState("medium"); const [configuration, setConfiguration] = useState({}); + const savedConfiguration = useMemo( + () => ({ + ...(widget?.defaultConfiguration ?? {}), + ...(placement?.configuration ?? {}) + }), + [placement, widget] + ); + const dirty = Boolean( + open + && widget + && placement + && ( + size !== placement.size + || JSON.stringify(configuration) !== JSON.stringify(savedConfiguration) + ) + ); useEffect(() => { if (!open || !widget || !placement) return; @@ -58,6 +82,34 @@ export default function WidgetConfigurationDialog({ [configuration, widget] ); + function discardDraft() { + if (!widget || !placement) return; + setSize(placement.size); + setConfiguration({ + ...(widget.defaultConfiguration ?? {}), + ...placement.configuration + }); + } + + function applyDraft(): boolean { + if (!placement || invalidConfiguration) return false; + onSave({ ...placement, size, configuration }); + return true; + } + + useUnsavedDraftGuard({ + dirty, + title: "i18n:govoplan-dashboard.unsaved_widget_title", + message: "i18n:govoplan-dashboard.unsaved_widget_message", + onSave: applyDraft, + onDiscard: discardDraft + }); + + function close() { + if (dirty) requestDiscard(onClose); + else onClose(); + } + if (!widget || !placement) return null; const supportedSizes = supportedWidgetSizes(widget); @@ -74,26 +126,28 @@ export default function WidgetConfigurationDialog({ - + } > + {supportedSizes.length > 1 && ( - + ({ id: candidate, @@ -142,7 +196,7 @@ function ConfigurationField({ if (field.kind === "select") { return ( - + + } variant="ghost" + disabled={atCapacity} disabledReason={ atCapacity ? "A Dashboard can contain at most 100 widgets." diff --git a/webui/src/features/dashboard/interfacePatterns.ts b/webui/src/features/dashboard/interfacePatterns.ts new file mode 100644 index 0000000..1cb9a0c --- /dev/null +++ b/webui/src/features/dashboard/interfacePatterns.ts @@ -0,0 +1,18 @@ +import type { DocumentationHelpReference } from "@govoplan/core-webui"; + +export const DASHBOARD_DOCUMENTATION = { + topicId: "dashboard.configurable-home", + documentationType: "user" +} satisfies DocumentationHelpReference; + +export const DASHBOARD_LAYOUT_DOCUMENTATION = { + topicId: "dashboard.reference.layout-and-widgets", + documentationType: "user" +} satisfies DocumentationHelpReference; + +export const DASHBOARD_I18N = { + loading: "i18n:govoplan-dashboard.loading_reason", + saving: "i18n:govoplan-dashboard.saving_reason", + noChanges: "i18n:govoplan-dashboard.no_changes_reason", + invalidWidget: "i18n:govoplan-dashboard.invalid_widget_reason" +} as const; diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index ce50435..17743fd 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -1,9 +1,73 @@ -export const generatedTranslations = { - en: { - "i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard" - }, - de: { - "i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard" - } +import type { PlatformTranslations } from "@govoplan/core-webui"; + +const en = { + "i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard", + "i18n:govoplan-dashboard.summary": "Dashboard summary", + "i18n:govoplan-dashboard.library": "Widget library", + "i18n:govoplan-dashboard.grid": "Dashboard grid", + "i18n:govoplan-dashboard.widget_settings": "Widget settings", + "i18n:govoplan-dashboard.installed_modules_widget": "Installed modules widget", + "i18n:govoplan-dashboard.loading_reason": "The saved Dashboard layout is still loading.", + "i18n:govoplan-dashboard.saving_reason": "The Dashboard layout is still being saved.", + "i18n:govoplan-dashboard.no_changes_reason": "There are no unsaved Dashboard changes.", + "i18n:govoplan-dashboard.invalid_widget_reason": "Complete the required widget settings with values inside their allowed ranges.", + "i18n:govoplan-dashboard.unsaved_layout_title": "Unsaved Dashboard layout", + "i18n:govoplan-dashboard.unsaved_layout_message": "Save or discard the Dashboard arrangement before leaving this page.", + "i18n:govoplan-dashboard.unsaved_widget_title": "Unapplied widget settings", + "i18n:govoplan-dashboard.unsaved_widget_message": "Apply or discard the widget settings before leaving this dialog.", + "Personal workspace assembled from installed module widgets.": "Personal workspace assembled from installed module widgets.", + "Refresh": "Refresh", + "Configure": "Configure", + "Cancel": "Cancel", + "Save layout": "Save layout", + "Saving...": "Saving...", + "Installed modules": "Installed modules", + "Available widgets": "Available widgets", + "On dashboard": "On dashboard", + "Layout": "Layout", + "Widget library": "Widget library", + "Search widgets": "Search widgets", + "Reset to module defaults": "Reset to module defaults", + "Widget size": "Widget size", + "Reset": "Reset", + "Apply": "Apply", + "Select an option": "Select an option", + "None": "None" +} as const; + +const de: Record = { + "i18n:govoplan-dashboard.dashboard.3f8b4df2": "Übersicht", + "i18n:govoplan-dashboard.summary": "Übersichtszusammenfassung", + "i18n:govoplan-dashboard.library": "Widget-Bibliothek", + "i18n:govoplan-dashboard.grid": "Übersichtsraster", + "i18n:govoplan-dashboard.widget_settings": "Widget-Einstellungen", + "i18n:govoplan-dashboard.installed_modules_widget": "Widget für installierte Module", + "i18n:govoplan-dashboard.loading_reason": "Die gespeicherte Übersichtsanordnung wird noch geladen.", + "i18n:govoplan-dashboard.saving_reason": "Die Übersichtsanordnung wird noch gespeichert.", + "i18n:govoplan-dashboard.no_changes_reason": "Es gibt keine ungespeicherten Änderungen an der Übersicht.", + "i18n:govoplan-dashboard.invalid_widget_reason": "Füllen Sie die erforderlichen Widget-Einstellungen mit Werten innerhalb der erlaubten Bereiche aus.", + "i18n:govoplan-dashboard.unsaved_layout_title": "Ungespeicherte Übersichtsanordnung", + "i18n:govoplan-dashboard.unsaved_layout_message": "Speichern oder verwerfen Sie die Übersichtsanordnung, bevor Sie diese Seite verlassen.", + "i18n:govoplan-dashboard.unsaved_widget_title": "Nicht angewendete Widget-Einstellungen", + "i18n:govoplan-dashboard.unsaved_widget_message": "Wenden Sie die Widget-Einstellungen an oder verwerfen Sie sie, bevor Sie den Dialog verlassen.", + "Personal workspace assembled from installed module widgets.": "Persönlicher Arbeitsbereich aus Widgets der installierten Module.", + "Refresh": "Aktualisieren", + "Configure": "Konfigurieren", + "Cancel": "Abbrechen", + "Save layout": "Anordnung speichern", + "Saving...": "Speichert...", + "Installed modules": "Installierte Module", + "Available widgets": "Verfügbare Widgets", + "On dashboard": "Auf der Übersicht", + "Layout": "Anordnung", + "Widget library": "Widget-Bibliothek", + "Search widgets": "Widgets suchen", + "Reset to module defaults": "Auf Modulvorgaben zurücksetzen", + "Widget size": "Widget-Größe", + "Reset": "Zurücksetzen", + "Apply": "Anwenden", + "Select an option": "Option auswählen", + "None": "Keine" }; +export const generatedTranslations: PlatformTranslations = { en, de }; diff --git a/webui/src/module.ts b/webui/src/module.ts index d92fc9d..bf76437 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -69,17 +69,62 @@ export const dashboardModule: PlatformWebModule = { optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"], translations, viewSurfaces: [ + { + id: "dashboard.page", + moduleId: "dashboard", + kind: "route", + label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", + order: 10 + }, + { + id: "dashboard.summary", + moduleId: "dashboard", + kind: "section", + label: "i18n:govoplan-dashboard.summary", + parentId: "dashboard.page", + order: 10 + }, + { + id: "dashboard.library", + moduleId: "dashboard", + kind: "section", + label: "i18n:govoplan-dashboard.library", + parentId: "dashboard.page", + order: 20 + }, + { + id: "dashboard.grid", + moduleId: "dashboard", + kind: "section", + label: "i18n:govoplan-dashboard.grid", + parentId: "dashboard.page", + order: 30 + }, + { + id: "dashboard.widget-settings", + moduleId: "dashboard", + kind: "action", + label: "i18n:govoplan-dashboard.widget_settings", + parentId: "dashboard.grid", + order: 40 + }, { id: "dashboard.widget.installed-modules", moduleId: "dashboard", kind: "section", - label: "Installed modules widget", + label: "i18n:govoplan-dashboard.installed_modules_widget", + parentId: "dashboard.grid", order: 10 } ], navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }], routes: [ - { path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) } + { + path: "/dashboard", + order: 10, + surfaceId: "dashboard.page", + render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) + } ], uiCapabilities: { "dashboard.widgets": dashboardWidgets