Migrate Forms interface patterns
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user