Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
662 lines
34 KiB
TypeScript
662 lines
34 KiB
TypeScript
import {
|
|
Download,
|
|
Eye,
|
|
FileCheck2,
|
|
Plus,
|
|
Save,
|
|
Send,
|
|
Trash2,
|
|
X
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { FormGrid, ActionToolbar,
|
|
ApiError,
|
|
ActionBlockerHint,
|
|
Button,
|
|
ConfirmDialog,
|
|
ContentSection,
|
|
Dialog,
|
|
DialogSection,
|
|
DocumentationHelpLink,
|
|
TextWithHelp,
|
|
DismissibleAlert,
|
|
FilterBar,
|
|
FormField,
|
|
IconButton,
|
|
LoadingFrame,
|
|
SegmentedControl,
|
|
SelectionList,
|
|
SelectionListItem,
|
|
SelectionListItemContent,
|
|
StatePanel,
|
|
StatusBadge,
|
|
ToggleSwitch,
|
|
WorkspaceActionBar,
|
|
WorkspaceFrame,
|
|
WorkspaceLayout,
|
|
formatDateTime,
|
|
hasScope,
|
|
useUnsavedChanges,
|
|
useUnsavedDraftGuard,
|
|
type ApiSettings,
|
|
type AuthInfo
|
|
} from "@govoplan/core-webui";
|
|
import { WysiwygEditor } from "@govoplan/core-webui/wysiwyg";
|
|
import {
|
|
checkTemplateCompatibility,
|
|
createTemplate,
|
|
deleteTemplate,
|
|
downloadTemplateRender,
|
|
listTemplateRenders,
|
|
listTemplateRevisions,
|
|
listTemplates,
|
|
publishTemplate,
|
|
renderTemplate,
|
|
updateTemplate,
|
|
type TemplateCompatibility,
|
|
type TemplateDefinition,
|
|
type TemplateFieldRequirement,
|
|
type TemplateFieldType,
|
|
type TemplatePayload,
|
|
type TemplateRender,
|
|
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";
|
|
|
|
const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [
|
|
{ value: "label", label: "Label" },
|
|
{ value: "label_sheet", label: "Label sheet" },
|
|
{ value: "envelope", label: "Envelope" },
|
|
{ value: "serial_letter", label: "Serial letter" },
|
|
{ value: "form_letter", label: "Form letter" },
|
|
{ value: "list_layout", label: "List layout" },
|
|
{ value: "email", label: "Email" },
|
|
{ value: "content_fragment", label: "Content fragment" },
|
|
{ value: "generic", label: "Generic" }
|
|
];
|
|
|
|
const FIELD_TYPES: TemplateFieldType[] = [
|
|
"string", "integer", "number", "boolean", "date", "datetime", "object", "array"
|
|
];
|
|
|
|
export default function TemplatesPage({ settings, auth }: Props) {
|
|
const [items, setItems] = useState<TemplateDefinition[]>([]);
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const [draft, setDraft] = useState<TemplatePayload>(emptyPayload());
|
|
const [savedKey, setSavedKey] = useState("");
|
|
const [view, setView] = useState<WorkspaceView>("definition");
|
|
const [search, setSearch] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
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");
|
|
const [persistToFiles, setPersistToFiles] = useState(false);
|
|
const [compatibility, setCompatibility] = useState<TemplateCompatibility | null>(null);
|
|
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");
|
|
const canPublish = hasScope(auth, "templates:template:publish") || hasScope(auth, "templates:template:admin");
|
|
const canRender = hasScope(auth, "templates:template:render") || hasScope(auth, "templates:template:admin");
|
|
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
|
|
const readOnly = !canWrite || Boolean(selected?.read_only);
|
|
|
|
const applyItem = useCallback((item: TemplateDefinition | null) => {
|
|
const next = item ? payloadFromItem(item) : emptyPayload();
|
|
setDraft(next);
|
|
setSavedKey(item ? draftKey(next) : "");
|
|
setCompatibility(null);
|
|
setRender(null);
|
|
setUsage(item?.revision.usages[0] ?? "campaign.postal");
|
|
}, []);
|
|
|
|
const reload = useCallback(async (preferredId?: string) => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const nextItems = await listTemplates(settings);
|
|
setItems(nextItems);
|
|
const nextId = preferredId && nextItems.some((item) => item.id === preferredId)
|
|
? preferredId
|
|
: nextItems.some((item) => item.id === selectedId)
|
|
? selectedId
|
|
: nextItems[0]?.id ?? "";
|
|
setSelectedId(nextId);
|
|
applyItem(nextItems.find((item) => item.id === nextId) ?? null);
|
|
} catch (caught) {
|
|
setError(errorMessage(caught));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [applyItem, selectedId, settings]);
|
|
|
|
useEffect(() => { void reload(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
useEffect(() => {
|
|
if (!selectedId) {
|
|
setRevisions([]);
|
|
setRenders([]);
|
|
return;
|
|
}
|
|
let active = true;
|
|
void Promise.all([
|
|
listTemplateRevisions(settings, selectedId),
|
|
listTemplateRenders(settings, selectedId)
|
|
]).then(([nextRevisions, nextRenders]) => {
|
|
if (!active) return;
|
|
setRevisions(nextRevisions);
|
|
setRenders(nextRenders);
|
|
}).catch((caught) => {
|
|
if (active) setError(errorMessage(caught));
|
|
});
|
|
return () => { active = false; };
|
|
}, [selectedId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
const visibleItems = useMemo(() => {
|
|
const needle = search.trim().toLocaleLowerCase();
|
|
return needle
|
|
? items.filter((item) => `${item.name} ${item.template_type} ${item.revision.usages.join(" ")}`.toLocaleLowerCase().includes(needle))
|
|
: items;
|
|
}, [items, search]);
|
|
|
|
const save = async () => {
|
|
if (!selected || !draft.name.trim() || !draft.usages.length) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const updated = await updateTemplate(settings, selected, draft);
|
|
setSuccess(`Saved immutable revision ${updated.current_revision}.`);
|
|
await reload(updated.id);
|
|
return true;
|
|
} catch (caught) {
|
|
setError(errorMessage(caught));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty,
|
|
onSave: save,
|
|
onDiscard: () => applyItem(selected),
|
|
title: "i18n:govoplan-templates.unsaved_title",
|
|
message: "i18n:govoplan-templates.unsaved_message"
|
|
});
|
|
|
|
const create = async (): Promise<boolean> => {
|
|
if (!createName.trim()) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const created = await createTemplate(settings, {
|
|
...emptyPayload(createType),
|
|
name: createName.trim()
|
|
});
|
|
setCreateOpen(false);
|
|
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) {
|
|
setError(errorMessage(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const remove = async () => {
|
|
if (!selected) return;
|
|
setBusy(true);
|
|
try {
|
|
await deleteTemplate(settings, selected);
|
|
setDeleteOpen(false);
|
|
setSuccess(`Deleted ${selected.name}.`);
|
|
await reload();
|
|
} catch (caught) {
|
|
setError(errorMessage(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const runRender = async (final: boolean) => {
|
|
if (!selected || dirty) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const sample = parseSample(sampleText);
|
|
const fields = flattenFieldTypes(sample);
|
|
const nextCompatibility = await checkTemplateCompatibility(settings, selected, usage, fields, outputFormat);
|
|
setCompatibility(nextCompatibility);
|
|
if (!nextCompatibility.compatible) return;
|
|
const nextRender = await renderTemplate(settings, selected, {
|
|
usage,
|
|
outputFormat,
|
|
items: [sample],
|
|
final,
|
|
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) {
|
|
setError(errorMessage(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
|
|
<WorkspaceActionBar
|
|
scope="workspace"
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
|
|
contextActions={<strong>Template library</strong>}
|
|
createAction={<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />}
|
|
/>
|
|
<WorkspaceLayout
|
|
variant="split"
|
|
primarySize="compact"
|
|
surface="contained"
|
|
primaryScrollable={false}
|
|
contentScrollable={false}
|
|
primaryLabel="Template library"
|
|
contentLabel="Template workspace"
|
|
contentClassName="templates-workspace"
|
|
primary={<>
|
|
<FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
|
|
<SelectionList variant="navigation" label="Templates">
|
|
{visibleItems.map((item) => (
|
|
<SelectionListItem
|
|
key={item.id}
|
|
selected={item.id === selectedId}
|
|
onClick={() => {
|
|
if (item.id === selectedId) return;
|
|
requestDiscard(() => {
|
|
setSelectedId(item.id);
|
|
applyItem(item);
|
|
});
|
|
}}
|
|
>
|
|
<SelectionListItemContent title={item.name} description={`${typeLabel(item.template_type)} · revision ${item.current_revision}`} />
|
|
<StatusBadge status={item.status} label={item.status} />
|
|
</SelectionListItem>
|
|
))}
|
|
{!visibleItems.length && <StatePanel size="compact" description="No matching templates." />}
|
|
</SelectionList>
|
|
</>}
|
|
>
|
|
<WorkspaceActionBar
|
|
scope="editor-pane"
|
|
variant="editor"
|
|
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
|
className="templates-workspace-toolbar"
|
|
contextActions={<span className="templates-current-title">
|
|
<TextWithHelp help={<DocumentationHelpLink reference={TEMPLATES_DOCUMENTATION} />}>
|
|
<strong>{selected?.name ?? "Select a template"}</strong>
|
|
</TextWithHelp>
|
|
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
|
</span>}
|
|
primaryActions={<>
|
|
<Button helpContextId="templates.action.publish" helpModuleId="templates" 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>
|
|
<SegmentedControl
|
|
value={view}
|
|
onChange={setView}
|
|
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
|
|
ariaLabel="Template workspace"
|
|
/>
|
|
</>}
|
|
destructiveActions={<IconButton label="Delete template" helpContextId="templates.action.delete" helpModuleId="templates" 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)} />}
|
|
discardAction={{ label: "Discard and reload", onClick: () => requestDiscard(() => void reload(selectedId)), disabled: !selected }}
|
|
saveAction={{
|
|
label: <><Save size={16} /> Save revision</>,
|
|
disabled: !selected || readOnly || busy,
|
|
disabledReason: busy ? TEMPLATES_I18N.busy : !selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined,
|
|
onClick: () => void save()
|
|
}}
|
|
/>
|
|
|
|
<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">
|
|
<div className="templates-content">
|
|
{!selected ? <StatePanel size="fill" title="Templates" description="Create or select a reusable template." /> : view === "definition" ? <>
|
|
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
|
|
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
|
|
</> : <>
|
|
<PreviewPanel
|
|
item={selected}
|
|
sampleText={sampleText}
|
|
usage={usage}
|
|
outputFormat={outputFormat}
|
|
persistToFiles={persistToFiles}
|
|
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={(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} />
|
|
</>}
|
|
</div>
|
|
</LoadingFrame>
|
|
</WorkspaceLayout>
|
|
|
|
<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></>}>
|
|
<DialogSection>
|
|
<FormGrid columns={2} collapseAt="standard">
|
|
<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>
|
|
</FormGrid>
|
|
</DialogSection>
|
|
</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()} />
|
|
</WorkspaceFrame>
|
|
);
|
|
}
|
|
|
|
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
|
|
return <ContentSection className="templates-history">
|
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></ActionToolbar>
|
|
<div className="templates-history-list">
|
|
{revisions.map((revision) => <div key={revision.id}>
|
|
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
|
|
<span className="templates-history-badges">
|
|
{revision.id === currentRevisionId && <StatusBadge status="current" label="Current" />}
|
|
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
|
|
</span>
|
|
</div>)}
|
|
{!revisions.length && <StatePanel size="inline" description="No revision evidence is available." />}
|
|
</div>
|
|
</ContentSection>;
|
|
}
|
|
|
|
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
|
|
return <ContentSection className="templates-history">
|
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></ActionToolbar>
|
|
<div className="templates-history-list">
|
|
{renders.map((item) => <div key={item.render_id}>
|
|
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
|
|
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
|
|
</div>)}
|
|
{!renders.length && <StatePanel size="inline" description="No output has been rendered for this template." />}
|
|
</div>
|
|
</ContentSection>;
|
|
}
|
|
|
|
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
|
|
const update = <K extends keyof TemplatePayload>(key: K, value: TemplatePayload[K]) => onChange({ ...draft, [key]: value });
|
|
const scopeOptions = [
|
|
{ value: "tenant:", label: "Tenant" },
|
|
{ value: `user:${auth.user.account_id}`, label: "Only me" },
|
|
...auth.groups.map((group) => ({ value: `group:${group.id}`, label: `Group: ${group.name}` }))
|
|
];
|
|
const scopeValue = `${draft.scope_type}:${draft.scope_id ?? ""}`;
|
|
const layout = draft.layout;
|
|
return (
|
|
<div className="templates-definition">
|
|
<div className="templates-definition-fields">
|
|
<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>
|
|
|
|
<ContentSection>
|
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></ActionToolbar>
|
|
<div className="templates-fields-table">
|
|
{draft.required_fields.map((field, index) => (
|
|
<div className="templates-field-row" key={`${index}:${field.path}`}>
|
|
<input disabled={disabled} value={field.path} placeholder="recipient.address" aria-label="Field path" onChange={(event) => updateField(draft, index, { path: event.target.value }, onChange)} />
|
|
<select disabled={disabled} value={field.value_type} aria-label="Field type" onChange={(event) => updateField(draft, index, { value_type: event.target.value as TemplateFieldType }, onChange)}>{FIELD_TYPES.map((value) => <option key={value} value={value}>{value}</option>)}</select>
|
|
<input disabled={disabled} value={field.label ?? ""} placeholder="Label" aria-label="Field label" onChange={(event) => updateField(draft, index, { label: event.target.value || null }, onChange)} />
|
|
<ToggleSwitch checked={field.required} label="Required" disabled={disabled} onChange={(checked) => updateField(draft, index, { required: checked }, onChange)} />
|
|
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
|
|
</div>
|
|
))}
|
|
{!draft.required_fields.length && <StatePanel size="inline" description="No required fields. Tokens still resolve from supplied parameters and items." />}
|
|
</div>
|
|
</ContentSection>
|
|
|
|
<ContentSection>
|
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Page and media</strong></ActionToolbar>
|
|
<div className="templates-layout-fields">
|
|
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
|
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
|
|
{draft.template_type === "label_sheet" && <>
|
|
<FormField label="Columns"><input type="number" min="1" max="12" disabled={disabled} value={Number(layout.columns ?? 3)} onChange={(event) => update("layout", { ...layout, columns: Number(event.target.value) })} /></FormField>
|
|
<FormField label="Rows"><input type="number" min="1" max="30" disabled={disabled} value={Number(layout.rows ?? 8)} onChange={(event) => update("layout", { ...layout, rows: Number(event.target.value) })} /></FormField>
|
|
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
|
|
</>}
|
|
</div>
|
|
</ContentSection>
|
|
|
|
<ContentSection className="templates-body-section">
|
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></ActionToolbar>
|
|
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
|
|
</ContentSection>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, disabledReason, onSampleText, onUsage, onOutputFormat, onPersistToFiles, onRender, onDownload }: {
|
|
item: TemplateDefinition;
|
|
sampleText: string;
|
|
usage: string;
|
|
outputFormat: "html" | "text";
|
|
persistToFiles: boolean;
|
|
compatibility: TemplateCompatibility | null;
|
|
render: TemplateRender | null;
|
|
disabled: boolean;
|
|
disabledReason?: string;
|
|
onSampleText: (value: string) => void;
|
|
onUsage: (value: string) => void;
|
|
onOutputFormat: (value: "html" | "text") => void;
|
|
onPersistToFiles: (value: boolean) => void;
|
|
onRender: (final: boolean) => void;
|
|
onDownload: () => void;
|
|
}) {
|
|
return (
|
|
<div className="templates-preview">
|
|
<ContentSection>
|
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></ActionToolbar>
|
|
<div className="templates-preview-controls">
|
|
<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} 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>
|
|
</ContentSection>
|
|
|
|
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
|
|
{compatibility.compatible
|
|
? "The selected usage, output profile, and supplied fields are compatible."
|
|
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
|
|
</DismissibleAlert>}
|
|
|
|
{render && <ContentSection className="templates-render-result">
|
|
<ActionToolbar surface="section-header" className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></ActionToolbar>
|
|
<dl>
|
|
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
|
|
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
|
|
<div><dt>Output</dt><dd>{shortHash(render.output_sha256)}</dd></div>
|
|
<div><dt>Renderer</dt><dd>{render.renderer_version}</dd></div>
|
|
<div><dt>Items / pages</dt><dd>{render.item_count} / {render.page_count}</dd></div>
|
|
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
|
|
</dl>
|
|
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
|
|
</ContentSection>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePayload {
|
|
return {
|
|
name: "",
|
|
description: null,
|
|
scope_type: "tenant",
|
|
scope_id: null,
|
|
template_type: templateType,
|
|
usages: templateType === "email"
|
|
? ["campaign.email", "campaign.content"]
|
|
: templateType === "content_fragment"
|
|
? ["campaign.content"]
|
|
: ["campaign.postal"],
|
|
locale: "en",
|
|
required_fields: [],
|
|
output_profiles: [],
|
|
content_text: null,
|
|
content_html: "<p>Hello {{name}},</p><p></p>",
|
|
layout: { page_size: pageSizeForType(templateType), margin_mm: 15 },
|
|
metadata: {}
|
|
};
|
|
}
|
|
|
|
function payloadFromItem(item: TemplateDefinition): TemplatePayload {
|
|
return {
|
|
name: item.name,
|
|
slug: item.slug,
|
|
description: item.description ?? null,
|
|
scope_type: item.scope_type,
|
|
scope_id: item.scope_id ?? null,
|
|
template_type: item.template_type,
|
|
usages: [...item.revision.usages],
|
|
locale: item.revision.locale,
|
|
required_fields: item.revision.required_fields.map((field) => ({ ...field })),
|
|
output_profiles: item.revision.output_profiles.map((profile) => ({ ...profile, capabilities: [...profile.capabilities], page: { ...profile.page } })),
|
|
content_text: item.revision.content_text ?? null,
|
|
content_html: item.revision.content_html ?? null,
|
|
layout: { ...item.revision.layout },
|
|
metadata: { ...item.revision.metadata }
|
|
};
|
|
}
|
|
|
|
function emptyField(): TemplateFieldRequirement {
|
|
return { path: "", value_type: "string", label: null, required: true, description: null };
|
|
}
|
|
|
|
function updateField(draft: TemplatePayload, index: number, patch: Partial<TemplateFieldRequirement>, onChange: (draft: TemplatePayload) => void) {
|
|
onChange({ ...draft, required_fields: draft.required_fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field) });
|
|
}
|
|
|
|
function pageSizeForType(type: TemplateType): string { return type === "envelope" ? "DL" : "A4"; }
|
|
function typeLabel(type: TemplateType): string { return TEMPLATE_TYPES.find((item) => item.value === type)?.label ?? type; }
|
|
function splitValues(value: string): string[] { return [...new Set(value.split(",").map((item) => item.trim().toLocaleLowerCase()).filter(Boolean))]; }
|
|
function draftKey(value: TemplatePayload): string { return JSON.stringify(value); }
|
|
function shortHash(value: string): string { return value.slice(0, 12); }
|
|
|
|
function parseSample(value: string): Record<string, unknown> {
|
|
const parsed: unknown = JSON.parse(value);
|
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("Sample input must be one JSON object.");
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
function flattenFieldTypes(value: Record<string, unknown>, prefix = "", result: Record<string, string> = {}): Record<string, string> {
|
|
for (const [key, item] of Object.entries(value)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (Array.isArray(item)) result[path] = "array";
|
|
else if (item !== null && typeof item === "object") {
|
|
result[path] = "object";
|
|
flattenFieldTypes(item as Record<string, unknown>, path, result);
|
|
} else if (typeof item === "number") result[path] = Number.isInteger(item) ? "integer" : "number";
|
|
else result[path] = typeof item;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
if (error instanceof ApiError) {
|
|
try {
|
|
const parsed = JSON.parse(error.body) as { detail?: unknown };
|
|
return typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail ?? parsed);
|
|
} catch { return error.message; }
|
|
}
|
|
return error instanceof Error ? error.message : "The template operation failed.";
|
|
}
|