Files
govoplan-forms-runtime/webui/src/features/forms/PublicFormPage.tsx
T

408 lines
15 KiB
TypeScript

import { Save, Send, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { ActionToolbar,
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">
<ActionToolbar className="form-instance-toolbar">
<strong>{localized.title || "Form"}</strong>
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={stateLabel(instance.status)} />}
</ActionToolbar>
<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>
}
{!editable && instance.status_access &&
<section className="form-status-access-receipt">
<div>
<strong>Track this application</strong>
<span>{statusAccessMessage(instance.status_access.mode)}</span>
</div>
<code>{instance.status_access.tracking_id}</code>
<a className="btn" href={instance.status_access.href}>Open status page</a>
</section>
}
</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}`;
}
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
if (mode === "authenticated") return "Sign in with the linked applicant account to view status.";
if (mode === "email_link") return "Use this tracking ID and the linked email address to request a short-lived status link.";
return "This permanent bearer link does not require sign-in. Store and share it carefully.";
}