Add governed form handoffs
This commit is contained in:
@@ -19,6 +19,10 @@ export type EvidenceReference = {
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
export type FormCondition =
|
||||
| { kind: "predicate"; field_key: string; operator: string; value?: unknown }
|
||||
| { kind: "all" | "any" | "not"; conditions: FormCondition[] };
|
||||
|
||||
export type FormFieldDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -28,6 +32,7 @@ export type FormFieldDefinition = {
|
||||
options: string[];
|
||||
constraints: Record<string, unknown>;
|
||||
default_value?: unknown;
|
||||
visibility_condition?: FormCondition | null;
|
||||
};
|
||||
|
||||
export type FormDefinition = {
|
||||
@@ -43,6 +48,30 @@ export type FormDefinition = {
|
||||
signature_requirement: "none" | "optional" | "required";
|
||||
policy_refs: string[];
|
||||
handoff_kinds: string[];
|
||||
pages?: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
visibility_condition?: FormCondition | null;
|
||||
sections: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
field_keys: string[];
|
||||
visibility_condition?: FormCondition | null;
|
||||
}>;
|
||||
}>;
|
||||
fallback_locale?: string | null;
|
||||
localizations?: Array<{
|
||||
locale: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
field_labels: Record<string, string>;
|
||||
field_help_texts: Record<string, string>;
|
||||
option_labels: Record<string, Record<string, string>>;
|
||||
page_titles: Record<string, string>;
|
||||
section_titles: Record<string, string>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
@@ -83,6 +112,24 @@ export type FormInstanceEvent = {
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FormHandoff = {
|
||||
effect_id: string;
|
||||
instance_id: string;
|
||||
instance_revision: number;
|
||||
binding_kind: "case" | "workflow";
|
||||
binding_reference: string;
|
||||
provider_capability: string;
|
||||
state: "requested" | "accepted" | "rejected" | "outcome_unknown" | "reconciled" | "compensated";
|
||||
attempt_count: number;
|
||||
requested_at: string;
|
||||
resolved_at?: string | null;
|
||||
target_ref?: InstitutionalReference | null;
|
||||
href?: string | null;
|
||||
evidence: EvidenceReference[];
|
||||
last_error?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function listFormInstances(
|
||||
settings: ApiSettings,
|
||||
options: { statuses?: string[]; definitionId?: string; offset?: number; limit?: number } = {},
|
||||
@@ -169,3 +216,57 @@ export function submitFormInstance(
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function listFormHandoffs(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ handoffs: FormHandoff[] }> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs`, { signal });
|
||||
}
|
||||
|
||||
export function startFormHandoff(
|
||||
settings: ApiSettings,
|
||||
instance: FormInstance,
|
||||
bindingKind: "case" | "workflow",
|
||||
bindingReference?: string
|
||||
): Promise<{ handoff: FormHandoff; instance?: FormInstance | null }> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/handoffs/native`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
binding_kind: bindingKind,
|
||||
binding_reference: bindingReference?.trim() || null,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
requested_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function actOnFormHandoff(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
effectId: string,
|
||||
action: "retry" | "reconcile"
|
||||
): Promise<{ handoff: FormHandoff; instance?: FormInstance | null }> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs/${encodeURIComponent(effectId)}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recorded_at: new Date().toISOString() })
|
||||
});
|
||||
}
|
||||
|
||||
export function compensateFormHandoff(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
effectId: string,
|
||||
changeReason: string
|
||||
): Promise<{ handoff: FormHandoff }> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs/${encodeURIComponent(effectId)}/compensate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
recorded_at: new Date().toISOString(),
|
||||
confirmed_absent: true,
|
||||
change_reason: changeReason
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ArrowLeft, Save, Send } from "lucide-react";
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DismissibleAlert,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
useGuardedNavigate,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
@@ -16,43 +18,55 @@ import {
|
||||
getFormInstance,
|
||||
getFormInstanceEvents,
|
||||
getFormInstanceHistory,
|
||||
listFormHandoffs,
|
||||
startFormHandoff,
|
||||
actOnFormHandoff,
|
||||
compensateFormHandoff,
|
||||
saveFormDraft,
|
||||
submitFormInstance,
|
||||
type FormDefinition,
|
||||
type FormFieldDefinition,
|
||||
type FormInstance,
|
||||
type FormInstanceEvent,
|
||||
type FormHandoff,
|
||||
type ValidationResult
|
||||
} from "../../api/formsRuntime";
|
||||
|
||||
|
||||
export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
export default function FormInstancePage({ settings, auth }: 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 [handoffs, setHandoffs] = useState<FormHandoff[]>([]);
|
||||
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 [handoffBusy, setHandoffBusy] = useState(false);
|
||||
const [handoffKind, setHandoffKind] = useState<"case" | "workflow">("case");
|
||||
const [handoffBinding, setHandoffBinding] = useState("");
|
||||
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextInstance = await getFormInstance(settings, instanceId, signal);
|
||||
const [nextDefinition, nextHistory, nextEvents] = await Promise.all([
|
||||
const [nextDefinition, nextHistory, nextEvents, nextHandoffs] = await Promise.all([
|
||||
getFormDefinition(settings, instanceId, signal),
|
||||
getFormInstanceHistory(settings, instanceId, signal),
|
||||
getFormInstanceEvents(settings, instanceId, signal)
|
||||
getFormInstanceEvents(settings, instanceId, signal),
|
||||
listFormHandoffs(settings, instanceId, signal)
|
||||
]);
|
||||
setInstance(nextInstance);
|
||||
setDefinition(nextDefinition);
|
||||
setHistory(nextHistory.revisions);
|
||||
setEvents(nextEvents.events);
|
||||
setHandoffs(nextHandoffs.handoffs);
|
||||
setValues(nextInstance.values);
|
||||
setChangeReason("");
|
||||
} finally {
|
||||
@@ -70,6 +84,13 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!definition) return;
|
||||
if (!definition.handoff_kinds.includes(handoffKind)) {
|
||||
setHandoffKind(definition.handoff_kinds.includes("case") ? "case" : "workflow");
|
||||
}
|
||||
}, [definition, handoffKind]);
|
||||
|
||||
const editable = instance?.status === "started" || instance?.status === "draft";
|
||||
const canSave = instance?.status === "draft" && definition?.allow_drafts;
|
||||
const changed = useMemo(
|
||||
@@ -84,6 +105,16 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
}
|
||||
return grouped;
|
||||
}, [instance]);
|
||||
const localized = useMemo(
|
||||
() => localizeDefinition(definition),
|
||||
[definition]
|
||||
);
|
||||
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");
|
||||
|
||||
async function save() {
|
||||
if (!instance || !canSave || !changed || !changeReason.trim()) return;
|
||||
@@ -113,6 +144,48 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startHandoff() {
|
||||
if (!instance || !mayHandoff) return;
|
||||
setHandoffBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await startFormHandoff(settings, instance, handoffKind, handoffBinding);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The handoff could not be started.");
|
||||
} finally {
|
||||
setHandoffBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handoffAction(item: FormHandoff, action: "retry" | "reconcile") {
|
||||
setHandoffBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await actOnFormHandoff(settings, item.instance_id, item.effect_id, action);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The handoff could not be updated.");
|
||||
} finally {
|
||||
setHandoffBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function compensate() {
|
||||
if (!compensating) return;
|
||||
setHandoffBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await compensateFormHandoff(settings, compensating.instance_id, compensating.effect_id, "Operator confirmed that no target effect exists.");
|
||||
setCompensating(null);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The handoff could not be compensated.");
|
||||
} finally {
|
||||
setHandoffBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="forms-runtime-page">
|
||||
<div className="form-instance-shell">
|
||||
@@ -121,7 +194,7 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
Forms
|
||||
</Button>
|
||||
{definition && <strong>{definition.title}</strong>}
|
||||
{definition && <strong>{localized.title}</strong>}
|
||||
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={humanize(instance.status)} />}
|
||||
</div>
|
||||
<PageScrollViewport className="form-instance-viewport">
|
||||
@@ -135,26 +208,37 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
<div className="form-instance-content">
|
||||
<section className="form-instance-main">
|
||||
<header>
|
||||
<h1>{definition.title}</h1>
|
||||
{definition.description && <p>{definition.description}</p>}
|
||||
<h1>{localized.title}</h1>
|
||||
{localized.description && <p>{localized.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>
|
||||
{groups.map((group) =>
|
||||
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
|
||||
{(groups.length > 1 || definition.pages?.length) && <header><h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>{group.description && <p>{group.description}</p>}</header>}
|
||||
<div className="form-fields">
|
||||
{group.fields.map((field) =>
|
||||
<FormField
|
||||
key={field.key}
|
||||
field={{
|
||||
...field,
|
||||
label: localized.fieldLabels[field.key] ?? field.label,
|
||||
help_text: localized.fieldHelpTexts[field.key] ?? field.help_text,
|
||||
options: field.options
|
||||
}}
|
||||
optionLabels={localized.optionLabels[field.key] ?? {}}
|
||||
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>
|
||||
</section>
|
||||
)}
|
||||
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
|
||||
<div className="form-evidence-summary">
|
||||
<span>{instance.attachment_refs.length} attachments</span>
|
||||
@@ -193,6 +277,39 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
<code>{instance.receipt_id}</code>
|
||||
</div>
|
||||
}
|
||||
{!editable && definition.handoff_kinds.length > 0 &&
|
||||
<section className="form-handoffs">
|
||||
<div className="form-handoff-heading">
|
||||
<h2>Case and workflow handoffs</h2>
|
||||
{mayHandoff &&
|
||||
<div className="form-handoff-create">
|
||||
<select value={handoffKind} disabled={handoffBusy} onChange={(event) => setHandoffKind(event.target.value as "case" | "workflow")}>
|
||||
{definition.handoff_kinds.includes("case") && <option value="case">Create Case</option>}
|
||||
{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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{handoffs.length === 0 && <p className="form-handoff-empty">No handoff has been requested.</p>}
|
||||
<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)} />
|
||||
{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>}
|
||||
{canAdmin && (item.state === "rejected" || item.state === "outcome_unknown") && <Button variant="danger" disabled={handoffBusy} onClick={() => setCompensating(item)}>Compensate</Button>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
<aside className="form-instance-aside">
|
||||
<section>
|
||||
@@ -224,6 +341,16 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={Boolean(compensating)}
|
||||
title="Compensate handoff"
|
||||
message="Confirm only after verifying that no Case or Workflow target exists. This records an administrative recovery decision; it does not delete a remote target."
|
||||
confirmLabel="Confirm absent and compensate"
|
||||
tone="danger"
|
||||
busy={handoffBusy}
|
||||
onCancel={() => setCompensating(null)}
|
||||
onConfirm={() => void compensate()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -233,12 +360,14 @@ function FormField({
|
||||
value,
|
||||
disabled,
|
||||
diagnostics,
|
||||
optionLabels,
|
||||
onChange
|
||||
}: {
|
||||
field: FormFieldDefinition;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
diagnostics: ValidationResult[];
|
||||
optionLabels: Record<string, string>;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const describedBy = diagnostics.length > 0 ? `form-field-${field.key}-messages` : undefined;
|
||||
@@ -260,7 +389,7 @@ function FormField({
|
||||
<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)}
|
||||
{renderInput(field, value, disabled, describedBy, onChange, optionLabels)}
|
||||
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
||||
</label>
|
||||
);
|
||||
@@ -271,7 +400,8 @@ function renderInput(
|
||||
value: unknown,
|
||||
disabled: boolean,
|
||||
describedBy: string | undefined,
|
||||
onChange: (value: unknown) => void
|
||||
onChange: (value: unknown) => void,
|
||||
optionLabels: Record<string, string>
|
||||
) {
|
||||
const common = { disabled, required: field.required, "aria-describedby": describedBy };
|
||||
if (field.value_type === "multiline_text" || field.value_type === "object" || field.value_type === "list") {
|
||||
@@ -288,7 +418,7 @@ function renderInput(
|
||||
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>)}
|
||||
{field.options.map((option) => <option key={option} value={option}>{optionLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -300,7 +430,7 @@ function renderInput(
|
||||
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>)}
|
||||
{field.options.map((option) => <option key={option} value={option}>{optionLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -375,6 +505,90 @@ function numericConstraint(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
|
||||
const fields = new Map(definition.fields.map((field) => [field.key, field]));
|
||||
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
|
||||
if (!definition.pages?.length) {
|
||||
return [{
|
||||
pageKey: "page",
|
||||
sectionKey: "section",
|
||||
sectionTitle: definition.title,
|
||||
description: definition.description,
|
||||
fields: definition.fields.filter(fieldVisible)
|
||||
}];
|
||||
}
|
||||
return definition.pages.flatMap((page) => {
|
||||
if (page.visibility_condition && !evaluateCondition(page.visibility_condition, values)) return [];
|
||||
return page.sections.flatMap((section) => {
|
||||
if (section.visibility_condition && !evaluateCondition(section.visibility_condition, values)) return [];
|
||||
return [{
|
||||
pageKey: page.key,
|
||||
sectionKey: section.key,
|
||||
sectionTitle: section.title,
|
||||
description: section.description,
|
||||
fields: section.field_keys.map((key) => fields.get(key)).filter((field): field is FormFieldDefinition => Boolean(field && fieldVisible(field)))
|
||||
}];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function evaluateCondition(condition: NonNullable<FormFieldDefinition["visibility_condition"]>, values: Record<string, unknown>): boolean {
|
||||
if (condition.kind === "all") return condition.conditions.every((item) => evaluateCondition(item, values));
|
||||
if (condition.kind === "any") return condition.conditions.some((item) => evaluateCondition(item, values));
|
||||
if (condition.kind === "not") return !evaluateCondition(condition.conditions[0], values);
|
||||
const actual = values[condition.field_key];
|
||||
const expected = condition.value;
|
||||
switch (condition.operator) {
|
||||
case "eq": return actual === expected;
|
||||
case "neq": return actual !== expected;
|
||||
case "is_empty": return emptyValue(actual);
|
||||
case "is_not_empty": return !emptyValue(actual);
|
||||
case "in": return Array.isArray(expected) && expected.includes(actual);
|
||||
case "not_in": return Array.isArray(expected) && !expected.includes(actual);
|
||||
case "contains": return typeof actual === "string" ? actual.includes(String(expected ?? "")) : Array.isArray(actual) && actual.includes(expected);
|
||||
case "lt": return comparable(actual, expected, (left, right) => left < right);
|
||||
case "lte": return comparable(actual, expected, (left, right) => left <= right);
|
||||
case "gt": return comparable(actual, expected, (left, right) => left > right);
|
||||
case "gte": return comparable(actual, expected, (left, right) => left >= right);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyValue(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
||||
}
|
||||
|
||||
function comparable(left: unknown, right: unknown, compare: (left: number | string, right: number | string) => boolean): boolean {
|
||||
if ((typeof left === "number" && typeof right === "number") || (typeof left === "string" && typeof right === "string")) {
|
||||
return compare(left, right);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function localizeDefinition(definition: FormDefinition | null) {
|
||||
const canonical = {
|
||||
title: definition?.title ?? "",
|
||||
description: definition?.description ?? null,
|
||||
fieldLabels: {} as Record<string, string>,
|
||||
fieldHelpTexts: {} as Record<string, string>,
|
||||
optionLabels: {} as Record<string, Record<string, string>>,
|
||||
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()}-`)))
|
||||
?? definition.localizations.find((item) => item.locale.toLowerCase() === definition.fallback_locale?.toLowerCase());
|
||||
if (!localization) return canonical;
|
||||
return {
|
||||
title: localization.title || canonical.title,
|
||||
description: localization.description || canonical.description,
|
||||
fieldLabels: localization.field_labels,
|
||||
fieldHelpTexts: localization.field_help_texts,
|
||||
optionLabels: localization.option_labels,
|
||||
sectionTitles: localization.section_titles
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -143,6 +143,24 @@
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-runtime-section > header {
|
||||
padding-top: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-runtime-section > header h2,
|
||||
.form-handoffs h2 {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.form-runtime-section > header p {
|
||||
margin: 5px 0 10px;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -216,6 +234,71 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-handoffs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-handoff-heading,
|
||||
.form-handoff-create,
|
||||
.form-handoff-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-handoff-heading {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-handoff-create input {
|
||||
width: min(260px, 34vw);
|
||||
}
|
||||
|
||||
.form-handoff-empty {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.form-handoff-list {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-handoff-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) auto minmax(180px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 54px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-handoff-row > span:first-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.form-handoff-row small,
|
||||
.form-handoff-error {
|
||||
overflow: hidden;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-handoff-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-instance-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
@@ -296,4 +379,18 @@
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-handoff-create,
|
||||
.form-handoff-row {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-handoff-create {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-handoff-create input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user