Migrate Forms Runtime interface patterns

This commit is contained in:
2026-08-03 13:02:19 +02:00
parent b4388b1e1e
commit 07dd35bcc0
9 changed files with 440 additions and 32 deletions
+33
View File
@@ -0,0 +1,33 @@
# Forms Runtime Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the Forms
Runtime list, instance, submission, and native handoff surfaces. Core owns the
shared controls and interaction states. Forms Runtime keeps ownership of
instance revisions, validation, receipts, and handoff evidence.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/forms-runtime` | Filtered work queue | Select assigned or authorized instance | Shared loading/error/empty/help states and locale-aware dates |
| `/forms-runtime/:instanceId` | Guided runtime form | Change draft values or inspect immutable submission | Definition-provided fields/help, permission/lifecycle blocker, guarded draft |
| Save and submit actions | Consequential editor | Save revision or issue immutable receipt | Explained disabled reasons; submission confirmation; server validation remains authoritative |
| Case/Workflow handoffs | External-effect recovery queue | Record intent, execute, retry, reconcile, compensate | Permission reason, start confirmation, explicit unknown-outcome and compensation semantics |
| History and revisions | Evidence/provenance | Reconstruct status and value revisions | Immutable chronological evidence with platform-locale timestamps |
## Consequence And Availability Rules
- Runtime localization follows the platform-selected language, not the browser
language independently of the active account preference.
- A draft save requires changed values and a reason. Submission is separately
confirmed and creates an immutable receipt after server validation.
- Read-only fields explain whether permission or lifecycle caused the state.
- Handoff intent is persisted before provider execution. Unknown outcomes are
reconciled before retry; compensation records verified absence only.
- Optional Files, Policy, Case, Workflow, Approval, Portal, and Audit behavior
remains behind declared capabilities and interfaces.
Native controls preserve keyboard order, shared dialogs manage focus, changed
values use the global unsaved-draft guard, and bounded list/detail regions keep
their existing responsive scrolling. English and German catalogues cover
module-owned copy; definition content uses its own published localization.
@@ -317,6 +317,66 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
metadata={
"seed": True,
"help_contexts": [
"forms_runtime.navigation",
"forms_runtime.workspace",
"forms_runtime.instance",
"forms_runtime.state.read-only",
"forms_runtime.state.permission-blocked",
],
"privacy_notes": [
"Form values are returned only through tenant-bound instance permissions and ownership rules.",
"Validation messages expose field-level diagnostics without disclosing unrelated submissions.",
"Handoff rows retain provider references and outcomes but do not bypass target-module authorization.",
],
},
),
DocumentationTopic(
id="forms_runtime.reference.fields-and-consequences",
title="Form values, submission, and handoff consequences",
summary="Runtime field behavior, immutable receipts, draft revisions, optional evidence, and recoverable external effects.",
body=(
"The active instance resolves one exact published Form definition revision. Visibility conditions alter presentation, "
"not server validation or authorization. Saving a permitted draft creates a new revision with its change reason. "
"Submitting validates values, attachments, signatures, and policy requirements and records an immutable receipt; it is "
"not an editable draft save. A Case or Workflow handoff records intent before calling its optional provider and uses a "
"stable idempotency key. Rejected effects may be retried. Unknown outcomes must be reconciled before retry to avoid a "
"duplicate target. Administrative compensation records verified absence and never deletes a remote target."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Forms Runtime security and recovery",
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"seed": True,
"help_contexts": [
"forms_runtime.field.dynamic-value",
"forms_runtime.field.change-reason",
"forms_runtime.field.handoff-kind",
"forms_runtime.field.target-binding",
"forms_runtime.action.save-draft",
"forms_runtime.action.submit",
"forms_runtime.action.start-handoff",
"forms_runtime.action.reconcile-handoff",
"forms_runtime.action.compensate-handoff",
],
"consequence_classes": {
"save_draft": "Creates an immutable draft revision with a change reason.",
"submit": "Validates the exact definition and creates an immutable submission receipt.",
"start_handoff": "Persists intent before invoking an optional Case or Workflow provider.",
"reconcile": "Resolves an outcome-unknown effect without unsafe duplicate execution.",
"compensate": "Records an administrative proof that no target effect exists.",
},
},
), ),
), ),
architecture=declared_module_architecture( architecture=declared_module_architecture(
@@ -0,0 +1,33 @@
from __future__ import annotations
import unittest
from govoplan_forms_runtime.backend.manifest import manifest
class FormsRuntimeInterfaceDocumentationContractTests(unittest.TestCase):
def test_routes_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual(
{"/forms-runtime", "/forms-runtime/:instanceId"},
{item.path for item in frontend.routes}, # type: ignore[union-attr]
)
self.assertEqual(
{"forms_runtime.navigation", "forms_runtime.workspace", "forms_runtime.instance"},
{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_runtime.submissions"]
reference = topics["forms_runtime.reference.fields-and-consequences"]
self.assertIn("forms_runtime.instance", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
self.assertIn("forms_runtime.action.submit", reference.metadata["help_contexts"])
self.assertIn("submit", reference.metadata["consequence_classes"])
self.assertIn("compensate", reference.metadata["consequence_classes"])
if __name__ == "__main__":
unittest.main()
+107 -24
View File
@@ -2,15 +2,20 @@ import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { import {
ActionBlockerHint,
Button, Button,
ConfirmDialog, ConfirmDialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
i18nMessage,
useGuardedNavigate, useGuardedNavigate,
usePlatformLanguage,
useUnsavedDraftGuard,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -31,11 +36,17 @@ import {
type FormHandoff, type FormHandoff,
type ValidationResult type ValidationResult
} from "../../api/formsRuntime"; } from "../../api/formsRuntime";
import {
FORMS_RUNTIME_DOCUMENTATION,
FORMS_RUNTIME_FIELD_DOCUMENTATION,
FORMS_RUNTIME_I18N
} from "./interfacePatterns";
export default function FormInstancePage({ settings, auth }: PlatformRouteContext) { export default function FormInstancePage({ settings, auth }: PlatformRouteContext) {
const { instanceId = "" } = useParams(); const { instanceId = "" } = useParams();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const [instance, setInstance] = useState<FormInstance | null>(null); const [instance, setInstance] = useState<FormInstance | null>(null);
const [definition, setDefinition] = useState<FormDefinition | null>(null); const [definition, setDefinition] = useState<FormDefinition | null>(null);
const [history, setHistory] = useState<FormInstance[]>([]); const [history, setHistory] = useState<FormInstance[]>([]);
@@ -50,6 +61,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const [handoffKind, setHandoffKind] = useState<"case" | "workflow">("case"); const [handoffKind, setHandoffKind] = useState<"case" | "workflow">("case");
const [handoffBinding, setHandoffBinding] = useState(""); const [handoffBinding, setHandoffBinding] = useState("");
const [compensating, setCompensating] = useState<FormHandoff | null>(null); const [compensating, setCompensating] = useState<FormHandoff | null>(null);
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
const load = useCallback(async (signal?: AbortSignal) => { const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true); setLoading(true);
@@ -91,8 +104,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
} }
}, [definition, handoffKind]); }, [definition, handoffKind]);
const editable = instance?.status === "started" || instance?.status === "draft"; const editableLifecycle = instance?.status === "started" || instance?.status === "draft";
const canSave = instance?.status === "draft" && definition?.allow_drafts; const canParticipate = hasScope(auth, "forms_runtime:submission:participate");
const canWrite = hasScope(auth, "forms_runtime:workspace:write");
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
const canEditPermission = canParticipate || canWrite;
const editable = Boolean(editableLifecycle && canEditPermission);
const canSave = Boolean(instance?.status === "draft" && definition?.allow_drafts && canEditPermission);
const changed = useMemo( const changed = useMemo(
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)), () => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
[instance, values] [instance, values]
@@ -106,30 +124,43 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
return grouped; return grouped;
}, [instance]); }, [instance]);
const localized = useMemo( const localized = useMemo(
() => localizeDefinition(definition), () => localizeDefinition(definition, language),
[definition] [definition, language]
); );
const groups = useMemo( const groups = useMemo(
() => definition ? visibleGroups(definition, values) : [], () => definition ? visibleGroups(definition, values) : [],
[definition, values] [definition, values]
); );
const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref); const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref);
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin"); const canHandoff = canWrite || canAdmin;
async function save() { async function save(): Promise<boolean> {
if (!instance || !canSave || !changed || !changeReason.trim()) return; if (!instance || !canSave || !changed || !changeReason.trim()) return false;
setSaving(true); setSaving(true);
setError(""); setError("");
try { try {
await saveFormDraft(settings, instance, values, changeReason.trim()); await saveFormDraft(settings, instance, values, changeReason.trim());
await load(); await load();
return true;
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : "The draft could not be saved."); setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
useUnsavedDraftGuard({
dirty: Boolean(instance && (changed || changeReason)),
onSave: save,
onDiscard: () => {
setValues(instance?.values ?? {});
setChangeReason("");
},
title: "i18n:govoplan-forms-runtime.unsaved_title",
message: "i18n:govoplan-forms-runtime.unsaved_message"
});
async function submit() { async function submit() {
if (!instance || !editable) return; if (!instance || !editable) return;
setSaving(true); setSaving(true);
@@ -195,7 +226,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
Forms Forms
</Button> </Button>
{definition && <strong>{localized.title}</strong>} {definition && <strong>{localized.title}</strong>}
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={humanize(instance.status)} />} {instance && <StatusBadge status={editableLifecycle ? "active" : "inactive"} label={stateLabel(instance.status)} />}
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
</div> </div>
<PageScrollViewport className="form-instance-viewport"> <PageScrollViewport className="form-instance-viewport">
{error && {error &&
@@ -204,6 +236,23 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
</DismissibleAlert> </DismissibleAlert>
} }
{loading && <LoadingIndicator label="Loading Form" />} {loading && <LoadingIndicator label="Loading Form" />}
{!loading && instance && !editable &&
<ActionBlockerHint
tone="info"
reason={canEditPermission ? {
summary: "Read-only Form",
details: FORMS_RUNTIME_I18N.lifecycleReason
} : {
summary: "Read-only Form",
details: FORMS_RUNTIME_I18N.editReason,
requiredAction: FORMS_RUNTIME_I18N.permissionAction,
actor: FORMS_RUNTIME_I18N.permissionActor,
target: FORMS_RUNTIME_I18N.permissionDestination
}}
labels={{ requiredAction: FORMS_RUNTIME_I18N.requiredAction, actor: FORMS_RUNTIME_I18N.actor, target: FORMS_RUNTIME_I18N.destination }}
documentation={FORMS_RUNTIME_DOCUMENTATION}
/>
}
{!loading && instance && definition && {!loading && instance && definition &&
<div className="form-instance-content"> <div className="form-instance-content">
<section className="form-instance-main"> <section className="form-instance-main">
@@ -226,7 +275,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}} }}
optionLabels={localized.optionLabels[field.key] ?? {}} optionLabels={localized.optionLabels[field.key] ?? {}}
value={values[field.key]} value={values[field.key]}
disabled={!editable || saving} disabled={!editable || saving}
diagnostics={diagnostics.get(field.key) ?? []} diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => { onChange={(value) => setValues((current) => {
const next = { ...current }; const next = { ...current };
@@ -247,6 +296,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
} }
{editable && {editable &&
<div className="form-instance-actions"> <div className="form-instance-actions">
<DocumentationHelpLink reference={FORMS_RUNTIME_FIELD_DOCUMENTATION} />
{canSave && {canSave &&
<label className="form-change-reason"> <label className="form-change-reason">
<span>Change reason</span> <span>Change reason</span>
@@ -260,12 +310,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
{canSave && {canSave &&
<Button <Button
onClick={() => void save()} onClick={() => void save()}
disabled={!changed || !changeReason.trim() || saving}> disabled={!changed || !changeReason.trim() || saving}
disabledReason={saving ? FORMS_RUNTIME_I18N.saving : !changed ? FORMS_RUNTIME_I18N.unchanged : !changeReason.trim() ? FORMS_RUNTIME_I18N.changeReason : undefined}>
<Save size={16} aria-hidden="true" /> <Save size={16} aria-hidden="true" />
Save draft Save draft
</Button> </Button>
} }
<Button variant="primary" onClick={() => void submit()} disabled={saving}> <Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : undefined}>
<Send size={16} aria-hidden="true" /> <Send size={16} aria-hidden="true" />
Submit Submit
</Button> </Button>
@@ -288,7 +339,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
{definition.handoff_kinds.includes("workflow") && <option value="workflow">Start Workflow</option>} {definition.handoff_kinds.includes("workflow") && <option value="workflow">Start Workflow</option>}
</select> </select>
<input value={handoffBinding} disabled={handoffBusy} onChange={(event) => setHandoffBinding(event.target.value)} placeholder="Target binding (optional)" aria-label="Exact target binding" /> <input value={handoffBinding} disabled={handoffBusy} onChange={(event) => setHandoffBinding(event.target.value)} placeholder="Target binding (optional)" aria-label="Exact target binding" />
<Button variant="primary" disabled={handoffBusy} onClick={() => void startHandoff()}><Send size={15} aria-hidden="true" />Start</Button> <Button variant="primary" disabled={handoffBusy || !canHandoff} disabledReason={handoffBusy ? FORMS_RUNTIME_I18N.saving : !canHandoff ? FORMS_RUNTIME_I18N.handoffReason : undefined} onClick={() => setConfirmingHandoff(true)}><Send size={15} aria-hidden="true" />Start</Button>
</div> </div>
} }
</div> </div>
@@ -296,13 +347,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<div className="form-handoff-list"> <div className="form-handoff-list">
{handoffs.map((item) => {handoffs.map((item) =>
<div className="form-handoff-row" key={item.effect_id}> <div className="form-handoff-row" key={item.effect_id}>
<span><strong>{humanize(item.binding_kind)}</strong><small>{item.binding_reference}</small></span> <span><strong>{domainLabel(item.binding_kind)}</strong><small>{item.binding_reference}</small></span>
<StatusBadge status={item.state === "accepted" || item.state === "reconciled" ? "active" : "inactive"} label={humanize(item.state)} /> <StatusBadge status={item.state === "accepted" || item.state === "reconciled" ? "active" : "inactive"} label={stateLabel(item.state)} />
{item.last_error && <span className="form-handoff-error">{item.last_error}</span>} {item.last_error && <span className="form-handoff-error">{item.last_error}</span>}
<span className="form-handoff-actions"> <span className="form-handoff-actions">
{item.href && <Button onClick={() => navigate(item.href!)}><ExternalLink size={15} aria-hidden="true" />Open</Button>} {item.href && <Button onClick={() => navigate(item.href!)}><ExternalLink size={15} aria-hidden="true" />Open</Button>}
{item.state === "rejected" && <Button disabled={handoffBusy} onClick={() => void handoffAction(item, "retry")}><RefreshCw size={15} aria-hidden="true" />Retry</Button>} {item.state === "rejected" && <Button disabled={handoffBusy || !canHandoff} disabledReason={handoffBusy ? FORMS_RUNTIME_I18N.saving : !canHandoff ? FORMS_RUNTIME_I18N.handoffReason : undefined} onClick={() => void handoffAction(item, "retry")}><RefreshCw size={15} aria-hidden="true" />Retry</Button>}
{item.state === "outcome_unknown" && <Button disabled={handoffBusy} onClick={() => void handoffAction(item, "reconcile")}><RefreshCw size={15} aria-hidden="true" />Reconcile</Button>} {item.state === "outcome_unknown" && <Button disabled={handoffBusy || !canHandoff} disabledReason={handoffBusy ? FORMS_RUNTIME_I18N.saving : !canHandoff ? FORMS_RUNTIME_I18N.handoffReason : undefined} onClick={() => void handoffAction(item, "reconcile")}><RefreshCw size={15} aria-hidden="true" />Reconcile</Button>}
{canAdmin && (item.state === "rejected" || item.state === "outcome_unknown") && <Button variant="danger" disabled={handoffBusy} onClick={() => setCompensating(item)}>Compensate</Button>} {canAdmin && (item.state === "rejected" || item.state === "outcome_unknown") && <Button variant="danger" disabled={handoffBusy} onClick={() => setCompensating(item)}>Compensate</Button>}
</span> </span>
</div> </div>
@@ -317,9 +368,9 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<ol> <ol>
{events.map((event) => {events.map((event) =>
<li key={event.event_id}> <li key={event.event_id}>
<strong>{humanize(event.status)}</strong> <strong>{stateLabel(event.status)}</strong>
<span>{humanize(event.event_type)}</span> <span>{humanize(event.event_type)}</span>
<time>{formatDateTime(event.occurred_at)}</time> <time>{formatDateTime(event.occurred_at, language)}</time>
</li> </li>
)} )}
</ol> </ol>
@@ -331,7 +382,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<li key={item.revision}> <li key={item.revision}>
<strong>Revision {item.revision}</strong> <strong>Revision {item.revision}</strong>
<span>{item.change_reason}</span> <span>{item.change_reason}</span>
<time>{formatDateTime(item.recorded_at)}</time> <time>{formatDateTime(item.recorded_at, language)}</time>
</li> </li>
)} )}
</ol> </ol>
@@ -341,6 +392,30 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
} }
</PageScrollViewport> </PageScrollViewport>
</div> </div>
<ConfirmDialog
open={confirmingSubmit}
title="i18n:govoplan-forms-runtime.submit_title"
message="i18n:govoplan-forms-runtime.submit_message"
confirmLabel="Submit"
busy={saving}
onCancel={() => setConfirmingSubmit(false)}
onConfirm={() => {
setConfirmingSubmit(false);
void submit();
}}
/>
<ConfirmDialog
open={confirmingHandoff}
title="i18n:govoplan-forms-runtime.handoff_title"
message={i18nMessage("i18n:govoplan-forms-runtime.handoff_message", { kind: domainLabel(handoffKind) })}
confirmLabel="Start"
busy={handoffBusy}
onCancel={() => setConfirmingHandoff(false)}
onConfirm={() => {
setConfirmingHandoff(false);
void startHandoff();
}}
/>
<ConfirmDialog <ConfirmDialog
open={Boolean(compensating)} open={Boolean(compensating)}
title="Compensate handoff" title="Compensate handoff"
@@ -565,7 +640,7 @@ function comparable(left: unknown, right: unknown, compare: (left: number | stri
return false; return false;
} }
function localizeDefinition(definition: FormDefinition | null) { function localizeDefinition(definition: FormDefinition | null, language: string) {
const canonical = { const canonical = {
title: definition?.title ?? "", title: definition?.title ?? "",
description: definition?.description ?? null, description: definition?.description ?? null,
@@ -575,8 +650,8 @@ function localizeDefinition(definition: FormDefinition | null) {
sectionTitles: {} as Record<string, string> sectionTitles: {} as Record<string, string>
}; };
if (!definition?.localizations?.length) return canonical; if (!definition?.localizations?.length) return canonical;
const browserLocales = navigator.languages.map((value) => value.toLowerCase()); const requestedLocale = language.toLowerCase();
const localization = definition.localizations.find((item) => browserLocales.some((locale) => locale === item.locale.toLowerCase() || locale.startsWith(`${item.locale.toLowerCase()}-`))) const localization = definition.localizations.find((item) => requestedLocale === item.locale.toLowerCase() || requestedLocale.startsWith(`${item.locale.toLowerCase()}-`))
?? definition.localizations.find((item) => item.locale.toLowerCase() === definition.fallback_locale?.toLowerCase()); ?? definition.localizations.find((item) => item.locale.toLowerCase() === definition.fallback_locale?.toLowerCase());
if (!localization) return canonical; if (!localization) return canonical;
return { return {
@@ -589,10 +664,18 @@ function localizeDefinition(definition: FormDefinition | null) {
}; };
} }
function formatDateTime(value: string): string { function formatDateTime(value: string, locale?: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
} }
function humanize(value: string): string { function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
} }
function stateLabel(value: string): string {
return `i18n:govoplan-forms-runtime.state_${value}`;
}
function domainLabel(value: string): string {
return `i18n:govoplan-forms-runtime.domain_${value}`;
}
+16 -6
View File
@@ -2,20 +2,25 @@ import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Button, Button,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatusBadge, StatusBadge,
i18nMessage,
useGuardedNavigate, useGuardedNavigate,
usePlatformLanguage,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { listFormInstances, type FormInstance } from "../../api/formsRuntime"; import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"]; const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
export default function FormsRuntimePage({ settings }: PlatformRouteContext) { export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const [items, setItems] = useState<FormInstance[]>([]); const [items, setItems] = useState<FormInstance[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [status, setStatus] = useState("open"); const [status, setStatus] = useState("open");
@@ -50,7 +55,7 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
<main className="forms-runtime-page"> <main className="forms-runtime-page">
<div className="forms-runtime-shell"> <div className="forms-runtime-shell">
<div className="forms-runtime-toolbar"> <div className="forms-runtime-toolbar">
<Button onClick={() => void load()} disabled={loading}> <Button onClick={() => void load()} disabled={loading} disabledReason={loading ? FORMS_RUNTIME_I18N.loading : undefined}>
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
Refresh Refresh
</Button> </Button>
@@ -68,7 +73,8 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
</label> </label>
<span className="forms-runtime-count">{total} forms</span> <span className="forms-runtime-count">{i18nMessage("i18n:govoplan-forms-runtime.form_count", { total })}</span>
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
</div> </div>
<PageScrollViewport className="forms-runtime-list-viewport"> <PageScrollViewport className="forms-runtime-list-viewport">
{error && {error &&
@@ -93,8 +99,8 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
<strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong> <strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong>
<span>Revision {item.definition_ref.version ?? "-"}</span> <span>Revision {item.definition_ref.version ?? "-"}</span>
</span> </span>
<span>{formatDateTime(item.recorded_at)}</span> <span>{formatDateTime(item.recorded_at, language)}</span>
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={humanize(item.status)} /> <StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={stateLabel(item.status)} />
</button> </button>
)} )}
</div> </div>
@@ -109,10 +115,14 @@ function isOpen(status: string): boolean {
return OPEN_STATUSES.includes(status); return OPEN_STATUSES.includes(status);
} }
function formatDateTime(value: string): string { function formatDateTime(value: string, locale?: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
} }
function humanize(value: string): string { function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
} }
function stateLabel(value: string): string {
return `i18n:govoplan-forms-runtime.state_${value}`;
}
@@ -0,0 +1,27 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const FORMS_RUNTIME_DOCUMENTATION = {
topicId: "forms_runtime.submissions",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const FORMS_RUNTIME_FIELD_DOCUMENTATION = {
topicId: "forms_runtime.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const FORMS_RUNTIME_I18N = {
loading: "i18n:govoplan-forms-runtime.loading_reason",
saving: "i18n:govoplan-forms-runtime.saving_reason",
editReason: "i18n:govoplan-forms-runtime.edit_permission_reason",
handoffReason: "i18n:govoplan-forms-runtime.handoff_permission_reason",
lifecycleReason: "i18n:govoplan-forms-runtime.lifecycle_reason",
unchanged: "i18n:govoplan-forms-runtime.unchanged_reason",
changeReason: "i18n:govoplan-forms-runtime.change_reason_required",
requiredAction: "i18n:govoplan-forms-runtime.required_action",
actor: "i18n:govoplan-forms-runtime.responsible_actor",
destination: "i18n:govoplan-forms-runtime.destination",
permissionAction: "i18n:govoplan-forms-runtime.permission_action",
permissionActor: "i18n:govoplan-forms-runtime.permission_actor",
permissionDestination: "i18n:govoplan-forms-runtime.permission_destination"
} as const;
+155
View File
@@ -0,0 +1,155 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-forms-runtime.forms": "Forms",
"i18n:govoplan-forms-runtime.loading_reason": "Form data is still loading.",
"i18n:govoplan-forms-runtime.saving_reason": "A Form operation is still running.",
"i18n:govoplan-forms-runtime.edit_permission_reason": "Your account may read this Form but may not change or submit it.",
"i18n:govoplan-forms-runtime.handoff_permission_reason": "Your account may not create or recover Case and Workflow handoffs.",
"i18n:govoplan-forms-runtime.lifecycle_reason": "This Form is immutable in its current lifecycle state.",
"i18n:govoplan-forms-runtime.unchanged_reason": "There are no changed values to save.",
"i18n:govoplan-forms-runtime.change_reason_required": "Enter a change reason before saving the draft.",
"i18n:govoplan-forms-runtime.required_action": "Required action",
"i18n:govoplan-forms-runtime.responsible_actor": "Responsible actor",
"i18n:govoplan-forms-runtime.destination": "Destination",
"i18n:govoplan-forms-runtime.permission_action": "Ask for the appropriate Forms Runtime permission or assignment.",
"i18n:govoplan-forms-runtime.permission_actor": "An Access administrator or the responsible process owner",
"i18n:govoplan-forms-runtime.permission_destination": "Access role assignments or the assigning workflow",
"i18n:govoplan-forms-runtime.unsaved_title": "Unsaved Form",
"i18n:govoplan-forms-runtime.unsaved_message": "Save or discard the changed Form values before leaving this surface.",
"i18n:govoplan-forms-runtime.submit_title": "Submit Form",
"i18n:govoplan-forms-runtime.submit_message": "Submit this Form? The server validates the exact published definition and records an immutable receipt.",
"i18n:govoplan-forms-runtime.handoff_title": "Start governed handoff",
"i18n:govoplan-forms-runtime.handoff_message": "Start the {kind} handoff? Intent is recorded before execution and an unknown outcome requires reconciliation.",
"i18n:govoplan-forms-runtime.form_count": "{total} forms",
"i18n:govoplan-forms-runtime.state_started": "Started",
"i18n:govoplan-forms-runtime.state_draft": "Draft",
"i18n:govoplan-forms-runtime.state_submitted": "Submitted",
"i18n:govoplan-forms-runtime.state_validated": "Validated",
"i18n:govoplan-forms-runtime.state_needs_review": "Needs review",
"i18n:govoplan-forms-runtime.state_accepted": "Accepted",
"i18n:govoplan-forms-runtime.state_rejected": "Rejected",
"i18n:govoplan-forms-runtime.state_handed_off": "Handed off",
"i18n:govoplan-forms-runtime.state_archived": "Archived",
"i18n:govoplan-forms-runtime.state_reconciled": "Reconciled",
"i18n:govoplan-forms-runtime.state_outcome_unknown": "Outcome unknown",
"i18n:govoplan-forms-runtime.state_requested": "Requested",
"i18n:govoplan-forms-runtime.state_compensated": "Compensated",
"i18n:govoplan-forms-runtime.domain_case": "Case",
"i18n:govoplan-forms-runtime.domain_workflow": "Workflow",
"Forms": "Forms",
"Refresh": "Refresh",
"Status": "Status",
"Open": "Open",
"All": "All",
"Draft": "Draft",
"Submitted": "Submitted",
"Needs review": "Needs review",
"Accepted": "Accepted",
"Rejected": "Rejected",
"Handed off": "Handed off",
"Archived": "Archived",
"Loading forms": "Loading forms",
"No matching Forms.": "No matching Forms.",
"Revision": "Revision",
"Loading Form": "Loading Form",
"Change reason": "Change reason",
"Save draft": "Save draft",
"Submit": "Submit",
"Submission receipt": "Submission receipt",
"Case and workflow handoffs": "Case and workflow handoffs",
"Create Case": "Create Case",
"Start Workflow": "Start Workflow",
"Target binding (optional)": "Target binding (optional)",
"Exact target binding": "Exact target binding",
"Start": "Start",
"No handoff has been requested.": "No handoff has been requested.",
"Open target": "Open target",
"Retry": "Retry",
"Reconcile": "Reconcile",
"Compensate": "Compensate",
"Status history": "Status history",
"Revisions": "Revisions",
"Select": "Select",
"Compensate handoff": "Compensate handoff",
"Confirm absent and compensate": "Confirm absent and compensate",
"Read-only Form": "Read-only Form"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-forms-runtime.forms": "Formulare",
"i18n:govoplan-forms-runtime.loading_reason": "Formulardaten werden noch geladen.",
"i18n:govoplan-forms-runtime.saving_reason": "Eine Formularaktion läuft noch.",
"i18n:govoplan-forms-runtime.edit_permission_reason": "Ihr Konto darf dieses Formular lesen, aber nicht ändern oder absenden.",
"i18n:govoplan-forms-runtime.handoff_permission_reason": "Ihr Konto darf keine Fall- oder Workflow-Übergaben erstellen oder wiederherstellen.",
"i18n:govoplan-forms-runtime.lifecycle_reason": "Dieses Formular ist in seinem aktuellen Lebenszyklus unveränderlich.",
"i18n:govoplan-forms-runtime.unchanged_reason": "Es gibt keine geänderten Werte zu speichern.",
"i18n:govoplan-forms-runtime.change_reason_required": "Geben Sie vor dem Speichern des Entwurfs einen Änderungsgrund ein.",
"i18n:govoplan-forms-runtime.required_action": "Erforderliche Aktion",
"i18n:govoplan-forms-runtime.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-forms-runtime.destination": "Ziel",
"i18n:govoplan-forms-runtime.permission_action": "Fordern Sie die passende Formularberechtigung oder Zuweisung an.",
"i18n:govoplan-forms-runtime.permission_actor": "Eine Zugriffsadministration oder die verantwortliche Prozessstelle",
"i18n:govoplan-forms-runtime.permission_destination": "Zugriff und Rollenzuweisungen oder der zuweisende Workflow",
"i18n:govoplan-forms-runtime.unsaved_title": "Ungespeichertes Formular",
"i18n:govoplan-forms-runtime.unsaved_message": "Speichern oder verwerfen Sie die geänderten Formularwerte, bevor Sie diese Oberfläche verlassen.",
"i18n:govoplan-forms-runtime.submit_title": "Formular absenden",
"i18n:govoplan-forms-runtime.submit_message": "Dieses Formular absenden? Der Server prüft die exakte veröffentlichte Definition und erfasst einen unveränderlichen Beleg.",
"i18n:govoplan-forms-runtime.handoff_title": "Geregelte Übergabe starten",
"i18n:govoplan-forms-runtime.handoff_message": "Die Übergabe an {kind} starten? Die Absicht wird vor der Ausführung erfasst; ein unbekanntes Ergebnis muss abgeglichen werden.",
"i18n:govoplan-forms-runtime.form_count": "{total} Formulare",
"i18n:govoplan-forms-runtime.state_started": "Gestartet",
"i18n:govoplan-forms-runtime.state_draft": "Entwurf",
"i18n:govoplan-forms-runtime.state_submitted": "Abgesendet",
"i18n:govoplan-forms-runtime.state_validated": "Validiert",
"i18n:govoplan-forms-runtime.state_needs_review": "Prüfung erforderlich",
"i18n:govoplan-forms-runtime.state_accepted": "Angenommen",
"i18n:govoplan-forms-runtime.state_rejected": "Abgelehnt",
"i18n:govoplan-forms-runtime.state_handed_off": "Übergeben",
"i18n:govoplan-forms-runtime.state_archived": "Archiviert",
"i18n:govoplan-forms-runtime.state_reconciled": "Abgeglichen",
"i18n:govoplan-forms-runtime.state_outcome_unknown": "Ergebnis unbekannt",
"i18n:govoplan-forms-runtime.state_requested": "Angefordert",
"i18n:govoplan-forms-runtime.state_compensated": "Kompensiert",
"i18n:govoplan-forms-runtime.domain_case": "Fall",
"i18n:govoplan-forms-runtime.domain_workflow": "Workflow",
"Forms": "Formulare",
"Refresh": "Aktualisieren",
"Status": "Status",
"Open": "Offen",
"All": "Alle",
"Draft": "Entwurf",
"Submitted": "Abgesendet",
"Needs review": "Prüfung erforderlich",
"Accepted": "Angenommen",
"Rejected": "Abgelehnt",
"Handed off": "Übergeben",
"Archived": "Archiviert",
"Loading forms": "Formulare werden geladen",
"No matching Forms.": "Keine passenden Formulare.",
"Revision": "Revision",
"Loading Form": "Formular wird geladen",
"Change reason": "Änderungsgrund",
"Save draft": "Entwurf speichern",
"Submit": "Absenden",
"Submission receipt": "Übermittlungsbeleg",
"Case and workflow handoffs": "Fall- und Workflow-Übergaben",
"Create Case": "Fall erstellen",
"Start Workflow": "Workflow starten",
"Target binding (optional)": "Zielbindung (optional)",
"Exact target binding": "Exakte Zielbindung",
"Start": "Starten",
"No handoff has been requested.": "Es wurde keine Übergabe angefordert.",
"Open target": "Ziel öffnen",
"Retry": "Erneut versuchen",
"Reconcile": "Abgleichen",
"Compensate": "Kompensieren",
"Status history": "Statusverlauf",
"Revisions": "Revisionen",
"Select": "Auswählen",
"Compensate handoff": "Übergabe kompensieren",
"Confirm absent and compensate": "Fehlen bestätigen und kompensieren",
"Read-only Form": "Schreibgeschütztes Formular"
};
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-runtime.css"; import "./styles/forms-runtime.css";
@@ -12,10 +13,11 @@ const routeScopes = [
export const formsRuntimeModule: PlatformWebModule = { export const formsRuntimeModule: PlatformWebModule = {
id: "forms_runtime", id: "forms_runtime",
label: "Forms", label: "i18n:govoplan-forms-runtime.forms",
version: "0.1.14", version: "0.1.14",
dependencies: ["access", "forms"], dependencies: ["access", "forms"],
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"], optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
translations: generatedTranslations,
routes: [ routes: [
{ {
path: "/forms-runtime", path: "/forms-runtime",
@@ -35,7 +37,7 @@ export const formsRuntimeModule: PlatformWebModule = {
navItems: [ navItems: [
{ {
to: "/forms-runtime", to: "/forms-runtime",
label: "Forms", label: "i18n:govoplan-forms-runtime.forms",
iconName: "form", iconName: "form",
anyOf: routeScopes, anyOf: routeScopes,
order: 37, order: 37,
+5
View File
@@ -48,6 +48,11 @@
padding: 16px 18px 24px; padding: 16px 18px 24px;
} }
.form-instance-viewport > .action-blocker-hint {
max-width: 1280px;
margin: 0 auto 16px;
}
.forms-runtime-list { .forms-runtime-list {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);