From e64af305347b60c9dc8bbc8391d9c30eed900c96 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 12:48:55 +0200 Subject: [PATCH] Migrate Committee interface patterns --- docs/INTERFACE_PATTERN_MIGRATION.md | 42 ++++ src/govoplan_committee/backend/manifest.py | 58 +++++ .../test_interface_documentation_contract.py | 38 +++ .../committee/CommitteeBallotDialog.tsx | 69 +++++- .../src/features/committee/CommitteePage.tsx | 185 +++++++++++---- .../committee/CommitteeRecordDialog.tsx | 124 ++++++++-- .../features/committee/interfacePatterns.ts | 44 ++++ webui/src/i18n/generatedTranslations.ts | 223 ++++++++++++++++++ webui/src/module.ts | 6 +- webui/src/styles/committee.css | 9 + 10 files changed, 719 insertions(+), 79 deletions(-) create mode 100644 docs/INTERFACE_PATTERN_MIGRATION.md create mode 100644 tests/test_interface_documentation_contract.py create mode 100644 webui/src/features/committee/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..aced363 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,42 @@ +# Committee Interface Pattern Migration + +This document records the bounded migration of Committee-owned WebUI surfaces +to the GovOPlaN interface pattern language. Core owns shared controls, help, +blocking explanations, draft guards, confirmations, and host shells. Committee +owns bodies, meetings, agenda context, vote context, minutes, and the bounded +projection of their institutional evidence. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/committee` toolbar and body list | Searchable workspace directory | Change query projection or create body | Shared search/button/help controls, explicit loading/error/permission states | +| Body and meeting selectors | Master-detail selection | Change active context | Stable keyboard buttons, selected/hover state, bounded independent scrolling | +| Meeting detail | Governed record workspace | Create or revise agenda, vote, and minute records | Shared sections, statuses, action explanations, locale-aware dates | +| Committee record dialog | Consequential record editor | Save immutable revision or change lifecycle | Contextual field help, guarded draft, required change reason, lifecycle confirmation | +| Ballot finalization dialog | Governed provider action | Import aggregate result and evidence | Permission explanation, guarded draft, explicit confirmation, no individual ballots | + +## Consequence And Availability Rules + +- Every save creates a new Committee revision and retains the change reason. +- Terminal lifecycle states are immutable; disabled edit actions explain that + constraint rather than disappearing. +- Cancelling, withdrawing, or retiring stops future work but never removes + existing revisions, minutes, decisions, or evidence. +- Provider and Voting integrations remain optional capability boundaries. + Committee stores only the aggregate result and assurance evidence. +- Missing write or ballot-finalization permission identifies the required + action, responsible administrator, and Access destination. +- Unsaved editors intercept route, browser, backdrop, and explicit close + attempts through the shared draft guard. + +## State And Accessibility Evidence + +The module uses shared buttons, icon buttons, dialogs, confirmations, alerts, +status badges, loading indicators, action blockers, field help, and draft +guards. Native buttons preserve keyboard order and shared dialogs retain focus. +The existing three-region desktop layout and two-region compact layout keep +lists and details independently scrollable. English and German catalogues cover +module metadata and owned UI copy, while dates follow the selected platform +locale. Manifest topics expose stable route, field, blocker, privacy, and +consequence references without importing optional sibling modules. diff --git a/src/govoplan_committee/backend/manifest.py b/src/govoplan_committee/backend/manifest.py index 2b8ae53..2c49d48 100644 --- a/src/govoplan_committee/backend/manifest.py +++ b/src/govoplan_committee/backend/manifest.py @@ -237,6 +237,17 @@ DOCUMENTATION = ( ), metadata={ "seed": True, + "help_contexts": [ + "committee.navigation", + "committee.workspace", + "committee.state.read-only", + "committee.state.permission-blocked", + ], + "privacy_notes": [ + "Committee lists expose only records authorized by the active tenant and permission context.", + "Provider-backed voting stores aggregate result evidence, not individual confidential ballots.", + "Protected decision reasoning requires the dedicated protected-read permission.", + ], "domain_objects": [ "committee bodies", "meeting agendas", @@ -252,6 +263,53 @@ DOCUMENTATION = ( ], }, ), + DocumentationTopic( + id=f"{MODULE_ID}.reference.fields-and-consequences", + title="Committee fields and consequences", + summary="Field provenance, lifecycle restrictions, optional provider references, and evidence consequences for Committee records.", + body=( + "Body organization-unit references determine institutional context but do not grant access by themselves. " + "Meeting times establish agenda context. Subject, Decision, Approval, record, and ballot identifiers are stable " + "cross-module references and remain optional only where the current lifecycle allows it. State transitions create " + "new immutable revisions; terminal records cannot be edited in place. Closing a vote or accepting minutes requires " + "the corresponding governed evidence. Provider ballot finalization imports only an aggregate result and assurance " + "evidence. Change reasons are retained with every revision. Cancelling, withdrawing, or retiring a record stops future " + "work but does not erase existing revisions, decisions, minutes, or audit evidence." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + order=110, + related_modules=OPTIONAL_DEPENDENCIES, + links=( + DocumentationLink( + label="Committee domain boundary", + href="govoplan-committee/docs/COMMITTEE_DOMAIN_BOUNDARY.md", + kind="repository", + ), + ), + metadata={ + "seed": True, + "help_contexts": [ + "committee.field.state", + "committee.field.organization-unit-reference", + "committee.field.subject-reference", + "committee.field.decision-reference", + "committee.field.approval-reference", + "committee.field.evidence-reference", + "committee.field.ballot-provider-reference", + "committee.field.change-reason", + "committee.action.change-state", + "committee.action.finalize-ballot", + ], + "consequence_classes": { + "save_revision": "Creates a new immutable Committee record revision with its change reason.", + "change_lifecycle": "Changes which future actions remain possible; terminal states are immutable.", + "finalize_ballot": "Imports a governed aggregate result and evidence without retaining individual ballots.", + "cancel_or_retire": "Stops future use while retaining institutional and audit evidence.", + }, + }, + ), ) manifest = ModuleManifest( diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..73cedd8 --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import unittest + +from govoplan_committee.backend.manifest import manifest + + +class CommitteeInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual( + {"/committee"}, + {route.path for route in frontend.routes}, # type: ignore[union-attr] + ) + self.assertEqual( + {"committee.navigation", "committee.workspace"}, + {surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr] + ) + + def test_topics_publish_help_privacy_and_consequence_metadata(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + self.assertIn("committee.module-boundary", topics) + self.assertIn("committee.reference.fields-and-consequences", topics) + + guide = topics["committee.module-boundary"] + self.assertIn("committee.workspace", guide.metadata["help_contexts"]) + self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) + + reference = topics["committee.reference.fields-and-consequences"] + self.assertIn("committee.field.state", reference.metadata["help_contexts"]) + self.assertIn("committee.action.finalize-ballot", reference.metadata["help_contexts"]) + self.assertIn("change_lifecycle", reference.metadata["consequence_classes"]) + self.assertIn("finalize_ballot", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/committee/CommitteeBallotDialog.tsx b/webui/src/features/committee/CommitteeBallotDialog.tsx index 8bc6d71..7f52627 100644 --- a/webui/src/features/committee/CommitteeBallotDialog.tsx +++ b/webui/src/features/committee/CommitteeBallotDialog.tsx @@ -1,9 +1,14 @@ import { useEffect, useState } from "react"; import { Button, + ConfirmDialog, Dialog, + DocumentationHelpLink, DismissibleAlert, FormField, + i18nMessage, + useUnsavedChanges, + useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui"; import { @@ -11,6 +16,10 @@ import { finalizeVotingBallot, type CommitteeRecord } from "../../api/committee"; +import { + COMMITTEE_FIELD_DOCUMENTATION, + COMMITTEE_INTERFACE_I18N +} from "./interfacePatterns"; export default function CommitteeBallotDialog({ @@ -31,6 +40,8 @@ export default function CommitteeBallotDialog({ const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate."); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [confirming, setConfirming] = useState(false); + const { requestDiscard } = useUnsavedChanges(); useEffect(() => { if (!open) return; @@ -38,9 +49,12 @@ export default function CommitteeBallotDialog({ setApprovalId(""); setChangeReason("Imported verified ballot aggregate."); setError(""); + setConfirming(false); }, [open, record.object_id]); - async function finalize() { + const dirty = Boolean(providerBallotRef || approvalId || changeReason !== "Imported verified ballot aggregate."); + + async function finalize(closeAfter = true): Promise { setBusy(true); setError(""); try { @@ -52,31 +66,54 @@ export default function CommitteeBallotDialog({ changeReason }); onSaved(saved); - onClose(); + if (closeAfter) onClose(); + return true; } catch (reason) { setError(reason instanceof Error ? reason.message : "Ballot result could not be imported."); + return false; } finally { setBusy(false); } } + useUnsavedDraftGuard({ + dirty: open && dirty, + onSave: () => finalize(false), + onDiscard: () => { + setProviderBallotRef(""); + setApprovalId(""); + setChangeReason("Imported verified ballot aggregate."); + }, + title: "i18n:govoplan-committee.unsaved_title", + message: "i18n:govoplan-committee.unsaved_message" + }); + + function requestClose() { + if (busy) return; + if (dirty) requestDiscard(onClose); + else onClose(); + } + const providerId = String(record.attributes.provider_id ?? ""); const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim(); + const incomplete = (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim(); return ( + <> - + @@ -84,22 +121,36 @@ export default function CommitteeBallotDialog({ } >
+
{error ? {error} : null}

