feat: implement definition-aware forms runtime
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/forms-runtime-webui",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/forms-runtime.css": "./src/styles/forms-runtime.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type InstitutionalReference = {
|
||||
kind: string;
|
||||
owner_module: string;
|
||||
object_id: string;
|
||||
tenant_id: string;
|
||||
version?: string | null;
|
||||
valid_at?: string | null;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type EvidenceReference = {
|
||||
kind: string;
|
||||
owner_module: string;
|
||||
evidence_id: string;
|
||||
tenant_id: string;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
export type FormFieldDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
value_type: "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
|
||||
required: boolean;
|
||||
help_text?: string | null;
|
||||
options: string[];
|
||||
constraints: Record<string, unknown>;
|
||||
default_value?: unknown;
|
||||
};
|
||||
|
||||
export type FormDefinition = {
|
||||
reference: InstitutionalReference;
|
||||
key: string;
|
||||
temporal: { revision: string; recorded_at?: string | null };
|
||||
title: string;
|
||||
description?: string | null;
|
||||
fields: FormFieldDefinition[];
|
||||
publication_state: "draft" | "published" | "retired";
|
||||
allow_drafts: boolean;
|
||||
max_attachments: number;
|
||||
signature_requirement: "none" | "optional" | "required";
|
||||
policy_refs: string[];
|
||||
handoff_kinds: string[];
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
field?: string | null;
|
||||
severity: "warning" | "error";
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type FormInstance = {
|
||||
reference: InstitutionalReference;
|
||||
tenant_id: string;
|
||||
instance_id: string;
|
||||
revision: number;
|
||||
status: string;
|
||||
definition_ref: InstitutionalReference;
|
||||
values: Record<string, unknown>;
|
||||
validation_results: ValidationResult[];
|
||||
attachment_refs: EvidenceReference[];
|
||||
signature_refs: EvidenceReference[];
|
||||
handoff_refs: InstitutionalReference[];
|
||||
service_ref?: InstitutionalReference | null;
|
||||
receipt_id?: string | null;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
created_by: string;
|
||||
changed_by: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export type FormInstanceEvent = {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
instance_revision: number;
|
||||
status: string;
|
||||
occurred_at: string;
|
||||
actor_id: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function listFormInstances(
|
||||
settings: ApiSettings,
|
||||
options: { statuses?: string[]; definitionId?: string; offset?: number; limit?: number } = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<{ instances: FormInstance[]; total: number; offset: number; limit: number }> {
|
||||
return apiFetch(settings, apiPath("/api/v1/forms-runtime/instances", {
|
||||
status: options.statuses,
|
||||
definition_id: options.definitionId,
|
||||
offset: options.offset ?? 0,
|
||||
limit: options.limit ?? 100
|
||||
}), { signal });
|
||||
}
|
||||
|
||||
export function getFormInstance(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<FormInstance> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}`, { signal });
|
||||
}
|
||||
|
||||
export function getFormDefinition(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<FormDefinition> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/definition`,
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function getFormInstanceHistory(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ revisions: FormInstance[] }> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/history`, { signal });
|
||||
}
|
||||
|
||||
export function getFormInstanceEvents(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ events: FormInstanceEvent[] }> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/events`, { signal });
|
||||
}
|
||||
|
||||
export function saveFormDraft(
|
||||
settings: ApiSettings,
|
||||
instance: FormInstance,
|
||||
values: Record<string, unknown>,
|
||||
changeReason: string
|
||||
): Promise<FormInstance> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
values,
|
||||
attachment_refs: instance.attachment_refs,
|
||||
signature_refs: instance.signature_refs,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function submitFormInstance(
|
||||
settings: ApiSettings,
|
||||
instance: FormInstance,
|
||||
values: Record<string, unknown>
|
||||
): Promise<FormInstance> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
values,
|
||||
attachment_refs: instance.attachment_refs,
|
||||
signature_refs: instance.signature_refs,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
recorded_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
useGuardedNavigate,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
|
||||
|
||||
|
||||
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
|
||||
|
||||
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [items, setItems] = useState<FormInstance[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [status, setStatus] = useState("open");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback((signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
return listFormInstances(settings, {
|
||||
statuses: status === "open" ? OPEN_STATUSES : status ? [status] : undefined,
|
||||
limit: 200
|
||||
}, signal).
|
||||
then((result) => {
|
||||
setItems(result.instances);
|
||||
setTotal(result.total);
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
}, [settings, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
load(controller.signal).catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Forms could not be loaded.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<main className="forms-runtime-page">
|
||||
<div className="forms-runtime-shell">
|
||||
<div className="forms-runtime-toolbar">
|
||||
<Button onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="open">Open</option>
|
||||
<option value="">All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="needs_review">Needs review</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="handed_off">Handed off</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="forms-runtime-count">{total} forms</span>
|
||||
</div>
|
||||
<PageScrollViewport className="forms-runtime-list-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{loading && <LoadingIndicator label="Loading forms" />}
|
||||
{!loading && !error && items.length === 0 &&
|
||||
<div className="forms-runtime-empty">No matching Forms.</div>
|
||||
}
|
||||
{!loading && items.length > 0 &&
|
||||
<div className="forms-runtime-list" role="list">
|
||||
{items.map((item) =>
|
||||
<button
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="forms-runtime-row"
|
||||
key={item.instance_id}
|
||||
onClick={() => navigate(`/forms-runtime/${encodeURIComponent(item.instance_id)}`)}>
|
||||
<span className="forms-runtime-row-main">
|
||||
<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)} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, formsRuntimeModule } from "./module";
|
||||
export * from "./api/formsRuntime";
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/forms-runtime.css";
|
||||
|
||||
|
||||
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
|
||||
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
|
||||
const routeScopes = [
|
||||
"forms_runtime:submission:participate",
|
||||
"forms_runtime:workspace:read"
|
||||
];
|
||||
|
||||
export const formsRuntimeModule: PlatformWebModule = {
|
||||
id: "forms_runtime",
|
||||
label: "Forms",
|
||||
version: "0.1.14",
|
||||
dependencies: ["access", "forms"],
|
||||
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
|
||||
routes: [
|
||||
{
|
||||
path: "/forms-runtime",
|
||||
anyOf: routeScopes,
|
||||
order: 37,
|
||||
surfaceId: "forms_runtime.workspace",
|
||||
render: (context) => createElement(FormsRuntimePage, context)
|
||||
},
|
||||
{
|
||||
path: "/forms-runtime/:instanceId",
|
||||
anyOf: routeScopes,
|
||||
order: 38,
|
||||
surfaceId: "forms_runtime.instance",
|
||||
render: (context) => createElement(FormInstancePage, context)
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/forms-runtime",
|
||||
label: "Forms",
|
||||
iconName: "form",
|
||||
anyOf: routeScopes,
|
||||
order: 37,
|
||||
surfaceId: "forms_runtime.navigation"
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{ id: "forms_runtime.navigation", moduleId: "forms_runtime", kind: "navigation", label: "Forms navigation", order: 10 },
|
||||
{ id: "forms_runtime.workspace", moduleId: "forms_runtime", kind: "route", label: "Forms workspace", order: 20 },
|
||||
{ id: "forms_runtime.instance", moduleId: "forms_runtime", kind: "route", label: "Form instance", order: 30 }
|
||||
]
|
||||
};
|
||||
|
||||
export default formsRuntimeModule;
|
||||
@@ -0,0 +1,299 @@
|
||||
.forms-runtime-page,
|
||||
.forms-runtime-shell,
|
||||
.form-instance-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forms-runtime-shell,
|
||||
.form-instance-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.forms-runtime-toolbar,
|
||||
.form-instance-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 58px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.forms-runtime-toolbar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.forms-runtime-toolbar label > span,
|
||||
.forms-runtime-count {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.forms-runtime-count,
|
||||
.form-instance-toolbar .status-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.forms-runtime-list-viewport,
|
||||
.form-instance-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px 18px 24px;
|
||||
}
|
||||
|
||||
.forms-runtime-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.forms-runtime-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(170px, auto) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forms-runtime-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.forms-runtime-row:hover,
|
||||
.forms-runtime-row:focus-visible {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.forms-runtime-row-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.forms-runtime-row-main strong,
|
||||
.forms-runtime-row-main span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forms-runtime-row-main span,
|
||||
.forms-runtime-row > span:not(.status-badge) {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.forms-runtime-empty {
|
||||
padding: 36px 0;
|
||||
color: var(--text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-instance-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: 28px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-instance-main,
|
||||
.form-instance-aside {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-instance-main > header {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-instance-main h1 {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.form-instance-main > header p {
|
||||
max-width: 70ch;
|
||||
margin: 7px 0 0;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 18px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field:has(textarea),
|
||||
.form-field:has(select[multiple]) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-field > span:first-child {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-field > span b {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-field > small {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.form-field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-field select[multiple] {
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.form-field-toggle {
|
||||
justify-content: flex-end;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.form-field-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.form-field-messages [data-severity="error"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-field-messages [data-severity="warning"] {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.form-evidence-summary,
|
||||
.form-receipt {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.form-receipt {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-receipt code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-instance-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-change-reason {
|
||||
display: flex;
|
||||
min-width: min(360px, 100%);
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.form-change-reason span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-instance-aside section + section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.form-instance-aside h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.form-instance-aside ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-instance-aside li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 0 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.form-instance-aside li span,
|
||||
.form-instance-aside li time {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.forms-runtime-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.forms-runtime-row > span:not(.forms-runtime-row-main, .status-badge) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-instance-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-field:has(textarea),
|
||||
.form-field:has(select[multiple]) {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.form-instance-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user