Adopt shared WebUI structural primitives
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user