feat: implement definition-aware forms runtime
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { ArrowLeft, Save, Send } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
useGuardedNavigate,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
getFormDefinition,
|
||||
getFormInstance,
|
||||
getFormInstanceEvents,
|
||||
getFormInstanceHistory,
|
||||
saveFormDraft,
|
||||
submitFormInstance,
|
||||
type FormDefinition,
|
||||
type FormFieldDefinition,
|
||||
type FormInstance,
|
||||
type FormInstanceEvent,
|
||||
type ValidationResult
|
||||
} from "../../api/formsRuntime";
|
||||
|
||||
|
||||
export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
const { instanceId = "" } = useParams();
|
||||
const navigate = useGuardedNavigate();
|
||||
const [instance, setInstance] = useState<FormInstance | null>(null);
|
||||
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||
const [history, setHistory] = useState<FormInstance[]>([]);
|
||||
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextInstance = await getFormInstance(settings, instanceId, signal);
|
||||
const [nextDefinition, nextHistory, nextEvents] = await Promise.all([
|
||||
getFormDefinition(settings, instanceId, signal),
|
||||
getFormInstanceHistory(settings, instanceId, signal),
|
||||
getFormInstanceEvents(settings, instanceId, signal)
|
||||
]);
|
||||
setInstance(nextInstance);
|
||||
setDefinition(nextDefinition);
|
||||
setHistory(nextHistory.revisions);
|
||||
setEvents(nextEvents.events);
|
||||
setValues(nextInstance.values);
|
||||
setChangeReason("");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
load(controller.signal).catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "The Form could not be loaded.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const editable = instance?.status === "started" || instance?.status === "draft";
|
||||
const canSave = instance?.status === "draft" && definition?.allow_drafts;
|
||||
const changed = useMemo(
|
||||
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
|
||||
[instance, values]
|
||||
);
|
||||
const diagnostics = useMemo(() => {
|
||||
const grouped = new Map<string, ValidationResult[]>();
|
||||
for (const item of instance?.validation_results ?? []) {
|
||||
const key = item.field ?? "";
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), item]);
|
||||
}
|
||||
return grouped;
|
||||
}, [instance]);
|
||||
|
||||
async function save() {
|
||||
if (!instance || !canSave || !changed || !changeReason.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await saveFormDraft(settings, instance, values, changeReason.trim());
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!instance || !editable) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitFormInstance(settings, instance, values);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="forms-runtime-page">
|
||||
<div className="form-instance-shell">
|
||||
<div className="form-instance-toolbar">
|
||||
<Button onClick={() => navigate("/forms-runtime")}>
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
Forms
|
||||
</Button>
|
||||
{definition && <strong>{definition.title}</strong>}
|
||||
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={humanize(instance.status)} />}
|
||||
</div>
|
||||
<PageScrollViewport className="form-instance-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{loading && <LoadingIndicator label="Loading Form" />}
|
||||
{!loading && instance && definition &&
|
||||
<div className="form-instance-content">
|
||||
<section className="form-instance-main">
|
||||
<header>
|
||||
<h1>{definition.title}</h1>
|
||||
{definition.description && <p>{definition.description}</p>}
|
||||
</header>
|
||||
<div className="form-fields">
|
||||
{definition.fields.map((field) =>
|
||||
<FormField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
disabled={!editable || saving}
|
||||
diagnostics={diagnostics.get(field.key) ?? []}
|
||||
onChange={(value) => setValues((current) => {
|
||||
const next = { ...current };
|
||||
if (value === undefined || value === "") delete next[field.key];
|
||||
else next[field.key] = value;
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
|
||||
<div className="form-evidence-summary">
|
||||
<span>{instance.attachment_refs.length} attachments</span>
|
||||
<span>{instance.signature_refs.length} signatures</span>
|
||||
</div>
|
||||
}
|
||||
{editable &&
|
||||
<div className="form-instance-actions">
|
||||
{canSave &&
|
||||
<label className="form-change-reason">
|
||||
<span>Change reason</span>
|
||||
<input
|
||||
value={changeReason}
|
||||
onChange={(event) => setChangeReason(event.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
{canSave &&
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
disabled={!changed || !changeReason.trim() || saving}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
Save draft
|
||||
</Button>
|
||||
}
|
||||
<Button variant="primary" onClick={() => void submit()} disabled={saving}>
|
||||
<Send size={16} aria-hidden="true" />
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
{!editable && instance.receipt_id &&
|
||||
<div className="form-receipt">
|
||||
<span>Submission receipt</span>
|
||||
<code>{instance.receipt_id}</code>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
<aside className="form-instance-aside">
|
||||
<section>
|
||||
<h2>Status history</h2>
|
||||
<ol>
|
||||
{events.map((event) =>
|
||||
<li key={event.event_id}>
|
||||
<strong>{humanize(event.status)}</strong>
|
||||
<span>{humanize(event.event_type)}</span>
|
||||
<time>{formatDateTime(event.occurred_at)}</time>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Revisions</h2>
|
||||
<ol>
|
||||
{history.map((item) =>
|
||||
<li key={item.revision}>
|
||||
<strong>Revision {item.revision}</strong>
|
||||
<span>{item.change_reason}</span>
|
||||
<time>{formatDateTime(item.recorded_at)}</time>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
field,
|
||||
value,
|
||||
disabled,
|
||||
diagnostics,
|
||||
onChange
|
||||
}: {
|
||||
field: FormFieldDefinition;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
diagnostics: ValidationResult[];
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const describedBy = diagnostics.length > 0 ? `form-field-${field.key}-messages` : undefined;
|
||||
if (field.value_type === "boolean") {
|
||||
return (
|
||||
<div className="form-field form-field-toggle">
|
||||
<ToggleSwitch
|
||||
label={`${field.label}${field.required ? " (required)" : ""}`}
|
||||
checked={Boolean(value)}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
help={field.help_text ?? undefined}
|
||||
/>
|
||||
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label className="form-field">
|
||||
<span>{field.label}{field.required && <b aria-hidden="true"> *</b>}</span>
|
||||
{field.help_text && <small>{field.help_text}</small>}
|
||||
{renderInput(field, value, disabled, describedBy, onChange)}
|
||||
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInput(
|
||||
field: FormFieldDefinition,
|
||||
value: unknown,
|
||||
disabled: boolean,
|
||||
describedBy: string | undefined,
|
||||
onChange: (value: unknown) => void
|
||||
) {
|
||||
const common = { disabled, required: field.required, "aria-describedby": describedBy };
|
||||
if (field.value_type === "multiline_text" || field.value_type === "object" || field.value_type === "list") {
|
||||
return (
|
||||
<textarea
|
||||
{...common}
|
||||
rows={field.value_type === "multiline_text" ? 5 : 7}
|
||||
value={structuredValue(value)}
|
||||
onChange={(event) => onChange(field.value_type === "multiline_text" ? event.target.value : parseStructured(event.target.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.value_type === "choice") {
|
||||
return (
|
||||
<select {...common} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">Select</option>
|
||||
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (field.value_type === "multi_choice") {
|
||||
const selected = Array.isArray(value) ? value.map(String) : [];
|
||||
return (
|
||||
<select
|
||||
{...common}
|
||||
multiple
|
||||
value={selected}
|
||||
onChange={(event) => onChange(Array.from(event.target.selectedOptions, (option) => option.value))}>
|
||||
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
const type = field.value_type === "integer" || field.value_type === "number"
|
||||
? "number"
|
||||
: field.value_type === "email"
|
||||
? "email"
|
||||
: field.value_type === "date"
|
||||
? "date"
|
||||
: field.value_type === "datetime"
|
||||
? "datetime-local"
|
||||
: "text";
|
||||
return (
|
||||
<input
|
||||
{...common}
|
||||
type={type}
|
||||
step={field.value_type === "integer" ? 1 : field.value_type === "number" ? "any" : undefined}
|
||||
min={numericConstraint(field.constraints.minimum)}
|
||||
max={numericConstraint(field.constraints.maximum)}
|
||||
minLength={numericConstraint(field.constraints.min_length)}
|
||||
maxLength={numericConstraint(field.constraints.max_length)}
|
||||
value={inputValue(field, value)}
|
||||
onChange={(event) => onChange(inputChangeValue(field, event.target.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldMessages({ id, diagnostics }: { id?: string; diagnostics: ValidationResult[] }) {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return (
|
||||
<span id={id} className="form-field-messages" aria-live="polite">
|
||||
{diagnostics.map((item) => <small key={item.code} data-severity={item.severity}>{item.message}</small>)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function inputValue(field: FormFieldDefinition, value: unknown): string | number {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (field.value_type === "datetime" && typeof value === "string") {
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isNaN(parsed.valueOf())) {
|
||||
const local = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
}
|
||||
return typeof value === "number" || typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
function inputChangeValue(field: FormFieldDefinition, value: string): unknown {
|
||||
if (!value) return undefined;
|
||||
if (field.value_type === "integer") return Number.parseInt(value, 10);
|
||||
if (field.value_type === "number") return Number(value);
|
||||
if (field.value_type === "datetime") return new Date(value).toISOString();
|
||||
return value;
|
||||
}
|
||||
|
||||
function structuredValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function parseStructured(value: string): unknown {
|
||||
if (!value.trim()) return undefined;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function numericConstraint(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
Reference in New Issue
Block a user