{votingBallotId ? <>Voting ballot {votingBallotId} will be closed and its aggregate result recorded in the Committee minutes. : <>Provider {providerId} returns only the verified aggregate result, receipt hash and evidence.}

- {!votingBallotId ? + {!votingBallotId ? setProviderBallotRef(event.target.value)} /> : null} - + setApprovalId(event.target.value)} /> - + setChangeReason(event.target.value)} />
+ { + setConfirming(false); + void finalize(); + }} + onCancel={() => setConfirming(false)} + /> + ); } diff --git a/webui/src/features/committee/CommitteePage.tsx b/webui/src/features/committee/CommitteePage.tsx index bb3f021..c7718e1 100644 --- a/webui/src/features/committee/CommitteePage.tsx +++ b/webui/src/features/committee/CommitteePage.tsx @@ -11,13 +11,17 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { + ActionBlockerHint, Button, + DocumentationHelpLink, DismissibleAlert, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, + i18nMessage, + usePlatformLanguage, type PlatformRouteContext } from "@govoplan/core-webui"; import { @@ -27,6 +31,11 @@ import { } from "../../api/committee"; import CommitteeBallotDialog from "./CommitteeBallotDialog"; import CommitteeRecordDialog from "./CommitteeRecordDialog"; +import { + COMMITTEE_DOCUMENTATION, + COMMITTEE_INTERFACE_I18N, + committeeDisabledReason +} from "./interfacePatterns"; import { canReviseCommitteeRecord } from "./lifecycle"; @@ -37,6 +46,7 @@ type EditorTarget = { }; export default function CommitteePage({ settings, auth }: PlatformRouteContext) { + const { language, translateText } = usePlatformLanguage(); const tenantId = auth.active_tenant?.id ?? auth.tenant.id; const canWrite = hasScope(auth, "committee:workspace:write"); const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize"); @@ -165,9 +175,9 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext) const meetingTime = useMemo( () => selectedMeeting - ? `${formatDateTime(selectedMeeting.attributes.starts_at)} - ${formatTime(selectedMeeting.attributes.ends_at)}` + ? `${formatDateTime(selectedMeeting.attributes.starts_at, language)} - ${formatTime(selectedMeeting.attributes.ends_at, language)}` : "", - [selectedMeeting] + [language, selectedMeeting] ); return ( @@ -178,20 +188,42 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)