diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..2edc097 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,32 @@ +# Templates Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to the Template +library, immutable-revision editor, preview/final-output workspace, and render +evidence history. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/templates` library | Governed directory | Select, create, or retire Template | Shared loading, empty, permission, read-only, disabled-reason, and help states | +| Definition editor | Consequential definition editor | Save immutable revision | Guarded draft with scope, usage, required-data, layout, and content semantics | +| Publish action | Governed lifecycle transition | Make one revision consumable | Permission/lifecycle explanation and explicit confirmation | +| Preview/final output | Evidence-producing preview | Validate sample or render final output | Compatibility diagnostics, published-revision gate, confirmation, and retained hashes | +| Revision/render history | Evidence register | Inspect immutable history | Stable status, timestamps, digests, artifact availability, and bounded download | + +## Consequence And Availability Rules + +- Saving creates a new immutable revision. Publishing never rewrites an older + revision or its render evidence. +- Inherited or policy-constrained Templates remain visible but identify why + they are read-only, who can change that, and where to continue. +- Final output requires a published revision and explicit confirmation. It + records template, input, output, and renderer evidence and may use Files only + through the optional artifact capability. +- Deleting prevents future selection while retained revisions and renders stay + governed by retention policy. + +Backend and WebUI manifests publish the same surface identifiers. English and +German catalogues cover module-owned vocabulary, contextual help resolves from +manifest documentation, and create/editor drafts are guarded across selection, +reload, and navigation. diff --git a/src/govoplan_templates/backend/manifest.py b/src/govoplan_templates/backend/manifest.py index e59a388..92811a0 100644 --- a/src/govoplan_templates/backend/manifest.py +++ b/src/govoplan_templates/backend/manifest.py @@ -85,7 +85,15 @@ DOCUMENTATION = ( layer="available", documentation_types=("admin", "user"), audience=("operator", "module_admin", "product_owner"), - metadata={"seed": True}, + metadata={ + "seed": True, + "help_contexts": [ + "templates.page", + "templates.library", + "templates.editor", + "templates.state.read-only", + ], + }, ), DocumentationTopic( id="templates.printable-output", @@ -101,7 +109,51 @@ DOCUMENTATION = ( documentation_types=("admin", "user"), audience=("operator", "module_admin", "product_owner"), related_modules=("files", "dist_lists", "campaigns", "audit"), - metadata={"seed": True}, + metadata={ + "seed": True, + "help_contexts": [ + "templates.preview", + "templates.action.validate-preview", + "templates.action.render-final", + "templates.evidence.render", + ], + }, + ), + DocumentationTopic( + id="templates.reference.fields-and-consequences", + title="Template fields and lifecycle consequences", + summary="Scope, usage, data contract, publication, rendering, and deletion semantics for reusable Templates.", + body=( + "Visibility determines which tenant, group, or user scope may discover the Template; inherited Templates may be read-only. " + "Usages are capability contexts that constrain where a Template may be selected. Required fields form the compatibility " + "contract checked against supplied data before rendering. Saving creates a new immutable revision. Publishing marks one " + "revision as available for final output without rewriting older revisions or evidence. Preview validates and renders bounded " + "sample output; final rendering requires the published revision and records template, input, and output hashes plus renderer " + "evidence. Files may retain the artifact when its optional capability is available. Deletion removes the Template from future " + "selection but does not rewrite retained render evidence." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "product_owner"), + related_modules=("files", "dist_lists", "campaigns", "audit", "policy"), + metadata={ + "seed": True, + "help_contexts": [ + "templates.field.type", + "templates.field.locale", + "templates.field.visibility", + "templates.field.usages", + "templates.field.required-data", + "templates.action.publish", + "templates.action.delete", + ], + "consequence_classes": { + "save_revision": "Creates a new immutable Template revision.", + "publish_revision": "Makes the selected immutable revision eligible for final consumer output.", + "render_final": "Creates retained render evidence and may persist an artifact through Files.", + "delete_template": "Prevents future selection without rewriting retained revisions or render evidence.", + }, + }, ), ) diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..a2c5789 --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import unittest + +from govoplan_templates.backend.manifest import manifest + + +class TemplatesInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual({"/templates"}, {item.path for item in frontend.routes}) # type: ignore[union-attr] + self.assertEqual( + { + "templates.page", + "templates.library", + "templates.editor", + "templates.preview", + }, + {item.id for item in frontend.view_surfaces}, # type: ignore[union-attr] + ) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + library = topics["templates.library"] + output = topics["templates.printable-output"] + reference = topics["templates.reference.fields-and-consequences"] + + self.assertIn("templates.state.read-only", library.metadata["help_contexts"]) + self.assertIn("templates.action.render-final", output.metadata["help_contexts"]) + self.assertIn("templates.field.usages", reference.metadata["help_contexts"]) + self.assertIn("publish_revision", reference.metadata["consequence_classes"]) + self.assertIn("delete_template", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/templates/TemplatesPage.tsx b/webui/src/features/templates/TemplatesPage.tsx index 45e749c..31cb4a2 100644 --- a/webui/src/features/templates/TemplatesPage.tsx +++ b/webui/src/features/templates/TemplatesPage.tsx @@ -12,9 +12,11 @@ import { import { useCallback, useEffect, useMemo, useState } from "react"; import { ApiError, + ActionBlockerHint, Button, ConfirmDialog, Dialog, + DocumentationHelpLink, DismissibleAlert, FormField, IconButton, @@ -24,6 +26,7 @@ import { ToggleSwitch, formatDateTime, hasScope, + useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings, type AuthInfo @@ -49,6 +52,12 @@ import { type TemplateRevision, type TemplateType } from "../../api/templates"; +import { + TEMPLATE_FIELDS_DOCUMENTATION, + TEMPLATE_OUTPUT_DOCUMENTATION, + TEMPLATES_DOCUMENTATION, + TEMPLATES_I18N +} from "./interfacePatterns"; type Props = { settings: ApiSettings; auth: AuthInfo }; type WorkspaceView = "definition" | "preview"; @@ -83,6 +92,8 @@ export default function TemplatesPage({ settings, auth }: Props) { const [createName, setCreateName] = useState(""); const [createType, setCreateType] = useState("form_letter"); const [deleteOpen, setDeleteOpen] = useState(false); + const [publishOpen, setPublishOpen] = useState(false); + const [finalRenderOpen, setFinalRenderOpen] = useState(false); const [sampleText, setSampleText] = useState('{\n "name": "Ada Example",\n "address": "Main Street 1",\n "postal_code": "10115",\n "city": "Berlin"\n}'); const [usage, setUsage] = useState("campaign.postal"); const [outputFormat, setOutputFormat] = useState<"html" | "text">("html"); @@ -91,6 +102,7 @@ export default function TemplatesPage({ settings, auth }: Props) { const [render, setRender] = useState(null); const [revisions, setRevisions] = useState([]); const [renders, setRenders] = useState([]); + const { requestDiscard } = useUnsavedChanges(); const selected = items.find((item) => item.id === selectedId) ?? null; const canWrite = hasScope(auth, "templates:template:write") || hasScope(auth, "templates:template:admin"); @@ -173,10 +185,16 @@ export default function TemplatesPage({ settings, auth }: Props) { } }; - useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: () => applyItem(selected) }); + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: () => applyItem(selected), + title: "i18n:govoplan-templates.unsaved_title", + message: "i18n:govoplan-templates.unsaved_message" + }); - const create = async () => { - if (!createName.trim()) return; + const create = async (): Promise => { + if (!createName.trim()) return false; setBusy(true); setError(""); try { @@ -188,18 +206,39 @@ export default function TemplatesPage({ settings, auth }: Props) { setCreateName(""); setSuccess(`Created ${created.name}.`); await reload(created.id); + return true; } catch (caught) { setError(errorMessage(caught)); + return false; } finally { setBusy(false); } }; + useUnsavedDraftGuard({ + dirty: Boolean(createOpen && createName.trim()), + onSave: create, + onDiscard: () => { + setCreateOpen(false); + setCreateName(""); + setCreateType("form_letter"); + }, + title: "i18n:govoplan-templates.create_unsaved_title", + message: "i18n:govoplan-templates.create_unsaved_message" + }); + + const closeCreate = () => { + if (busy) return; + if (createName.trim()) requestDiscard(() => setCreateOpen(false)); + else setCreateOpen(false); + }; + const publish = async () => { if (!selected || dirty) return; setBusy(true); try { const updated = await publishTemplate(settings, selected); + setPublishOpen(false); setSuccess(`Published revision ${updated.current_revision}.`); await reload(updated.id); } catch (caught) { @@ -242,6 +281,7 @@ export default function TemplatesPage({ settings, auth }: Props) { persistToFiles }); setRender(nextRender); + if (final) setFinalRenderOpen(false); setRenders(await listTemplateRenders(settings, selected.id)); setSuccess(`${final ? "Final" : "Preview"} output rendered with ${nextRender.item_count} item(s).`); } catch (caught) { @@ -257,7 +297,7 @@ export default function TemplatesPage({ settings, auth }: Props) {