From 24e95591e0edc7e5324da17b77edaed54addf705 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 12:55:27 +0200 Subject: [PATCH] Migrate Approvals interface patterns --- docs/INTERFACE_PATTERN_MIGRATION.md | 30 ++++ src/govoplan_approvals/backend/manifest.py | 56 ++++++++ .../test_interface_documentation_contract.py | 30 ++++ .../approvals/ApprovalRequestDialog.tsx | 49 +++++-- .../src/features/approvals/ApprovalsPage.tsx | 25 ++-- .../features/approvals/interfacePatterns.ts | 27 ++++ webui/src/i18n/generatedTranslations.ts | 129 ++++++++++++++++++ webui/src/module.ts | 6 +- webui/src/styles/approvals.css | 3 + 9 files changed, 333 insertions(+), 22 deletions(-) create mode 100644 docs/INTERFACE_PATTERN_MIGRATION.md create mode 100644 tests/test_interface_documentation_contract.py create mode 100644 webui/src/features/approvals/interfacePatterns.ts create mode 100644 webui/src/i18n/generatedTranslations.ts diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..9ecd67d --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,30 @@ +# Approvals Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to the +Approvals-owned route without changing the append-only approval model or +importing optional sibling modules. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/approvals` list | Searchable work queue | Select request or create immutable chain | Shared loading, empty, error, permission, selection, and help states | +| Approval detail | Governed record detail | Inspect exact subject, chain, state, and evidence | Locale-aware history, status, provenance, explained lifecycle actions | +| Request dialog | Consequential definition editor | Freeze exact subject and approval chain | Guarded draft, field help, validation reasons, at least one step | +| Decision dialog | Governed decision | Append approval or rejection evidence | Required reason/signature, guarded draft, explicit confirmation action | + +## Consequence And Availability Rules + +- Creation freezes the subject identity and digest plus every ordered step, + selector, quorum, separation, and signature requirement. +- Approving or rejecting appends attributable evidence and cannot be edited. +- Only pending or escalated requests can be decided. Terminal requests remain + available for reconstruction. +- Missing create or decide permission is visible and points to the Access role + assignment destination and responsible administrator. +- Signature references are evidence pointers and never a cryptographic claim. + +The module uses Core dialogs, controls, status, blockers, help, loading, empty, +error, and draft-guard contracts. Native selection buttons preserve keyboard +order; bounded list/detail viewports remain responsive. English and German +catalogues cover module-owned copy and dates follow the active platform locale. diff --git a/src/govoplan_approvals/backend/manifest.py b/src/govoplan_approvals/backend/manifest.py index 9b702c4..330cc26 100644 --- a/src/govoplan_approvals/backend/manifest.py +++ b/src/govoplan_approvals/backend/manifest.py @@ -235,6 +235,62 @@ manifest = ModuleManifest( kind="repository", ), ), + metadata={ + "seed": True, + "help_contexts": [ + "approvals.navigation", + "approvals.workspace", + "approvals.state.permission-blocked", + "approvals.state.empty", + ], + "privacy_notes": [ + "Approval lists and histories remain tenant-bound and permission-filtered.", + "Signature references identify evidence but do not expose private key material.", + "Decision history retains actor and reason as governed evidence.", + ], + }, + ), + DocumentationTopic( + id="approvals.reference.fields-and-consequences", + title="Approval fields and consequences", + summary="Exact-subject identity, selector, separation-of-duty, signature, and decision consequences.", + body=( + "Subject module, type, identifier, version, and SHA-256 digest freeze the exact object revision being approved. " + "Ordered steps, actor selectors, required counts, requester separation, unique actors, and signature requirements " + "are copied into the immutable request and do not follow later template changes. Actor values are provider-neutral " + "identifiers interpreted through Access and IDM contracts. Approval or rejection appends a decision with actor, " + "reason, optional signature reference, and optimistic-concurrency revision. Completed, rejected, cancelled, and " + "expired requests remain evidence and cannot be decided again." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + related_modules=OPTIONAL_DEPENDENCIES, + links=( + DocumentationLink( + label="Approvals boundary and recovery", + href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md", + kind="repository", + ), + ), + metadata={ + "seed": True, + "help_contexts": [ + "approvals.field.subject-reference", + "approvals.field.subject-digest", + "approvals.field.actor-selector", + "approvals.field.separation-of-duties", + "approvals.field.signature-reference", + "approvals.action.create-request", + "approvals.action.decide-request", + ], + "consequence_classes": { + "create_request": "Freezes an exact subject and immutable approval chain.", + "approve_step": "Appends an attributable decision and may advance or complete the chain.", + "reject_request": "Appends a rejection and completes the request according to its frozen policy.", + "retain_evidence": "Keeps request revisions, decisions, reasons, and signature references for reconstruction.", + }, + }, ), ), 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..0fada9e --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import unittest + +from govoplan_approvals.backend.manifest import manifest + + +class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual({"/approvals"}, {item.path for item in frontend.routes}) # type: ignore[union-attr] + self.assertEqual( + {"approvals.navigation", "approvals.workspace"}, + {item.id for item in frontend.view_surfaces}, # type: ignore[union-attr] + ) + + def test_help_privacy_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + guide = topics["approvals.module-boundary"] + reference = topics["approvals.reference.fields-and-consequences"] + self.assertIn("approvals.workspace", guide.metadata["help_contexts"]) + self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) + self.assertIn("approvals.field.subject-digest", reference.metadata["help_contexts"]) + self.assertIn("create_request", reference.metadata["consequence_classes"]) + self.assertIn("reject_request", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/approvals/ApprovalRequestDialog.tsx b/webui/src/features/approvals/ApprovalRequestDialog.tsx index 0153b27..b72016b 100644 --- a/webui/src/features/approvals/ApprovalRequestDialog.tsx +++ b/webui/src/features/approvals/ApprovalRequestDialog.tsx @@ -1,45 +1,66 @@ import { Plus, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; -import { Button, Dialog, DismissibleAlert, FormField, IconButton, ToggleSwitch, type ApiSettings } from "@govoplan/core-webui"; +import { Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui"; import { createApproval, type ApprovalDraft, type ApprovalRequest, type ApprovalStep } from "../../api/approvals"; +import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns"; export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) { - const [draft, setDraft] = useState(() => initialDraft()); + const [baseline] = useState(() => initialDraft()); + const [draft, setDraft] = useState(baseline); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const { requestDiscard } = useUnsavedChanges(); const valid = useMemo(() => Boolean(draft.title.trim() && draft.subject_module.trim() && draft.subject_type.trim() && draft.subject_id.trim() && /^[0-9a-f]{64}$/.test(draft.subject_digest) && draft.steps.every((step) => step.key.trim() && step.label.trim() && step.selectors.every((selector) => selector.value.trim()))), [draft]); + const dirty = draftKey(draft) !== draftKey(baseline); - async function save() { + async function save(): Promise { setBusy(true); setError(""); try { onSaved(await createApproval(settings, draft)); + return true; } catch (reason) { setError(reason instanceof Error ? reason.message : "The Approval request could not be created."); + return false; } finally { setBusy(false); } } + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: () => setDraft(baseline), + title: "i18n:govoplan-approvals.unsaved_title", + message: "i18n:govoplan-approvals.unsaved_message" + }); + + function requestClose() { + if (busy) return; + if (dirty) requestDiscard(onClose); + else onClose(); + } + function patchStep(index: number, patch: Partial) { setDraft((current) => ({ ...current, steps: current.steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) })); } - return }> + return }>
+
{error && {error}}
- setDraft({ ...draft, title: event.target.value })} /> - setDraft({ ...draft, subject_module: event.target.value })} /> - setDraft({ ...draft, subject_type: event.target.value })} /> - setDraft({ ...draft, subject_id: event.target.value })} /> - setDraft({ ...draft, subject_version: event.target.value })} /> - setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /> + setDraft({ ...draft, title: event.target.value })} /> + setDraft({ ...draft, subject_module: event.target.value })} /> + setDraft({ ...draft, subject_type: event.target.value })} /> + setDraft({ ...draft, subject_id: event.target.value })} /> + setDraft({ ...draft, subject_version: event.target.value })} /> + setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} />