Migrate Templates interface patterns
This commit is contained in:
@@ -12,9 +12,11 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
ToggleSwitch,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
@@ -49,6 +52,12 @@ import {
|
||||
type TemplateRevision,
|
||||
type TemplateType
|
||||
} from "../../api/templates";
|
||||
import {
|
||||
TEMPLATE_FIELDS_DOCUMENTATION,
|
||||
TEMPLATE_OUTPUT_DOCUMENTATION,
|
||||
TEMPLATES_DOCUMENTATION,
|
||||
TEMPLATES_I18N
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = { settings: ApiSettings; auth: AuthInfo };
|
||||
type WorkspaceView = "definition" | "preview";
|
||||
@@ -83,6 +92,8 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createType, setCreateType] = useState<TemplateType>("form_letter");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [publishOpen, setPublishOpen] = useState(false);
|
||||
const [finalRenderOpen, setFinalRenderOpen] = useState(false);
|
||||
const [sampleText, setSampleText] = useState('{\n "name": "Ada Example",\n "address": "Main Street 1",\n "postal_code": "10115",\n "city": "Berlin"\n}');
|
||||
const [usage, setUsage] = useState("campaign.postal");
|
||||
const [outputFormat, setOutputFormat] = useState<"html" | "text">("html");
|
||||
@@ -91,6 +102,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
const [render, setRender] = useState<TemplateRender | null>(null);
|
||||
const [revisions, setRevisions] = useState<TemplateRevision[]>([]);
|
||||
const [renders, setRenders] = useState<TemplateRender[]>([]);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const selected = items.find((item) => item.id === selectedId) ?? null;
|
||||
const canWrite = hasScope(auth, "templates:template:write") || hasScope(auth, "templates:template:admin");
|
||||
@@ -173,10 +185,16 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: () => applyItem(selected) });
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => applyItem(selected),
|
||||
title: "i18n:govoplan-templates.unsaved_title",
|
||||
message: "i18n:govoplan-templates.unsaved_message"
|
||||
});
|
||||
|
||||
const create = async () => {
|
||||
if (!createName.trim()) return;
|
||||
const create = async (): Promise<boolean> => {
|
||||
if (!createName.trim()) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -188,18 +206,39 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
setCreateName("");
|
||||
setSuccess(`Created ${created.name}.`);
|
||||
await reload(created.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: Boolean(createOpen && createName.trim()),
|
||||
onSave: create,
|
||||
onDiscard: () => {
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
setCreateType("form_letter");
|
||||
},
|
||||
title: "i18n:govoplan-templates.create_unsaved_title",
|
||||
message: "i18n:govoplan-templates.create_unsaved_message"
|
||||
});
|
||||
|
||||
const closeCreate = () => {
|
||||
if (busy) return;
|
||||
if (createName.trim()) requestDiscard(() => setCreateOpen(false));
|
||||
else setCreateOpen(false);
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (!selected || dirty) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await publishTemplate(settings, selected);
|
||||
setPublishOpen(false);
|
||||
setSuccess(`Published revision ${updated.current_revision}.`);
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
@@ -242,6 +281,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
persistToFiles
|
||||
});
|
||||
setRender(nextRender);
|
||||
if (final) setFinalRenderOpen(false);
|
||||
setRenders(await listTemplateRenders(settings, selected.id));
|
||||
setSuccess(`${final ? "Final" : "Preview"} output rendered with ${nextRender.item_count} item(s).`);
|
||||
} catch (caught) {
|
||||
@@ -257,7 +297,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
<aside className="templates-sidebar">
|
||||
<div className="templates-sidebar-toolbar">
|
||||
<strong>Template library</strong>
|
||||
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} onClick={() => setCreateOpen(true)} />
|
||||
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
|
||||
</div>
|
||||
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
|
||||
<div className="templates-list">
|
||||
@@ -268,8 +308,10 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
className={item.id === selectedId ? "is-selected" : ""}
|
||||
onClick={() => {
|
||||
if (item.id === selectedId) return;
|
||||
setSelectedId(item.id);
|
||||
applyItem(item);
|
||||
requestDiscard(() => {
|
||||
setSelectedId(item.id);
|
||||
applyItem(item);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span>
|
||||
@@ -287,10 +329,11 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
||||
</span>
|
||||
<div className="templates-toolbar-actions">
|
||||
<IconButton label="Discard and reload" icon={<RefreshCw size={17} />} onClick={() => void reload(selectedId)} />
|
||||
<Button variant="primary" disabled={!selected || readOnly || !dirty || busy} onClick={() => void save()}><Save size={16} /> Save revision</Button>
|
||||
<Button disabled={!selected || !canPublish || dirty || busy} onClick={() => void publish()}><FileCheck2 size={16} /> Publish</Button>
|
||||
<IconButton label="Delete template" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} onClick={() => setDeleteOpen(true)} />
|
||||
<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />
|
||||
<IconButton label="Discard and reload" icon={<RefreshCw size={17} />} disabled={loading || busy} disabledReason={loading ? TEMPLATES_I18N.loading : busy ? TEMPLATES_I18N.busy : undefined} onClick={() => requestDiscard(() => void reload(selectedId))} />
|
||||
<Button variant="primary" disabled={!selected || readOnly || !dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : !dirty ? TEMPLATES_I18N.noChanges : undefined} onClick={() => void save()}><Save size={16} /> Save revision</Button>
|
||||
<Button disabled={!selected || !canPublish || dirty || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : !canPublish ? TEMPLATES_I18N.publishReason : dirty ? TEMPLATES_I18N.saveBeforeAction : undefined} onClick={() => setPublishOpen(true)}><FileCheck2 size={16} /> Publish</Button>
|
||||
<IconButton label="Delete template" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={setView}
|
||||
@@ -303,6 +346,18 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
<div className="templates-alerts">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||
{selected && readOnly && <ActionBlockerHint
|
||||
tone="info"
|
||||
reason={{
|
||||
summary: "Template is read-only",
|
||||
details: canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason,
|
||||
requiredAction: TEMPLATES_I18N.permissionAction,
|
||||
actor: TEMPLATES_I18N.permissionActor,
|
||||
target: TEMPLATES_I18N.permissionDestination
|
||||
}}
|
||||
labels={{ requiredAction: TEMPLATES_I18N.requiredAction, actor: TEMPLATES_I18N.actor, target: TEMPLATES_I18N.destination }}
|
||||
documentation={TEMPLATES_DOCUMENTATION}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading templates">
|
||||
@@ -320,11 +375,12 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
compatibility={compatibility}
|
||||
render={render}
|
||||
disabled={busy || dirty || !canRender}
|
||||
disabledReason={busy ? TEMPLATES_I18N.busy : dirty ? TEMPLATES_I18N.saveBeforeAction : !canRender ? TEMPLATES_I18N.renderReason : undefined}
|
||||
onSampleText={setSampleText}
|
||||
onUsage={setUsage}
|
||||
onOutputFormat={setOutputFormat}
|
||||
onPersistToFiles={setPersistToFiles}
|
||||
onRender={runRender}
|
||||
onRender={(final) => final ? setFinalRenderOpen(true) : void runRender(false)}
|
||||
onDownload={() => render && void downloadTemplateRender(settings, render).catch((caught) => setError(errorMessage(caught)))}
|
||||
/>
|
||||
<RenderHistory renders={renders} settings={settings} onError={setError} />
|
||||
@@ -334,12 +390,14 @@ export default function TemplatesPage({ settings, auth }: Props) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} title="Add template" onClose={() => setCreateOpen(false)} footer={<><Button onClick={() => setCreateOpen(false)}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} onClick={() => void create()}>Create</Button></>}>
|
||||
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
|
||||
<div className="templates-dialog-form">
|
||||
<FormField label="Name"><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
||||
<FormField label="Type"><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
||||
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
<ConfirmDialog open={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} />
|
||||
<ConfirmDialog open={finalRenderOpen} title="i18n:govoplan-templates.render_title" message="i18n:govoplan-templates.render_message" confirmLabel="Render final output" busy={busy} onCancel={() => setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} />
|
||||
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
|
||||
</main>
|
||||
);
|
||||
@@ -386,11 +444,11 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
|
||||
return (
|
||||
<div className="templates-definition">
|
||||
<div className="templates-definition-fields">
|
||||
<FormField label="Name"><input disabled={disabled} value={draft.name} onChange={(event) => update("name", event.target.value)} /></FormField>
|
||||
<FormField label="Type"><select disabled={disabled} value={draft.template_type} onChange={(event) => update("template_type", event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||
<FormField label="Locale"><input disabled={disabled} value={draft.locale} onChange={(event) => update("locale", event.target.value)} /></FormField>
|
||||
<FormField label="Visibility"><select disabled={disabled} value={scopeValue} onChange={(event) => { const [scopeType, scopeId] = event.target.value.split(":", 2); onChange({ ...draft, scope_type: scopeType as TemplatePayload["scope_type"], scope_id: scopeId || null }); }}>{scopeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></FormField>
|
||||
<FormField label="Usages" help="Comma-separated capability contexts, for example campaign.postal or addresses.labels."><input disabled={disabled} value={draft.usages.join(", ")} onChange={(event) => update("usages", splitValues(event.target.value))} /></FormField>
|
||||
<FormField label="Name" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input disabled={disabled} value={draft.name} onChange={(event) => update("name", event.target.value)} /></FormField>
|
||||
<FormField label="Type" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select disabled={disabled} value={draft.template_type} onChange={(event) => update("template_type", event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||
<FormField label="Locale" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input disabled={disabled} value={draft.locale} onChange={(event) => update("locale", event.target.value)} /></FormField>
|
||||
<FormField label="Visibility" documentation={TEMPLATE_FIELDS_DOCUMENTATION}><select disabled={disabled} value={scopeValue} onChange={(event) => { const [scopeType, scopeId] = event.target.value.split(":", 2); onChange({ ...draft, scope_type: scopeType as TemplatePayload["scope_type"], scope_id: scopeId || null }); }}>{scopeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></FormField>
|
||||
<FormField label="Usages" help="Comma-separated capability contexts, for example campaign.postal or addresses.labels." documentation={TEMPLATE_FIELDS_DOCUMENTATION}><input disabled={disabled} value={draft.usages.join(", ")} onChange={(event) => update("usages", splitValues(event.target.value))} /></FormField>
|
||||
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
|
||||
</div>
|
||||
|
||||
@@ -431,7 +489,7 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, onSampleText, onUsage, onOutputFormat, onPersistToFiles, onRender, onDownload }: {
|
||||
function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, disabledReason, onSampleText, onUsage, onOutputFormat, onPersistToFiles, onRender, onDownload }: {
|
||||
item: TemplateDefinition;
|
||||
sampleText: string;
|
||||
usage: string;
|
||||
@@ -440,6 +498,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
|
||||
compatibility: TemplateCompatibility | null;
|
||||
render: TemplateRender | null;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSampleText: (value: string) => void;
|
||||
onUsage: (value: string) => void;
|
||||
onOutputFormat: (value: "html" | "text") => void;
|
||||
@@ -452,14 +511,14 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
|
||||
<section className="templates-section">
|
||||
<div className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></div>
|
||||
<div className="templates-preview-controls">
|
||||
<FormField label="Usage"><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
||||
<FormField label="Output"><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
|
||||
<FormField label="Usage" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
||||
<FormField label="Output" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
|
||||
<ToggleSwitch checked={persistToFiles} label="Store in Files when available" onChange={onPersistToFiles} />
|
||||
</div>
|
||||
<textarea className="templates-sample" value={sampleText} onChange={(event) => onSampleText(event.target.value)} spellCheck={false} aria-label="Sample item JSON" />
|
||||
<div className="templates-preview-actions">
|
||||
<Button disabled={disabled} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
|
||||
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={!item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
|
||||
<Button disabled={disabled} disabledReason={disabled ? disabledReason : undefined} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
|
||||
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={disabled ? disabledReason : !item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user