Migrate Forms interface patterns

This commit is contained in:
2026-08-03 13:08:43 +02:00
parent e2033640a1
commit e505536e6f
9 changed files with 429 additions and 31 deletions
+33
View File
@@ -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.
+67 -7
View File
@@ -38,6 +38,13 @@ MODULE_VERSION = "0.1.14"
READ_SCOPE = "forms:definition:read" READ_SCOPE = "forms:definition:read"
WRITE_SCOPE = "forms:definition:write" WRITE_SCOPE = "forms:definition:write"
ADMIN_SCOPE = "forms:definition:admin" ADMIN_SCOPE = "forms:definition:admin"
OPTIONAL_DEPENDENCIES = (
"forms_runtime",
"portal",
"workflow_engine",
"cases",
"policy",
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -69,13 +76,7 @@ manifest = ModuleManifest(
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
dependencies=("access",), dependencies=("access",),
optional_dependencies=( optional_dependencies=OPTIONAL_DEPENDENCIES,
"forms_runtime",
"portal",
"workflow_engine",
"cases",
"policy",
),
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -205,6 +206,65 @@ manifest = ModuleManifest(
kind="repository", 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( architecture=declared_module_architecture(
@@ -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()
@@ -2,11 +2,17 @@ import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Button, Button,
ConfirmDialog,
Dialog, Dialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FormField as Field, FormField as Field,
IconButton, IconButton,
ToggleSwitch, ToggleSwitch,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings type ApiSettings
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -18,6 +24,7 @@ import {
type FormPageDefinition, type FormPageDefinition,
type FormValueType type FormValueType
} from "../../api/forms"; } from "../../api/forms";
import { FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
const VALUE_TYPES: Array<{ value: FormValueType; label: string }> = [ const VALUE_TYPES: Array<{ value: FormValueType; label: string }> = [
@@ -52,17 +59,24 @@ export default function FormDefinitionDialog({
onClose: () => void; onClose: () => void;
onSaved: (definition: FormDefinition) => void; onSaved: (definition: FormDefinition) => void;
}) { }) {
const [draft, setDraft] = useState<FormDefinition>(() => initialDraft(tenantId, definition)); const { translateText } = usePlatformLanguage();
const [baseline, setBaseline] = useState<FormDefinition>(() => initialDraft(tenantId, definition));
const [draft, setDraft] = useState<FormDefinition>(baseline);
const [changeReason, setChangeReason] = useState(""); const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [confirmLifecycle, setConfirmLifecycle] = useState(false);
const { requestDiscard } = useUnsavedChanges();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setDraft(initialDraft(tenantId, definition)); const next = initialDraft(tenantId, definition);
setDraft(next);
setBaseline(next);
setChangeReason(""); setChangeReason("");
setBusy(false); setBusy(false);
setError(""); setError("");
setConfirmLifecycle(false);
}, [definition, open, tenantId]); }, [definition, open, tenantId]);
const valid = useMemo(() => Boolean( const valid = useMemo(() => Boolean(
@@ -72,9 +86,13 @@ export default function FormDefinitionDialog({
&& draft.fields.every((field) => field.key.trim() && field.label.trim()) && draft.fields.every((field) => field.key.trim() && field.label.trim())
&& changeReason.trim() && changeReason.trim()
), [changeReason, draft]); ), [changeReason, draft]);
const dirty = useMemo(
() => Boolean(changeReason || JSON.stringify(draft) !== JSON.stringify(baseline)),
[baseline, changeReason, draft]
);
async function save() { async function save(): Promise<boolean> {
if (!valid) return; if (!valid) return false;
setBusy(true); setBusy(true);
setError(""); setError("");
const revision = crypto.randomUUID(); const revision = crypto.randomUUID();
@@ -106,13 +124,41 @@ export default function FormDefinitionDialog({
definition?.reference.version definition?.reference.version
); );
onSaved(saved); onSaved(saved);
return true;
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form definition could not be saved."); setError(reason instanceof Error ? reason.message : "The Form definition could not be saved.");
return false;
} finally { } finally {
setBusy(false); 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<FormFieldDefinition>) { function patchField(index: number, patch: Partial<FormFieldDefinition>) {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@@ -137,19 +183,20 @@ export default function FormDefinitionDialog({
<Dialog <Dialog
open={open} open={open}
title={definition ? `Revise ${definition.title}` : "New Form definition"} title={definition ? `Revise ${definition.title}` : "New Form definition"}
onClose={onClose} onClose={requestClose}
closeDisabled={busy} closeDisabled={busy}
portal portal
className="form-definition-dialog" className="form-definition-dialog"
footer={ footer={
<> <>
<Button onClick={onClose} disabled={busy}>Cancel</Button> <Button onClick={requestClose} disabled={busy} disabledReason={busy ? FORMS_I18N.busy : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void save()} disabled={busy || !valid}> <Button variant="primary" onClick={requestSave} disabled={busy || !valid} disabledReason={busy ? FORMS_I18N.busy : !valid ? FORMS_I18N.incomplete : undefined}>
{busy ? "Saving" : "Save revision"} {busy ? "Saving" : "Save revision"}
</Button> </Button>
</> </>
}> }>
<div className="form-definition-editor"> <div className="form-definition-editor">
<div className="form-definition-help"><DocumentationHelpLink reference={FORMS_FIELD_DOCUMENTATION} /></div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="form-definition-grid"> <div className="form-definition-grid">
<Field label="Title"> <Field label="Title">
@@ -161,14 +208,14 @@ export default function FormDefinitionDialog({
<Field label="Description" className="form-definition-wide"> <Field label="Description" className="form-definition-wide">
<textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /> <textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
</Field> </Field>
<Field label="Publication state"> <Field label="Publication state" help={!canPublish ? FORMS_I18N.adminReason : undefined} documentation={FORMS_FIELD_DOCUMENTATION}>
<select value={draft.publication_state} disabled={busy} onChange={(event) => setDraft({ ...draft, publication_state: event.target.value as FormDefinition["publication_state"] })}> <select value={draft.publication_state} disabled={busy} onChange={(event) => setDraft({ ...draft, publication_state: event.target.value as FormDefinition["publication_state"] })}>
{definition?.publication_state !== "published" && <option value="draft">Draft</option>} {definition?.publication_state !== "published" && <option value="draft">Draft</option>}
{canPublish && <option value="published">Published</option>} {canPublish && <option value="published">Published</option>}
{canPublish && <option value="retired">Retired</option>} {canPublish && <option value="retired">Retired</option>}
</select> </select>
</Field> </Field>
<Field label="Signature"> <Field label="Signature" documentation={FORMS_FIELD_DOCUMENTATION}>
<select value={draft.signature_requirement} disabled={busy} onChange={(event) => setDraft({ ...draft, signature_requirement: event.target.value as FormDefinition["signature_requirement"] })}> <select value={draft.signature_requirement} disabled={busy} onChange={(event) => setDraft({ ...draft, signature_requirement: event.target.value as FormDefinition["signature_requirement"] })}>
<option value="none">Not used</option> <option value="none">Not used</option>
<option value="optional">Optional</option> <option value="optional">Optional</option>
@@ -181,7 +228,7 @@ export default function FormDefinitionDialog({
<div className="form-definition-toggle"> <div className="form-definition-toggle">
<ToggleSwitch label="Draft saving" checked={draft.allow_drafts} disabled={busy} onChange={(allow_drafts) => setDraft({ ...draft, allow_drafts })} /> <ToggleSwitch label="Draft saving" checked={draft.allow_drafts} disabled={busy} onChange={(allow_drafts) => setDraft({ ...draft, allow_drafts })} />
</div> </div>
<Field label="Policy references" className="form-definition-wide"> <Field label="Policy references" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
<input value={draft.policy_refs.join(", ")} disabled={busy} placeholder="policy:permit-intake" onChange={(event) => setDraft({ ...draft, policy_refs: splitValues(event.target.value) })} /> <input value={draft.policy_refs.join(", ")} disabled={busy} placeholder="policy:permit-intake" onChange={(event) => setDraft({ ...draft, policy_refs: splitValues(event.target.value) })} />
</Field> </Field>
<div className="form-definition-handoffs form-definition-wide"> <div className="form-definition-handoffs form-definition-wide">
@@ -201,7 +248,7 @@ export default function FormDefinitionDialog({
/> />
)} )}
</div> </div>
<Field label="Accessibility instructions" className="form-definition-wide"> <Field label="Accessibility instructions" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
<textarea <textarea
rows={2} rows={2}
value={String(draft.accessibility?.instructions ?? "")} value={String(draft.accessibility?.instructions ?? "")}
@@ -253,6 +300,7 @@ export default function FormDefinitionDialog({
icon={<Trash2 size={16} />} icon={<Trash2 size={16} />}
variant="danger" variant="danger"
disabled={busy || draft.fields.length === 1} disabled={busy || draft.fields.length === 1}
disabledReason={busy ? FORMS_I18N.busy : draft.fields.length === 1 ? FORMS_I18N.oneField : undefined}
onClick={() => setDraft(removeField(draft, index))} onClick={() => setDraft(removeField(draft, index))}
/> />
</div> </div>
@@ -261,10 +309,23 @@ export default function FormDefinitionDialog({
<PageEditor draft={draft} disabled={busy} onChange={setDraft} /> <PageEditor draft={draft} disabled={busy} onChange={setDraft} />
<LocalizationEditor draft={draft} disabled={busy} onChange={setDraft} /> <LocalizationEditor draft={draft} disabled={busy} onChange={setDraft} />
<DefinitionPreview definition={draft} previous={definition} /> <DefinitionPreview definition={draft} previous={definition} />
<Field label="Change reason"> <Field label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}>
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" /> <input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
</Field> </Field>
</div> </div>
<ConfirmDialog
open={confirmLifecycle}
title="i18n:govoplan-forms.lifecycle_title"
message={i18nMessage("i18n:govoplan-forms.lifecycle_message", { state: translateText(humanize(draft.publication_state)) })}
confirmLabel="Save revision"
tone={draft.publication_state === "retired" ? "danger" : "default"}
busy={busy}
onCancel={() => setConfirmLifecycle(false)}
onConfirm={() => {
setConfirmLifecycle(false);
void save();
}}
/>
</Dialog> </Dialog>
); );
} }
+37 -10
View File
@@ -1,8 +1,10 @@
import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react"; import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { import {
ActionBlockerHint,
Button, Button,
Dialog, Dialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
IconButton, IconButton,
FormField, FormField,
@@ -10,6 +12,8 @@ import {
PageScrollViewport, PageScrollViewport,
StatusBadge, StatusBadge,
hasScope, hasScope,
useUnsavedChanges,
useUnsavedDraftGuard,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -21,6 +25,7 @@ import {
type FormPackageFragment type FormPackageFragment
} from "../../api/forms"; } from "../../api/forms";
import FormDefinitionDialog from "./FormDefinitionDialog"; import FormDefinitionDialog from "./FormDefinitionDialog";
import { FORMS_DOCUMENTATION, FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
export default function FormsPage({ settings, auth }: PlatformRouteContext) { export default function FormsPage({ settings, auth }: PlatformRouteContext) {
@@ -36,6 +41,7 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
const [importReason, setImportReason] = useState(""); const [importReason, setImportReason] = useState("");
const [importAssessment, setImportAssessment] = useState(""); const [importAssessment, setImportAssessment] = useState("");
const [importBusy, setImportBusy] = useState(false); const [importBusy, setImportBusy] = useState(false);
const { requestDiscard } = useUnsavedChanges();
const importInput = useRef<HTMLInputElement>(null); const importInput = useRef<HTMLInputElement>(null);
const canWrite = hasScope(auth, "forms:definition:write"); const canWrite = hasScope(auth, "forms:definition:write");
const canAdmin = hasScope(auth, "forms:definition:admin"); const canAdmin = hasScope(auth, "forms:definition:admin");
@@ -87,21 +93,40 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
} }
} }
async function importPackage() { async function importPackage(): Promise<boolean> {
if (!importing || !importReason.trim()) return; if (!importing || !importReason.trim()) return false;
setImportBusy(true); setImportBusy(true);
setError(""); setError("");
try { try {
await importFormDefinitionPackage(settings, importing, { changeReason: importReason.trim() }); await importFormDefinitionPackage(settings, importing, { changeReason: importReason.trim() });
setImporting(null); setImporting(null);
await load(); await load();
return true;
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form package could not be imported."); setError(reason instanceof Error ? reason.message : "The Form package could not be imported.");
return false;
} finally { } finally {
setImportBusy(false); setImportBusy(false);
} }
} }
useUnsavedDraftGuard({
dirty: Boolean(importing && importReason),
onSave: importPackage,
onDiscard: () => {
setImporting(null);
setImportReason("");
},
title: "i18n:govoplan-forms.unsaved_title",
message: "i18n:govoplan-forms.unsaved_message"
});
function closeImport() {
if (importBusy) return;
if (importing && importReason) requestDiscard(() => setImporting(null));
else setImporting(null);
}
async function downloadPackage(item: FormDefinition) { async function downloadPackage(item: FormDefinition) {
setError(""); setError("");
try { try {
@@ -135,14 +160,16 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<option value="retired">Retired</option> <option value="retired">Retired</option>
</select> </select>
</label> </label>
<IconButton label="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} /> <IconButton label="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} disabledReason={loading ? FORMS_I18N.loading : undefined} />
{canWrite && <IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading} />} <IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading || !canWrite} disabledReason={loading ? FORMS_I18N.loading : !canWrite ? FORMS_I18N.writeReason : undefined} />
<input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} /> <input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} />
{canWrite && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>} <Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? FORMS_I18N.writeReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>
<span className="forms-count">{total}</span> <span className="forms-count">{total}</span>
<DocumentationHelpLink reference={FORMS_DOCUMENTATION} />
</div> </div>
<PageScrollViewport className="forms-list-viewport"> <PageScrollViewport className="forms-list-viewport">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Form management permission", details: FORMS_I18N.writeReason, requiredAction: FORMS_I18N.permissionAction, actor: FORMS_I18N.permissionActor, target: FORMS_I18N.permissionDestination }} labels={{ requiredAction: FORMS_I18N.requiredAction, actor: FORMS_I18N.actor, target: FORMS_I18N.destination }} documentation={FORMS_DOCUMENTATION} />}
{loading && <LoadingIndicator label="Loading Form definitions" />} {loading && <LoadingIndicator label="Loading Form definitions" />}
{!loading && !error && items.length === 0 && <div className="forms-empty">No matching definitions.</div>} {!loading && !error && items.length === 0 && <div className="forms-empty">No matching definitions.</div>}
{!loading && items.length > 0 && {!loading && items.length > 0 &&
@@ -157,7 +184,7 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} /> <StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} />
<span className="forms-row-actions"> <span className="forms-row-actions">
<IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(item)} /> <IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(item)} />
{mayRevise && <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} onClick={() => setEditing(item)} />} <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} disabled={!mayRevise} disabledReason={!canWrite ? FORMS_I18N.writeReason : item.publication_state === "retired" || (item.publication_state === "published" && !canAdmin) ? FORMS_I18N.lifecycleReason : undefined} onClick={() => setEditing(item)} />
</span> </span>
</div> </div>
); );
@@ -183,16 +210,16 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<Dialog <Dialog
open={Boolean(importing)} open={Boolean(importing)}
title="Import Form package" title="Import Form package"
onClose={() => setImporting(null)} onClose={closeImport}
closeDisabled={importBusy} closeDisabled={importBusy}
portal portal
footer={<> footer={<>
<Button onClick={() => setImporting(null)} disabled={importBusy}>Cancel</Button> <Button onClick={closeImport} disabled={importBusy} disabledReason={importBusy ? FORMS_I18N.busy : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void importPackage()} disabled={importBusy || !importReason.trim()}>{importBusy ? "Importing" : "Import as draft"}</Button> <Button variant="primary" onClick={() => void importPackage()} disabled={importBusy || !importReason.trim()} disabledReason={importBusy ? FORMS_I18N.busy : !importReason.trim() ? FORMS_I18N.incomplete : undefined}>{importBusy ? "Importing" : "Import as draft"}</Button>
</>}> </>}>
<div className="forms-package-import"> <div className="forms-package-import">
<p>Assessment: <strong>{humanize(importAssessment)}</strong>. The source revision is retained as provenance and imported as a new local draft.</p> <p>Assessment: <strong>{humanize(importAssessment)}</strong>. The source revision is retained as provenance and imported as a new local draft.</p>
<FormField label="Change reason"><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField> <FormField label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField>
</div> </div>
</Dialog> </Dialog>
</main> </main>
@@ -0,0 +1,27 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const FORMS_DOCUMENTATION = {
topicId: "forms.definitions",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const FORMS_FIELD_DOCUMENTATION = {
topicId: "forms.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const FORMS_I18N = {
loading: "i18n:govoplan-forms.loading_reason",
busy: "i18n:govoplan-forms.busy_reason",
writeReason: "i18n:govoplan-forms.write_permission_reason",
adminReason: "i18n:govoplan-forms.admin_permission_reason",
lifecycleReason: "i18n:govoplan-forms.lifecycle_reason",
incomplete: "i18n:govoplan-forms.incomplete_reason",
oneField: "i18n:govoplan-forms.one_field_reason",
requiredAction: "i18n:govoplan-forms.required_action",
actor: "i18n:govoplan-forms.responsible_actor",
destination: "i18n:govoplan-forms.destination",
permissionAction: "i18n:govoplan-forms.permission_action",
permissionActor: "i18n:govoplan-forms.permission_actor",
permissionDestination: "i18n:govoplan-forms.permission_destination"
} as const;
+149
View File
@@ -0,0 +1,149 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-forms.form_definitions": "Form definitions",
"i18n:govoplan-forms.loading_reason": "Form definitions are still loading.",
"i18n:govoplan-forms.busy_reason": "Another Form definition action is still running.",
"i18n:govoplan-forms.write_permission_reason": "Your account may not create, import, or revise Form definitions.",
"i18n:govoplan-forms.admin_permission_reason": "Publishing and retirement require Form administration permission.",
"i18n:govoplan-forms.lifecycle_reason": "Retired definitions cannot be revised; published definitions require administrative authority.",
"i18n:govoplan-forms.incomplete_reason": "Complete the title, key, fields, and change reason first.",
"i18n:govoplan-forms.one_field_reason": "A Form definition must retain at least one field.",
"i18n:govoplan-forms.required_action": "Required action",
"i18n:govoplan-forms.responsible_actor": "Responsible actor",
"i18n:govoplan-forms.destination": "Destination",
"i18n:govoplan-forms.permission_action": "Ask for the corresponding Form definition permission.",
"i18n:govoplan-forms.permission_actor": "An Access or tenant administrator",
"i18n:govoplan-forms.permission_destination": "Access role assignments",
"i18n:govoplan-forms.unsaved_title": "Unsaved Form definition",
"i18n:govoplan-forms.unsaved_message": "Save or discard the Form definition draft before leaving this surface.",
"i18n:govoplan-forms.lifecycle_title": "Confirm definition lifecycle",
"i18n:govoplan-forms.lifecycle_message": "Save this revision as {state}? Existing submissions keep their exact definition revision.",
"Form definitions": "Form definitions",
"Search definitions": "Search definitions",
"Search Form definitions": "Search Form definitions",
"Search": "Search",
"State": "State",
"All": "All",
"Draft": "Draft",
"Published": "Published",
"Retired": "Retired",
"Refresh definitions": "Refresh definitions",
"Import Form package": "Import Form package",
"New definition": "New definition",
"Loading Form definitions": "Loading Form definitions",
"No matching definitions.": "No matching definitions.",
"Assessment": "Assessment",
"Cancel": "Cancel",
"Importing": "Importing",
"Import as draft": "Import as draft",
"Change reason": "Change reason",
"New Form definition": "New Form definition",
"Saving": "Saving",
"Save revision": "Save revision",
"Title": "Title",
"Key": "Key",
"Description": "Description",
"Publication state": "Publication state",
"Signature": "Signature",
"Not used": "Not used",
"Optional": "Optional",
"Required": "Required",
"Maximum attachments": "Maximum attachments",
"Draft saving": "Draft saving",
"Policy references": "Policy references",
"Permitted handoffs": "Permitted handoffs",
"Case": "Case",
"Workflow": "Workflow",
"Record": "Record",
"Accessibility instructions": "Accessibility instructions",
"Fields": "Fields",
"Add field": "Add field",
"Label": "Label",
"Type": "Type",
"Help text": "Help text",
"Options": "Options",
"Pages and sections": "Pages and sections",
"Enable pages": "Enable pages",
"Add page": "Add page",
"Add section": "Add section",
"Localizations": "Localizations",
"Add locale": "Add locale",
"Preview and revision changes": "Preview and revision changes",
"No Form management permission": "No Form management permission"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-forms.form_definitions": "Formulardefinitionen",
"i18n:govoplan-forms.loading_reason": "Formulardefinitionen werden noch geladen.",
"i18n:govoplan-forms.busy_reason": "Eine andere Aktion für Formulardefinitionen läuft noch.",
"i18n:govoplan-forms.write_permission_reason": "Ihr Konto darf Formulardefinitionen nicht erstellen, importieren oder überarbeiten.",
"i18n:govoplan-forms.admin_permission_reason": "Veröffentlichung und Stilllegung erfordern die Formularadministration.",
"i18n:govoplan-forms.lifecycle_reason": "Stillgelegte Definitionen können nicht überarbeitet werden; veröffentlichte Definitionen erfordern administrative Berechtigung.",
"i18n:govoplan-forms.incomplete_reason": "Füllen Sie zuerst Titel, Schlüssel, Felder und Änderungsgrund aus.",
"i18n:govoplan-forms.one_field_reason": "Eine Formulardefinition muss mindestens ein Feld behalten.",
"i18n:govoplan-forms.required_action": "Erforderliche Aktion",
"i18n:govoplan-forms.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-forms.destination": "Ziel",
"i18n:govoplan-forms.permission_action": "Fordern Sie die entsprechende Berechtigung für Formulardefinitionen an.",
"i18n:govoplan-forms.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
"i18n:govoplan-forms.permission_destination": "Zugriff und Rollenzuweisungen",
"i18n:govoplan-forms.unsaved_title": "Ungespeicherte Formulardefinition",
"i18n:govoplan-forms.unsaved_message": "Speichern oder verwerfen Sie den Entwurf der Formulardefinition, bevor Sie diese Oberfläche verlassen.",
"i18n:govoplan-forms.lifecycle_title": "Definitionslebenszyklus bestätigen",
"i18n:govoplan-forms.lifecycle_message": "Diese Revision als {state} speichern? Bestehende Einreichungen behalten ihre exakte Definitionsrevision.",
"Form definitions": "Formulardefinitionen",
"Search definitions": "Definitionen suchen",
"Search Form definitions": "Formulardefinitionen durchsuchen",
"Search": "Suchen",
"State": "Status",
"All": "Alle",
"Draft": "Entwurf",
"Published": "Veröffentlicht",
"Retired": "Stillgelegt",
"Refresh definitions": "Definitionen aktualisieren",
"Import Form package": "Formularpaket importieren",
"New definition": "Neue Definition",
"Loading Form definitions": "Formulardefinitionen werden geladen",
"No matching definitions.": "Keine passenden Definitionen.",
"Assessment": "Bewertung",
"Cancel": "Abbrechen",
"Importing": "Importieren",
"Import as draft": "Als Entwurf importieren",
"Change reason": "Änderungsgrund",
"New Form definition": "Neue Formulardefinition",
"Saving": "Speichern",
"Save revision": "Revision speichern",
"Title": "Titel",
"Key": "Schlüssel",
"Description": "Beschreibung",
"Publication state": "Veröffentlichungsstatus",
"Signature": "Signatur",
"Not used": "Nicht verwendet",
"Optional": "Optional",
"Required": "Erforderlich",
"Maximum attachments": "Maximale Anhänge",
"Draft saving": "Entwurfsspeicherung",
"Policy references": "Richtlinienreferenzen",
"Permitted handoffs": "Zulässige Übergaben",
"Case": "Fall",
"Workflow": "Workflow",
"Record": "Datensatz",
"Accessibility instructions": "Hinweise zur Barrierefreiheit",
"Fields": "Felder",
"Add field": "Feld hinzufügen",
"Label": "Bezeichnung",
"Type": "Typ",
"Help text": "Hilfetext",
"Options": "Optionen",
"Pages and sections": "Seiten und Abschnitte",
"Enable pages": "Seiten aktivieren",
"Add page": "Seite hinzufügen",
"Add section": "Abschnitt hinzufügen",
"Localizations": "Lokalisierungen",
"Add locale": "Sprache hinzufügen",
"Preview and revision changes": "Vorschau und Revisionsänderungen",
"No Form management permission": "Keine Berechtigung zur Formularverwaltung"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+4 -2
View File
@@ -1,5 +1,6 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/forms.css"; import "./styles/forms.css";
@@ -7,10 +8,11 @@ const FormsPage = lazy(() => import("./features/forms/FormsPage"));
export const formsModule: PlatformWebModule = { export const formsModule: PlatformWebModule = {
id: "forms", id: "forms",
label: "Form definitions", label: "i18n:govoplan-forms.form_definitions",
version: "0.1.14", version: "0.1.14",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy"], optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy"],
translations: generatedTranslations,
routes: [ routes: [
{ {
path: "/forms", path: "/forms",
@@ -23,7 +25,7 @@ export const formsModule: PlatformWebModule = {
navItems: [ navItems: [
{ {
to: "/forms", to: "/forms",
label: "Form definitions", label: "i18n:govoplan-forms.form_definitions",
iconName: "list-tree", iconName: "list-tree",
anyOf: ["forms:definition:read"], anyOf: ["forms:definition:read"],
order: 36, order: 36,
+9
View File
@@ -52,6 +52,10 @@
padding: 16px 18px 24px; padding: 16px 18px 24px;
} }
.forms-list-viewport > .action-blocker-hint {
margin-bottom: 16px;
}
.forms-list { .forms-list {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -136,6 +140,11 @@
gap: 18px; gap: 18px;
} }
.form-definition-help {
display: flex;
justify-content: flex-end;
}
.form-definition-grid { .form-definition-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));