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
+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 { useParams } from "react-router";
import {
ActionBlockerHint,
Button,
ConfirmDialog,
DocumentationHelpLink,
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
ToggleSwitch,
hasScope,
i18nMessage,
useGuardedNavigate,
usePlatformLanguage,
useUnsavedDraftGuard,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
@@ -31,11 +36,17 @@ import {
type FormHandoff,
type ValidationResult
} from "../../api/formsRuntime";
import {
FORMS_RUNTIME_DOCUMENTATION,
FORMS_RUNTIME_FIELD_DOCUMENTATION,
FORMS_RUNTIME_I18N
} from "./interfacePatterns";
export default function FormInstancePage({ settings, auth }: PlatformRouteContext) {
const { instanceId = "" } = useParams();
const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const [instance, setInstance] = useState<FormInstance | null>(null);
const [definition, setDefinition] = useState<FormDefinition | null>(null);
const [history, setHistory] = useState<FormInstance[]>([]);
@@ -50,6 +61,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const [handoffKind, setHandoffKind] = useState<"case" | "workflow">("case");
const [handoffBinding, setHandoffBinding] = useState("");
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
@@ -91,8 +104,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
}, [definition, handoffKind]);
const editable = instance?.status === "started" || instance?.status === "draft";
const canSave = instance?.status === "draft" && definition?.allow_drafts;
const editableLifecycle = instance?.status === "started" || instance?.status === "draft";
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(
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
[instance, values]
@@ -106,30 +124,43 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
return grouped;
}, [instance]);
const localized = useMemo(
() => localizeDefinition(definition),
[definition]
() => localizeDefinition(definition, language),
[definition, language]
);
const groups = useMemo(
() => definition ? visibleGroups(definition, values) : [],
[definition, values]
);
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() {
if (!instance || !canSave || !changed || !changeReason.trim()) return;
async function save(): Promise<boolean> {
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
setSaving(true);
setError("");
try {
await saveFormDraft(settings, instance, values, changeReason.trim());
await load();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
return false;
} finally {
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() {
if (!instance || !editable) return;
setSaving(true);
@@ -195,7 +226,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
Forms
</Button>
{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>
<PageScrollViewport className="form-instance-viewport">
{error &&
@@ -204,6 +236,23 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
</DismissibleAlert>
}
{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 &&
<div className="form-instance-content">
<section className="form-instance-main">
@@ -226,7 +275,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}}
optionLabels={localized.optionLabels[field.key] ?? {}}
value={values[field.key]}
disabled={!editable || saving}
disabled={!editable || saving}
diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => {
const next = { ...current };
@@ -247,6 +296,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
{editable &&
<div className="form-instance-actions">
<DocumentationHelpLink reference={FORMS_RUNTIME_FIELD_DOCUMENTATION} />
{canSave &&
<label className="form-change-reason">
<span>Change reason</span>
@@ -260,12 +310,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
{canSave &&
<Button
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 draft
</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" />
Submit
</Button>
@@ -288,7 +339,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
{definition.handoff_kinds.includes("workflow") && <option value="workflow">Start Workflow</option>}
</select>
<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>
@@ -296,13 +347,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<div className="form-handoff-list">
{handoffs.map((item) =>
<div className="form-handoff-row" key={item.effect_id}>
<span><strong>{humanize(item.binding_kind)}</strong><small>{item.binding_reference}</small></span>
<StatusBadge status={item.state === "accepted" || item.state === "reconciled" ? "active" : "inactive"} label={humanize(item.state)} />
<span><strong>{domainLabel(item.binding_kind)}</strong><small>{item.binding_reference}</small></span>
<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>}
<span className="form-handoff-actions">
{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 === "outcome_unknown" && <Button disabled={handoffBusy} onClick={() => void handoffAction(item, "reconcile")}><RefreshCw size={15} aria-hidden="true" />Reconcile</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 || !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>}
</span>
</div>
@@ -317,9 +368,9 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<ol>
{events.map((event) =>
<li key={event.event_id}>
<strong>{humanize(event.status)}</strong>
<strong>{stateLabel(event.status)}</strong>
<span>{humanize(event.event_type)}</span>
<time>{formatDateTime(event.occurred_at)}</time>
<time>{formatDateTime(event.occurred_at, language)}</time>
</li>
)}
</ol>
@@ -331,7 +382,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<li key={item.revision}>
<strong>Revision {item.revision}</strong>
<span>{item.change_reason}</span>
<time>{formatDateTime(item.recorded_at)}</time>
<time>{formatDateTime(item.recorded_at, language)}</time>
</li>
)}
</ol>
@@ -341,6 +392,30 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
</PageScrollViewport>
</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
open={Boolean(compensating)}
title="Compensate handoff"
@@ -565,7 +640,7 @@ function comparable(left: unknown, right: unknown, compare: (left: number | stri
return false;
}
function localizeDefinition(definition: FormDefinition | null) {
function localizeDefinition(definition: FormDefinition | null, language: string) {
const canonical = {
title: definition?.title ?? "",
description: definition?.description ?? null,
@@ -575,8 +650,8 @@ function localizeDefinition(definition: FormDefinition | null) {
sectionTitles: {} as Record<string, string>
};
if (!definition?.localizations?.length) return canonical;
const browserLocales = navigator.languages.map((value) => value.toLowerCase());
const localization = definition.localizations.find((item) => browserLocales.some((locale) => locale === item.locale.toLowerCase() || locale.startsWith(`${item.locale.toLowerCase()}-`)))
const requestedLocale = language.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());
if (!localization) return canonical;
return {
@@ -589,10 +664,18 @@ function localizeDefinition(definition: FormDefinition | null) {
};
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
function formatDateTime(value: string, locale?: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function humanize(value: string): string {
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 {
Button,
DocumentationHelpLink,
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
i18nMessage,
useGuardedNavigate,
usePlatformLanguage,
type PlatformRouteContext
} from "@govoplan/core-webui";
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"];
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const [items, setItems] = useState<FormInstance[]>([]);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState("open");
@@ -50,7 +55,7 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
<main className="forms-runtime-page">
<div className="forms-runtime-shell">
<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" />
Refresh
</Button>
@@ -68,7 +73,8 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
<option value="archived">Archived</option>
</select>
</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>
<PageScrollViewport className="forms-runtime-list-viewport">
{error &&
@@ -93,8 +99,8 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
<strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong>
<span>Revision {item.definition_ref.version ?? "-"}</span>
</span>
<span>{formatDateTime(item.recorded_at)}</span>
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={humanize(item.status)} />
<span>{formatDateTime(item.recorded_at, language)}</span>
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={stateLabel(item.status)} />
</button>
)}
</div>
@@ -109,10 +115,14 @@ function isOpen(status: string): boolean {
return OPEN_STATUSES.includes(status);
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
function formatDateTime(value: string, locale?: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function humanize(value: string): string {
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;