diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..25e35d6 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,33 @@ +# Forms Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to the +Forms-owned definition catalogue, package import, and revision editor. Forms +owns immutable reusable schemas; Forms Runtime continues to own values, +submissions, receipts, and handoff execution. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/forms` catalogue | Searchable definition library | Filter, export, import, create, or revise | Shared loading/error/empty/permission/help states and stable row actions | +| Package import dialog | Consequential package review | Create local draft from external provenance | Assessment, required reason, guarded draft, never implicit publish | +| Definition dialog | Versioned definition editor | Create immutable revision | Guarded editor, contextual schema help, explained validation and permission states | +| Publication state | Governed lifecycle selector | Publish or retire exact revision | Admin-only availability and explicit lifecycle confirmation | +| Field/page/localization editors | Structured schema composition | Change future runtime schema | At least one field, stable ordering, preview, localization and accessibility metadata | + +## Consequence And Availability Rules + +- Every save creates an immutable revision with a reason. Runtime instances + retain the exact revision with which they were created. +- Publishing permits future authorized use. Retirement prevents future use but + does not erase definitions, submissions, receipts, or evidence. +- Imported packages become local drafts and retain source provenance. +- Missing write or administration authority remains visible with an Access + destination; retired definitions show a lifecycle explanation. +- Optional Runtime, Portal, Workflow, Case, and Policy behavior remains behind + declared interfaces and capabilities. + +The module uses shared controls, dialogs, blockers, field help, statuses, +loading, empty/error states, disabled reasons, confirmations, and unsaved-draft +guards. Existing responsive list/editor layouts remain bounded. English and +German catalogues cover the owned route and editor vocabulary. diff --git a/src/govoplan_forms/backend/manifest.py b/src/govoplan_forms/backend/manifest.py index 2d72274..6b6d62e 100644 --- a/src/govoplan_forms/backend/manifest.py +++ b/src/govoplan_forms/backend/manifest.py @@ -38,6 +38,13 @@ MODULE_VERSION = "0.1.14" READ_SCOPE = "forms:definition:read" WRITE_SCOPE = "forms:definition:write" ADMIN_SCOPE = "forms:definition:admin" +OPTIONAL_DEPENDENCIES = ( + "forms_runtime", + "portal", + "workflow_engine", + "cases", + "policy", +) def _permission(scope: str, label: str, description: str) -> PermissionDefinition: @@ -69,13 +76,7 @@ manifest = ModuleManifest( name=MODULE_NAME, version=MODULE_VERSION, dependencies=("access",), - optional_dependencies=( - "forms_runtime", - "portal", - "workflow_engine", - "cases", - "policy", - ), + optional_dependencies=OPTIONAL_DEPENDENCIES, required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -205,6 +206,65 @@ manifest = ModuleManifest( kind="repository", ), ), + metadata={ + "seed": True, + "help_contexts": [ + "forms.navigation", + "forms.catalogue", + "forms.state.permission-blocked", + "forms.state.empty", + ], + "privacy_notes": [ + "Definition catalogues contain schemas and policy references, not submitted Form values.", + "Package assessment does not grant access to referenced runtime submissions or external providers.", + "Published accessibility and localization content is visible wherever the exact definition is authorized.", + ], + }, + ), + DocumentationTopic( + id="forms.reference.fields-and-consequences", + title="Form definition fields and lifecycle consequences", + summary="Schema, publication, localization, policy, evidence, package, and handoff semantics for immutable Form revisions.", + body=( + "The stable key identifies the definition while each save creates a new immutable revision. Field keys, types, " + "constraints, visibility conditions, pages, sections, options, help, localization, and accessibility instructions " + "become the exact runtime schema. Attachment, signature, policy, draft, and permitted-handoff settings are enforced " + "by Forms Runtime when that module is present. Publishing makes a revision available for new instances; existing " + "instances retain their prior exact revision. Retirement prevents future use without deleting definitions or submissions. " + "Package import always creates a local draft and retains source provenance; it never silently publishes an imported revision." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + related_modules=OPTIONAL_DEPENDENCIES, + links=( + DocumentationLink( + label="Forms boundary and recovery", + href="govoplan-forms/docs/FORMS_BOUNDARY.md", + kind="repository", + ), + ), + metadata={ + "seed": True, + "help_contexts": [ + "forms.field.publication-state", + "forms.field.signature-requirement", + "forms.field.policy-references", + "forms.field.handoff-kinds", + "forms.field.accessibility", + "forms.field.change-reason", + "forms.action.save-revision", + "forms.action.publish", + "forms.action.retire", + "forms.action.import-package", + ], + "consequence_classes": { + "save_revision": "Creates an immutable definition revision with a change reason.", + "publish": "Makes the exact revision available for future authorized instances.", + "retire": "Stops future use while retaining definitions and exact runtime references.", + "import_package": "Creates a local draft and retains package provenance without automatic publication.", + }, + }, ), ), 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..cd02653 --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import unittest + +from govoplan_forms.backend.manifest import manifest + + +class FormsInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual({"/forms"}, {item.path for item in frontend.routes}) # type: ignore[union-attr] + self.assertEqual( + {"forms.navigation", "forms.catalogue"}, + {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["forms.definitions"] + reference = topics["forms.reference.fields-and-consequences"] + self.assertIn("forms.catalogue", guide.metadata["help_contexts"]) + self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) + self.assertIn("forms.field.publication-state", reference.metadata["help_contexts"]) + self.assertIn("publish", reference.metadata["consequence_classes"]) + self.assertIn("import_package", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/forms/FormDefinitionDialog.tsx b/webui/src/features/forms/FormDefinitionDialog.tsx index c91e1e4..2b48c5f 100644 --- a/webui/src/features/forms/FormDefinitionDialog.tsx +++ b/webui/src/features/forms/FormDefinitionDialog.tsx @@ -2,11 +2,17 @@ import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Button, + ConfirmDialog, Dialog, + DocumentationHelpLink, DismissibleAlert, FormField as Field, IconButton, ToggleSwitch, + i18nMessage, + usePlatformLanguage, + useUnsavedChanges, + useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui"; import { @@ -18,6 +24,7 @@ import { type FormPageDefinition, type FormValueType } from "../../api/forms"; +import { FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns"; const VALUE_TYPES: Array<{ value: FormValueType; label: string }> = [ @@ -52,17 +59,24 @@ export default function FormDefinitionDialog({ onClose: () => void; onSaved: (definition: FormDefinition) => void; }) { - const [draft, setDraft] = useState(() => initialDraft(tenantId, definition)); + const { translateText } = usePlatformLanguage(); + const [baseline, setBaseline] = useState(() => initialDraft(tenantId, definition)); + const [draft, setDraft] = useState(baseline); const [changeReason, setChangeReason] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [confirmLifecycle, setConfirmLifecycle] = useState(false); + const { requestDiscard } = useUnsavedChanges(); useEffect(() => { if (!open) return; - setDraft(initialDraft(tenantId, definition)); + const next = initialDraft(tenantId, definition); + setDraft(next); + setBaseline(next); setChangeReason(""); setBusy(false); setError(""); + setConfirmLifecycle(false); }, [definition, open, tenantId]); const valid = useMemo(() => Boolean( @@ -72,9 +86,13 @@ export default function FormDefinitionDialog({ && draft.fields.every((field) => field.key.trim() && field.label.trim()) && changeReason.trim() ), [changeReason, draft]); + const dirty = useMemo( + () => Boolean(changeReason || JSON.stringify(draft) !== JSON.stringify(baseline)), + [baseline, changeReason, draft] + ); - async function save() { - if (!valid) return; + async function save(): Promise { + if (!valid) return false; setBusy(true); setError(""); const revision = crypto.randomUUID(); @@ -106,13 +124,41 @@ export default function FormDefinitionDialog({ definition?.reference.version ); onSaved(saved); + return true; } catch (reason) { setError(reason instanceof Error ? reason.message : "The Form definition could not be saved."); + return false; } finally { setBusy(false); } } + useUnsavedDraftGuard({ + dirty: open && dirty, + onSave: save, + onDiscard: () => { + setDraft(baseline); + setChangeReason(""); + }, + title: "i18n:govoplan-forms.unsaved_title", + message: "i18n:govoplan-forms.unsaved_message" + }); + + function requestClose() { + if (busy) return; + if (dirty) requestDiscard(onClose); + else onClose(); + } + + function requestSave() { + const previousState = definition?.publication_state ?? "draft"; + if (draft.publication_state !== "draft" && draft.publication_state !== previousState) { + setConfirmLifecycle(true); + return; + } + void save(); + } + function patchField(index: number, patch: Partial) { setDraft((current) => ({ ...current, @@ -137,19 +183,20 @@ export default function FormDefinitionDialog({ - - + }>
+
{error && {error}}
@@ -161,14 +208,14 @@ export default function FormDefinitionDialog({