Add governed public form intake
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import { Save, Send, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DismissibleAlert,
|
||||
FileDropZone,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
usePlatformLanguage,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createPublicFormEvidenceGrant,
|
||||
getPublicFormIntake,
|
||||
savePublicFormDraft,
|
||||
startAnonymousFormIntake,
|
||||
startInvitationFormIntake,
|
||||
submitPublicFormIntake,
|
||||
uploadFormEvidence,
|
||||
type EvidenceReference,
|
||||
type FormDefinition,
|
||||
type FormInstance,
|
||||
type ValidationResult
|
||||
} from "../../api/formsRuntime";
|
||||
import {
|
||||
FormField,
|
||||
localizeDefinition,
|
||||
visibleGroups
|
||||
} from "./FormInstancePage";
|
||||
|
||||
|
||||
type AnonymousStartAttempt = {
|
||||
idempotencyKey: string;
|
||||
recordedAt: string;
|
||||
};
|
||||
|
||||
export default function PublicFormPage({ settings }: PlatformRouteContext) {
|
||||
const { publicId = "", token: invitationToken = "" } = useParams();
|
||||
const { language } = usePlatformLanguage();
|
||||
const [token, setToken] = useState(invitationToken);
|
||||
const [instance, setInstance] = useState<FormInstance | null>(null);
|
||||
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
|
||||
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
async function initialize() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
let activeToken = invitationToken;
|
||||
if (activeToken) {
|
||||
await startInvitationFormIntake(settings, activeToken);
|
||||
} else if (publicId) {
|
||||
activeToken = readSessionToken(publicId);
|
||||
if (!activeToken) {
|
||||
const attempt = anonymousStartAttempt(publicId);
|
||||
const started = await startAnonymousFormIntake(
|
||||
settings,
|
||||
publicId,
|
||||
attempt.idempotencyKey,
|
||||
attempt.recordedAt
|
||||
);
|
||||
activeToken = started.token ?? "";
|
||||
if (!activeToken) {
|
||||
throw new Error("This Form was already opened in another browser session. Use the original session or request a new link.");
|
||||
}
|
||||
rememberSessionToken(publicId, activeToken);
|
||||
clearAnonymousStartAttempt(publicId);
|
||||
}
|
||||
}
|
||||
if (!activeToken) throw new Error("This Form link is incomplete.");
|
||||
const loaded = await getPublicFormIntake(settings, activeToken, controller.signal);
|
||||
if (!active) return;
|
||||
setToken(activeToken);
|
||||
applyLoaded(loaded.instance, loaded.definition);
|
||||
} catch (reason) {
|
||||
if (active && (reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "The public Form could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
}
|
||||
function applyLoaded(nextInstance: FormInstance, nextDefinition: FormDefinition) {
|
||||
setInstance(nextInstance);
|
||||
setDefinition(nextDefinition);
|
||||
setValues(nextInstance.values);
|
||||
setAttachmentRefs(nextInstance.attachment_refs);
|
||||
}
|
||||
void initialize();
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [invitationToken, publicId, settings]);
|
||||
|
||||
const editable = Boolean(instance && ["started", "draft"].includes(instance.status));
|
||||
const changed = Boolean(
|
||||
instance && (
|
||||
JSON.stringify(values) !== JSON.stringify(instance.values)
|
||||
|| JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs)
|
||||
)
|
||||
);
|
||||
const localized = useMemo(
|
||||
() => localizeDefinition(definition, language),
|
||||
[definition, language]
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => definition ? visibleGroups(definition, values) : [],
|
||||
[definition, 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 signatureBlocked = Boolean(
|
||||
definition?.signature_requirement === "required"
|
||||
&& (instance?.signature_refs.length ?? 0) === 0
|
||||
);
|
||||
const attachmentLimitReached = Boolean(
|
||||
definition && attachmentRefs.length >= definition.max_attachments
|
||||
);
|
||||
|
||||
async function reload() {
|
||||
if (!token) return;
|
||||
const loaded = await getPublicFormIntake(settings, token);
|
||||
setInstance(loaded.instance);
|
||||
setDefinition(loaded.definition);
|
||||
setValues(loaded.instance.values);
|
||||
setAttachmentRefs(loaded.instance.attachment_refs);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!instance || !token || !definition?.allow_drafts || !changed) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await savePublicFormDraft(
|
||||
settings,
|
||||
token,
|
||||
instance,
|
||||
values,
|
||||
attachmentRefs,
|
||||
"Public participant saved the draft."
|
||||
);
|
||||
await reload();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!instance || !token || !editable || signatureBlocked) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitPublicFormIntake(settings, token, instance, values, attachmentRefs);
|
||||
await reload();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(files: File[]) {
|
||||
if (!instance || !token || !definition) return;
|
||||
const available = Math.max(0, definition.max_attachments - attachmentRefs.length);
|
||||
const selected = files.slice(0, available);
|
||||
if (selected.length === 0) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const uploaded: EvidenceReference[] = [];
|
||||
const names: Record<string, string> = {};
|
||||
for (const file of selected) {
|
||||
const grant = await createPublicFormEvidenceGrant(
|
||||
settings,
|
||||
token,
|
||||
instance,
|
||||
crypto.randomUUID(),
|
||||
[...attachmentRefs, ...uploaded]
|
||||
);
|
||||
const result = await uploadFormEvidence(settings, grant, file);
|
||||
uploaded.push(result.evidence);
|
||||
names[result.evidence.evidence_id] = file.name;
|
||||
}
|
||||
setAttachmentRefs((current) => [...current, ...uploaded]);
|
||||
setAttachmentNames((current) => ({ ...current, ...names }));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="forms-runtime-page forms-public-page">
|
||||
<div className="form-instance-shell">
|
||||
<div className="form-instance-toolbar">
|
||||
<strong>{localized.title || "Form"}</strong>
|
||||
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={stateLabel(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-public-content">
|
||||
<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
|
||||
}}
|
||||
optionLabels={localized.optionLabels[field.key] ?? {}}
|
||||
value={values[field.key]}
|
||||
disabled={!editable || busy}
|
||||
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>
|
||||
)}
|
||||
{definition.max_attachments > 0 && editable &&
|
||||
<section className="form-public-attachments">
|
||||
<h2>Attachments</h2>
|
||||
<FileDropZone
|
||||
multiple
|
||||
busy={busy}
|
||||
disabled={attachmentLimitReached}
|
||||
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
|
||||
onFiles={upload}
|
||||
/>
|
||||
<div className="form-public-attachment-list">
|
||||
{attachmentRefs.map((reference) =>
|
||||
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
|
||||
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
aria-label="Remove attachment"
|
||||
disabled={busy}
|
||||
onClick={() => setAttachmentRefs((current) => current.filter((item) => item !== reference))}>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
{signatureBlocked &&
|
||||
<ActionBlockerHint
|
||||
tone="warning"
|
||||
reason={{
|
||||
summary: "This Form requires an authenticated or external signature.",
|
||||
details: "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
|
||||
requiredAction: "Use an authenticated service entry or a configured signature provider.",
|
||||
actor: "Service owner",
|
||||
target: "Form intake and signature policy"
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{editable &&
|
||||
<div className="form-instance-actions">
|
||||
{definition.allow_drafts &&
|
||||
<Button disabled={busy || !changed} onClick={() => void save()}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
Save draft
|
||||
</Button>
|
||||
}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || signatureBlocked}
|
||||
disabledReason={signatureBlocked ? "The required signature profile is unavailable for this public link." : undefined}
|
||||
onClick={() => setConfirmingSubmit(true)}>
|
||||
<Send size={16} aria-hidden="true" />
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
{!editable && instance.receipt_id &&
|
||||
<div className="form-receipt">
|
||||
<strong>Submission received</strong>
|
||||
<span>Receipt</span>
|
||||
<code>{instance.receipt_id}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmingSubmit}
|
||||
title="Submit Form"
|
||||
message="Submit this Form? The values and managed attachment references become an immutable submission revision."
|
||||
confirmLabel="Submit"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingSubmit(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmingSubmit(false);
|
||||
void submit();
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function anonymousStartAttempt(publicId: string): AnonymousStartAttempt {
|
||||
const key = `govoplan.forms-runtime.start.${publicId}`;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(key);
|
||||
if (stored) return JSON.parse(stored) as AnonymousStartAttempt;
|
||||
const attempt = {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
recordedAt: new Date().toISOString()
|
||||
};
|
||||
sessionStorage.setItem(key, JSON.stringify(attempt));
|
||||
return attempt;
|
||||
} catch {
|
||||
return {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
recordedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clearAnonymousStartAttempt(publicId: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(`govoplan.forms-runtime.start.${publicId}`);
|
||||
} catch {
|
||||
// Private browsing may deny session storage; the active token still works.
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionToken(publicId: string): string {
|
||||
try {
|
||||
return sessionStorage.getItem(`govoplan.forms-runtime.token.${publicId}`) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function rememberSessionToken(publicId: string, token: string) {
|
||||
try {
|
||||
sessionStorage.setItem(`govoplan.forms-runtime.token.${publicId}`, token);
|
||||
} catch {
|
||||
// Keep the token in component memory when session storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function stateLabel(value: string): string {
|
||||
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||
}
|
||||
Reference in New Issue
Block a user