599 lines
25 KiB
TypeScript
599 lines
25 KiB
TypeScript
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";
|
|
import {
|
|
getFormDefinition,
|
|
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, 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, nextHandoffs] = await Promise.all([
|
|
getFormDefinition(settings, instanceId, signal),
|
|
getFormInstanceHistory(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 {
|
|
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]);
|
|
|
|
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(
|
|
() => 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]);
|
|
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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
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">
|
|
<div className="form-instance-toolbar">
|
|
<Button onClick={() => navigate("/forms-runtime")}>
|
|
<ArrowLeft size={16} aria-hidden="true" />
|
|
Forms
|
|
</Button>
|
|
{definition && <strong>{localized.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>{localized.title}</h1>
|
|
{localized.description && <p>{localized.description}</p>}
|
|
</header>
|
|
{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>
|
|
<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>
|
|
}
|
|
{!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>
|
|
<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>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function FormField({
|
|
field,
|
|
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;
|
|
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, optionLabels)}
|
|
<FieldMessages id={describedBy} diagnostics={diagnostics} />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function renderInput(
|
|
field: FormFieldDefinition,
|
|
value: unknown,
|
|
disabled: boolean,
|
|
describedBy: string | undefined,
|
|
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") {
|
|
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}>{optionLabels[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}>{optionLabels[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 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));
|
|
}
|
|
|
|
function humanize(value: string): string {
|
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
}
|