diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..18e31d2 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,34 @@ +# Voting Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to Voting's +catalogue, ballot editor, casting, result, and governance-transition surfaces. +It preserves the strict distinction between recorded and provider-backed +assurance profiles. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/voting` catalogue | Governed work queue | Select, create, or manage ballot | Shared loading/empty/error/help and explicit management permission state | +| Ballot editor | Consequential definition editor | Save mutable draft | Guarded draft, contextual assurance/electorate help, validation reasons | +| Recorded cast panel | Guided decision | Record or replace authorized vote | Frozen eligibility, explicit selection, confirmation, privacy-bounded receipt | +| Result and assurance | Evidence/provenance | Inspect tally and hashes | Result visibility follows assurance profile and backend authorization | +| Open/close/certify/challenge/annul | Governed lifecycle | Freeze, tally, certify, challenge, or annul | Permission/lifecycle reasons, confirmations, required reasons, retained evidence | + +## Consequence And Availability Rules + +- Opening freezes options, electorate, weights, threshold, replacement rule, + assurance profile, and provider binding. +- Recorded ballots are reconstructable and are not secret. Provider-backed + privacy claims never exceed the installed provider contract. +- Closing stops casting and records a tally; certification adds evidence. + Challenge and annulment append reasoned events and erase nothing. +- Missing permission or electorate membership identifies the required action, + responsible actor, and destination. +- Optional Committee, Decision, Encryption, Forms, Trust, Policy, Reporting, + and Workflow integrations remain behind declared contracts. + +The module uses shared controls, dialogs, confirmations, blockers, field help, +statuses, alerts, loading/empty states, disabled reasons, and unsaved-draft +guards. English and German catalogues cover the owned interaction vocabulary; +history timestamps follow the selected platform locale. diff --git a/src/govoplan_voting/backend/manifest.py b/src/govoplan_voting/backend/manifest.py index 9f45a6c..eb9b2dd 100644 --- a/src/govoplan_voting/backend/manifest.py +++ b/src/govoplan_voting/backend/manifest.py @@ -52,6 +52,16 @@ MANAGE_SCOPE = "voting:ballot:manage" CAST_SCOPE = "voting:ballot:cast" CERTIFY_SCOPE = "voting:ballot:certify" ADMIN_SCOPE = "voting:ballot:admin" +OPTIONAL_DEPENDENCIES = ( + "committee", + "decisions", + "encryption", + "forms", + "identity_trust", + "policy", + "reporting", + "workflow_engine", +) def _permission(scope: str, label: str, description: str) -> PermissionDefinition: @@ -103,16 +113,7 @@ manifest = ModuleManifest( name=MODULE_NAME, version=MODULE_VERSION, dependencies=("access",), - optional_dependencies=( - "committee", - "decisions", - "encryption", - "forms", - "identity_trust", - "policy", - "reporting", - "workflow_engine", - ), + optional_dependencies=OPTIONAL_DEPENDENCIES, required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -308,6 +309,67 @@ manifest = ModuleManifest( kind="repository", ), ), + metadata={ + "seed": True, + "help_contexts": [ + "voting.navigation", + "voting.catalogue", + "voting.ballot", + "voting.state.permission-blocked", + "voting.state.ineligible", + ], + "privacy_notes": [ + "Recorded ballots are reconstructable and must never be described as secret.", + "Provider-backed profiles expose only the aggregate, receipt, hash, and evidence allowed by the provider contract.", + "The bundled local confidential provider is server-readable and uncertified despite encrypting stored selections.", + ], + }, + ), + DocumentationTopic( + id="voting.reference.fields-and-consequences", + title="Ballot fields, assurance, and lifecycle consequences", + summary="Frozen electorate, threshold, provider, receipt, tally, certification, challenge, and annulment semantics.", + body=( + "Opening freezes the exact options, method, electorate, weights, quorum, threshold, replacement rule, assurance profile, " + "and provider binding. Recorded ballots remain attributable and reconstructable. Confidential, secret, and externally " + "certified profiles are claims of their installed provider only; the local confidential provider is server-readable and " + "uncertified. Casting records or replaces a vote only when the frozen definition permits it and returns a receipt. Closing " + "prevents further casting and records the tally. Certification adds evidence without rewriting the result. Challenge and " + "annulment are separately reasoned, auditable transitions and never erase the frozen definition, receipts, or prior history." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + related_modules=OPTIONAL_DEPENDENCIES, + links=( + DocumentationLink( + label="Voting domain and assurance boundary", + href="govoplan-voting/docs/VOTING_DOMAIN.md", + kind="repository", + ), + ), + metadata={ + "seed": True, + "help_contexts": [ + "voting.field.assurance-profile", + "voting.field.provider-reference", + "voting.field.electorate", + "voting.field.quorum-threshold", + "voting.action.cast", + "voting.action.open", + "voting.action.close", + "voting.action.certify", + "voting.action.challenge", + "voting.action.annul", + ], + "consequence_classes": { + "open": "Freezes the ballot definition and electorate and permits authorized casting.", + "cast": "Records or replaces one authorized vote and returns a privacy-bounded receipt.", + "close": "Stops casting and records the aggregate tally.", + "certify": "Adds certification evidence without rewriting the frozen result.", + "challenge_or_annul": "Appends a reasoned governance transition while retaining prior evidence.", + }, + }, ), ), 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..412532b --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import unittest + +from govoplan_voting.backend.manifest import manifest + + +class VotingInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual({"/voting"}, {item.path for item in frontend.routes}) # type: ignore[union-attr] + self.assertEqual( + {"voting.navigation", "voting.catalogue", "voting.ballot"}, + {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["voting.assurance"] + reference = topics["voting.reference.fields-and-consequences"] + self.assertIn("voting.ballot", guide.metadata["help_contexts"]) + self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) + self.assertIn("voting.field.assurance-profile", reference.metadata["help_contexts"]) + self.assertIn("cast", reference.metadata["consequence_classes"]) + self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/voting/VotingBallotDialog.tsx b/webui/src/features/voting/VotingBallotDialog.tsx index d1af4b8..4fad1cc 100644 --- a/webui/src/features/voting/VotingBallotDialog.tsx +++ b/webui/src/features/voting/VotingBallotDialog.tsx @@ -3,10 +3,13 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Dialog, + DocumentationHelpLink, DismissibleAlert, FormField, IconButton, ToggleSwitch, + useUnsavedChanges, + useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui"; import { @@ -15,6 +18,7 @@ import { type VotingBallot, type VotingBallotDraft } from "../../api/voting"; +import { VOTING_FIELD_DOCUMENTATION, VOTING_I18N } from "./interfacePatterns"; export default function VotingBallotDialog({ @@ -32,13 +36,17 @@ export default function VotingBallotDialog({ onClose: () => void; onSaved: (ballot: VotingBallot) => void; }) { - const [draft, setDraft] = useState(() => initialDraft(currentAccountId, ballot)); + const [baseline, setBaseline] = useState(() => initialDraft(currentAccountId, ballot)); + const [draft, setDraft] = useState(baseline); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const { requestDiscard } = useUnsavedChanges(); useEffect(() => { if (!open) return; - setDraft(initialDraft(currentAccountId, ballot)); + const next = initialDraft(currentAccountId, ballot); + setDraft(next); + setBaseline(next); setBusy(false); setError(""); }, [ballot, currentAccountId, open]); @@ -60,9 +68,10 @@ export default function VotingBallotDialog({ ) ) ), [draft]); + const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(baseline), [baseline, draft]); - async function save() { - if (!valid) return; + async function save(): Promise { + if (!valid) return false; setBusy(true); setError(""); const payload: VotingBallotDraft = { @@ -79,28 +88,45 @@ export default function VotingBallotDialog({ ? await updateVotingBallot(settings, ballot.id, ballot.revision, payload) : await createVotingBallot(settings, payload); onSaved(saved); + return true; } catch (reason) { setError(reason instanceof Error ? reason.message : "The ballot could not be saved."); + return false; } finally { setBusy(false); } } + useUnsavedDraftGuard({ + dirty: open && dirty, + onSave: save, + onDiscard: () => setDraft(baseline), + title: "i18n:govoplan-voting.unsaved_title", + message: "i18n:govoplan-voting.unsaved_message" + }); + + function requestClose() { + if (busy) return; + if (dirty) requestDiscard(onClose); + else onClose(); + } + return ( - - + + }>
+
{error && {error}}
setDraft({ ...draft, title: event.target.value })} /> @@ -112,7 +138,7 @@ export default function VotingBallotDialog({