Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:32 +02:00
parent cf4205f780
commit 3856765520
2 changed files with 59 additions and 102 deletions
+58 -45
View File
@@ -15,15 +15,23 @@ import { DialogSection, ToolbarGroup, ActionToolbar,
ActionBlockerHint,
Button,
ConfirmDialog,
ContentSection,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
FormField,
IconButton,
LoadingFrame,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
ToggleSwitch,
WorkspaceFrame,
WorkspaceLayout,
formatDateTime,
hasScope,
useUnsavedChanges,
@@ -293,20 +301,27 @@ export default function TemplatesPage({ settings, auth }: Props) {
};
return (
<main className="templates-page">
<div className="templates-shell">
<aside className="templates-sidebar">
<ActionToolbar className="templates-sidebar-toolbar">
<WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Template library"
contentLabel="Template workspace"
contentClassName="templates-workspace"
primary={<>
<ActionToolbar surface="panel-header">
<strong>Template library</strong>
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
</ActionToolbar>
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
<div className="templates-list">
<FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
<SelectionList variant="navigation" label="Templates">
{visibleItems.map((item) => (
<button
<SelectionListItem
key={item.id}
type="button"
className={item.id === selectedId ? "is-selected" : ""}
selected={item.id === selectedId}
onClick={() => {
if (item.id === selectedId) return;
requestDiscard(() => {
@@ -315,16 +330,15 @@ export default function TemplatesPage({ settings, auth }: Props) {
});
}}
>
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span>
<SelectionListItemContent title={item.name} description={`${typeLabel(item.template_type)} · revision ${item.current_revision}`} />
<StatusBadge status={item.status} label={item.status} />
</button>
</SelectionListItem>
))}
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>}
</div>
</aside>
<section className="templates-workspace">
<ActionToolbar className="templates-workspace-toolbar">
{!visibleItems.length && <StatePanel size="compact" description="No matching templates." />}
</SelectionList>
</>}
>
<ActionToolbar surface="panel-header" className="templates-workspace-toolbar">
<span className="templates-current-title">
<strong>{selected?.name ?? "Select a template"}</strong>
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
@@ -363,7 +377,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
<LoadingFrame loading={loading} label="Loading templates">
<div className="templates-content">
{!selected ? <p className="templates-empty">Create or select a reusable template.</p> : view === "definition" ? <>
{!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} />
</> : <>
@@ -388,8 +402,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
</>}
</div>
</LoadingFrame>
</section>
</div>
</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 className="templates-dialog-form">
@@ -400,13 +413,13 @@ export default function TemplatesPage({ settings, auth }: Props) {
<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>
</WorkspaceFrame>
);
}
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
return <section className="templates-section templates-history">
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div>
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>
@@ -415,22 +428,22 @@ function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }:
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
</span>
</div>)}
{!revisions.length && <p className="templates-inline-empty">No revision evidence is available.</p>}
{!revisions.length && <StatePanel size="inline" description="No revision evidence is available." />}
</div>
</section>;
</ContentSection>;
}
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
return <section className="templates-section templates-history">
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div>
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 && <p className="templates-inline-empty">No output has been rendered for this template.</p>}
{!renders.length && <StatePanel size="inline" description="No output has been rendered for this template." />}
</div>
</section>;
</ContentSection>;
}
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
@@ -453,8 +466,8 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
</div>
<section className="templates-section">
<div 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></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}`}>
@@ -465,12 +478,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<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 && <p className="templates-inline-empty">No required fields. Tokens still resolve from supplied parameters and items.</p>}
{!draft.required_fields.length && <StatePanel size="inline" description="No required fields. Tokens still resolve from supplied parameters and items." />}
</div>
</section>
</ContentSection>
<section className="templates-section">
<div className="templates-section-heading"><strong>Page and media</strong></div>
<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>
@@ -480,12 +493,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<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>
</section>
</ContentSection>
<section className="templates-section templates-body-section">
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div>
<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} />
</section>
</ContentSection>
</div>
);
}
@@ -509,8 +522,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
}) {
return (
<div className="templates-preview">
<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>
<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>
@@ -521,7 +534,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
<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>
</ContentSection>
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
{compatibility.compatible
@@ -529,8 +542,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
</DismissibleAlert>}
{render && <section className="templates-section templates-render-result">
<div className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></div>
{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>
@@ -540,7 +553,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
<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>
</section>}
</ContentSection>}
</div>
);
}