Migrate Forms interface patterns

This commit is contained in:
2026-08-03 13:08:43 +02:00
parent e2033640a1
commit e505536e6f
9 changed files with 429 additions and 31 deletions
@@ -2,11 +2,17 @@ import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField as Field,
IconButton,
ToggleSwitch,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
@@ -18,6 +24,7 @@ import {
type FormPageDefinition,
type FormValueType
} from "../../api/forms";
import { FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
const VALUE_TYPES: Array<{ value: FormValueType; label: string }> = [
@@ -52,17 +59,24 @@ export default function FormDefinitionDialog({
onClose: () => void;
onSaved: (definition: FormDefinition) => void;
}) {
const [draft, setDraft] = useState<FormDefinition>(() => initialDraft(tenantId, definition));
const { translateText } = usePlatformLanguage();
const [baseline, setBaseline] = useState<FormDefinition>(() => initialDraft(tenantId, definition));
const [draft, setDraft] = useState<FormDefinition>(baseline);
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmLifecycle, setConfirmLifecycle] = useState(false);
const { requestDiscard } = useUnsavedChanges();
useEffect(() => {
if (!open) return;
setDraft(initialDraft(tenantId, definition));
const next = initialDraft(tenantId, definition);
setDraft(next);
setBaseline(next);
setChangeReason("");
setBusy(false);
setError("");
setConfirmLifecycle(false);
}, [definition, open, tenantId]);
const valid = useMemo(() => Boolean(
@@ -72,9 +86,13 @@ export default function FormDefinitionDialog({
&& draft.fields.every((field) => field.key.trim() && field.label.trim())
&& changeReason.trim()
), [changeReason, draft]);
const dirty = useMemo(
() => Boolean(changeReason || JSON.stringify(draft) !== JSON.stringify(baseline)),
[baseline, changeReason, draft]
);
async function save() {
if (!valid) return;
async function save(): Promise<boolean> {
if (!valid) return false;
setBusy(true);
setError("");
const revision = crypto.randomUUID();
@@ -106,13 +124,41 @@ export default function FormDefinitionDialog({
definition?.reference.version
);
onSaved(saved);
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form definition could not be saved.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && dirty,
onSave: save,
onDiscard: () => {
setDraft(baseline);
setChangeReason("");
},
title: "i18n:govoplan-forms.unsaved_title",
message: "i18n:govoplan-forms.unsaved_message"
});
function requestClose() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
function requestSave() {
const previousState = definition?.publication_state ?? "draft";
if (draft.publication_state !== "draft" && draft.publication_state !== previousState) {
setConfirmLifecycle(true);
return;
}
void save();
}
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
setDraft((current) => ({
...current,
@@ -137,19 +183,20 @@ export default function FormDefinitionDialog({
<Dialog
open={open}
title={definition ? `Revise ${definition.title}` : "New Form definition"}
onClose={onClose}
onClose={requestClose}
closeDisabled={busy}
portal
className="form-definition-dialog"
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void save()} disabled={busy || !valid}>
<Button onClick={requestClose} disabled={busy} disabledReason={busy ? FORMS_I18N.busy : undefined}>Cancel</Button>
<Button variant="primary" onClick={requestSave} disabled={busy || !valid} disabledReason={busy ? FORMS_I18N.busy : !valid ? FORMS_I18N.incomplete : undefined}>
{busy ? "Saving" : "Save revision"}
</Button>
</>
}>
<div className="form-definition-editor">
<div className="form-definition-help"><DocumentationHelpLink reference={FORMS_FIELD_DOCUMENTATION} /></div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="form-definition-grid">
<Field label="Title">
@@ -161,14 +208,14 @@ export default function FormDefinitionDialog({
<Field label="Description" className="form-definition-wide">
<textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
</Field>
<Field label="Publication state">
<Field label="Publication state" help={!canPublish ? FORMS_I18N.adminReason : undefined} documentation={FORMS_FIELD_DOCUMENTATION}>
<select value={draft.publication_state} disabled={busy} onChange={(event) => setDraft({ ...draft, publication_state: event.target.value as FormDefinition["publication_state"] })}>
{definition?.publication_state !== "published" && <option value="draft">Draft</option>}
{canPublish && <option value="published">Published</option>}
{canPublish && <option value="retired">Retired</option>}
</select>
</Field>
<Field label="Signature">
<Field label="Signature" documentation={FORMS_FIELD_DOCUMENTATION}>
<select value={draft.signature_requirement} disabled={busy} onChange={(event) => setDraft({ ...draft, signature_requirement: event.target.value as FormDefinition["signature_requirement"] })}>
<option value="none">Not used</option>
<option value="optional">Optional</option>
@@ -181,7 +228,7 @@ export default function FormDefinitionDialog({
<div className="form-definition-toggle">
<ToggleSwitch label="Draft saving" checked={draft.allow_drafts} disabled={busy} onChange={(allow_drafts) => setDraft({ ...draft, allow_drafts })} />
</div>
<Field label="Policy references" className="form-definition-wide">
<Field label="Policy references" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
<input value={draft.policy_refs.join(", ")} disabled={busy} placeholder="policy:permit-intake" onChange={(event) => setDraft({ ...draft, policy_refs: splitValues(event.target.value) })} />
</Field>
<div className="form-definition-handoffs form-definition-wide">
@@ -201,7 +248,7 @@ export default function FormDefinitionDialog({
/>
)}
</div>
<Field label="Accessibility instructions" className="form-definition-wide">
<Field label="Accessibility instructions" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
<textarea
rows={2}
value={String(draft.accessibility?.instructions ?? "")}
@@ -253,6 +300,7 @@ export default function FormDefinitionDialog({
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy || draft.fields.length === 1}
disabledReason={busy ? FORMS_I18N.busy : draft.fields.length === 1 ? FORMS_I18N.oneField : undefined}
onClick={() => setDraft(removeField(draft, index))}
/>
</div>
@@ -261,10 +309,23 @@ export default function FormDefinitionDialog({
<PageEditor draft={draft} disabled={busy} onChange={setDraft} />
<LocalizationEditor draft={draft} disabled={busy} onChange={setDraft} />
<DefinitionPreview definition={draft} previous={definition} />
<Field label="Change reason">
<Field label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}>
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
</Field>
</div>
<ConfirmDialog
open={confirmLifecycle}
title="i18n:govoplan-forms.lifecycle_title"
message={i18nMessage("i18n:govoplan-forms.lifecycle_message", { state: translateText(humanize(draft.publication_state)) })}
confirmLabel="Save revision"
tone={draft.publication_state === "retired" ? "danger" : "default"}
busy={busy}
onCancel={() => setConfirmLifecycle(false)}
onConfirm={() => {
setConfirmLifecycle(false);
void save();
}}
/>
</Dialog>
);
}