Migrate Approvals interface patterns

This commit is contained in:
2026-08-03 12:55:27 +02:00
parent 62d0f300cb
commit 24e95591e0
9 changed files with 333 additions and 22 deletions
@@ -1,45 +1,66 @@
import { Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Button, Dialog, DismissibleAlert, FormField, IconButton, ToggleSwitch, type ApiSettings } from "@govoplan/core-webui";
import { Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui";
import { createApproval, type ApprovalDraft, type ApprovalRequest, type ApprovalStep } from "../../api/approvals";
import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) {
const [draft, setDraft] = useState<ApprovalDraft>(() => initialDraft());
const [baseline] = useState<ApprovalDraft>(() => initialDraft());
const [draft, setDraft] = useState<ApprovalDraft>(baseline);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { requestDiscard } = useUnsavedChanges();
const valid = useMemo(() => Boolean(draft.title.trim() && draft.subject_module.trim() && draft.subject_type.trim() && draft.subject_id.trim() && /^[0-9a-f]{64}$/.test(draft.subject_digest) && draft.steps.every((step) => step.key.trim() && step.label.trim() && step.selectors.every((selector) => selector.value.trim()))), [draft]);
const dirty = draftKey(draft) !== draftKey(baseline);
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
onSaved(await createApproval(settings, draft));
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Approval request could not be created.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => setDraft(baseline),
title: "i18n:govoplan-approvals.unsaved_title",
message: "i18n:govoplan-approvals.unsaved_message"
});
function requestClose() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
function patchStep(index: number, patch: Partial<ApprovalStep>) {
setDraft((current) => ({ ...current, steps: current.steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
}
return <Dialog open title="New Approval request" onClose={onClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant="primary" disabled={busy || !valid} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
return <Dialog open title="New Approval request" onClose={requestClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
<div className="approval-editor">
<div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="approval-editor-grid">
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Subject module"><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
<FormField label="Subject type"><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
<FormField label="Subject ID"><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
<FormField label="Subject version"><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
<FormField label="Subject SHA-256"><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></FormField>
<FormField label="Title" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Subject module" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
<FormField label="Subject type" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
<FormField label="Subject ID" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
<FormField label="Subject version" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
<FormField label="Subject SHA-256" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></FormField>
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
</div>
<div className="approval-editor-heading"><h3>Steps</h3><Button disabled={busy} onClick={() => setDraft({ ...draft, steps: [...draft.steps, emptyStep(draft.steps.length + 1)] })}><Plus size={16} aria-hidden="true" />Add step</Button></div>
<div className="approval-editor-heading"><h3>Steps</h3><Button disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined} onClick={() => setDraft({ ...draft, steps: [...draft.steps, emptyStep(draft.steps.length + 1)] })}><Plus size={16} aria-hidden="true" />Add step</Button></div>
<div className="approval-step-list">
{draft.steps.map((step, index) => <div className="approval-step-editor" key={`${index}:${step.key}`}>
<FormField label="Key"><input value={step.key} disabled={busy} onChange={(event) => patchStep(index, { key: event.target.value })} /></FormField>
@@ -48,7 +69,7 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: {
<FormField label="Actor value"><input value={step.selectors[0].value} disabled={busy} onChange={(event) => patchStep(index, { selectors: [{ ...step.selectors[0], value: event.target.value }] })} /></FormField>
<FormField label="Required"><input type="number" min={1} value={step.required_approvals} disabled={busy} onChange={(event) => patchStep(index, { required_approvals: Number(event.target.value) })} /></FormField>
<ToggleSwitch label="Signature" checked={step.signature_required} disabled={busy} onChange={(value) => patchStep(index, { signature_required: value })} />
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.steps.length === 1} onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, itemIndex) => itemIndex !== index) })} />
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.steps.length === 1} disabledReason={busy ? APPROVALS_I18N.busy : draft.steps.length === 1 ? APPROVALS_I18N.oneStep : undefined} onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, itemIndex) => itemIndex !== index) })} />
</div>)}
</div>
</div>
@@ -62,3 +83,7 @@ function emptyStep(index: number): ApprovalStep {
function initialDraft(): ApprovalDraft {
return { title: "", description: "", subject_module: "", subject_type: "", subject_id: "", subject_version: "", subject_digest: "", steps: [emptyStep(1)], separation_of_duties: true, unique_actors_across_steps: false, expires_at: null, policy_refs: [], evidence_actors: {}, template_id: null, template_revision: null, metadata: {} };
}
function draftKey(draft: ApprovalDraft): string {
return JSON.stringify(draft);
}