Migrate Templates interface patterns
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Templates Interface Pattern Migration
|
||||
|
||||
This migration applies the GovOPlaN interface pattern language to the Template
|
||||
library, immutable-revision editor, preview/final-output workspace, and render
|
||||
evidence history.
|
||||
|
||||
## Surface Inventory
|
||||
|
||||
| Surface | Archetype | Consequence class | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `/templates` library | Governed directory | Select, create, or retire Template | Shared loading, empty, permission, read-only, disabled-reason, and help states |
|
||||
| Definition editor | Consequential definition editor | Save immutable revision | Guarded draft with scope, usage, required-data, layout, and content semantics |
|
||||
| Publish action | Governed lifecycle transition | Make one revision consumable | Permission/lifecycle explanation and explicit confirmation |
|
||||
| Preview/final output | Evidence-producing preview | Validate sample or render final output | Compatibility diagnostics, published-revision gate, confirmation, and retained hashes |
|
||||
| Revision/render history | Evidence register | Inspect immutable history | Stable status, timestamps, digests, artifact availability, and bounded download |
|
||||
|
||||
## Consequence And Availability Rules
|
||||
|
||||
- Saving creates a new immutable revision. Publishing never rewrites an older
|
||||
revision or its render evidence.
|
||||
- Inherited or policy-constrained Templates remain visible but identify why
|
||||
they are read-only, who can change that, and where to continue.
|
||||
- Final output requires a published revision and explicit confirmation. It
|
||||
records template, input, output, and renderer evidence and may use Files only
|
||||
through the optional artifact capability.
|
||||
- Deleting prevents future selection while retained revisions and renders stay
|
||||
governed by retention policy.
|
||||
|
||||
Backend and WebUI manifests publish the same surface identifiers. English and
|
||||
German catalogues cover module-owned vocabulary, contextual help resolves from
|
||||
manifest documentation, and create/editor drafts are guarded across selection,
|
||||
reload, and navigation.
|
||||
@@ -85,7 +85,15 @@ DOCUMENTATION = (
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
metadata={"seed": True},
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"templates.page",
|
||||
"templates.library",
|
||||
"templates.editor",
|
||||
"templates.state.read-only",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="templates.printable-output",
|
||||
@@ -101,7 +109,51 @@ DOCUMENTATION = (
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
related_modules=("files", "dist_lists", "campaigns", "audit"),
|
||||
metadata={"seed": True},
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"templates.preview",
|
||||
"templates.action.validate-preview",
|
||||
"templates.action.render-final",
|
||||
"templates.evidence.render",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="templates.reference.fields-and-consequences",
|
||||
title="Template fields and lifecycle consequences",
|
||||
summary="Scope, usage, data contract, publication, rendering, and deletion semantics for reusable Templates.",
|
||||
body=(
|
||||
"Visibility determines which tenant, group, or user scope may discover the Template; inherited Templates may be read-only. "
|
||||
"Usages are capability contexts that constrain where a Template may be selected. Required fields form the compatibility "
|
||||
"contract checked against supplied data before rendering. Saving creates a new immutable revision. Publishing marks one "
|
||||
"revision as available for final output without rewriting older revisions or evidence. Preview validates and renders bounded "
|
||||
"sample output; final rendering requires the published revision and records template, input, and output hashes plus renderer "
|
||||
"evidence. Files may retain the artifact when its optional capability is available. Deletion removes the Template from future "
|
||||
"selection but does not rewrite retained render evidence."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
related_modules=("files", "dist_lists", "campaigns", "audit", "policy"),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"templates.field.type",
|
||||
"templates.field.locale",
|
||||
"templates.field.visibility",
|
||||
"templates.field.usages",
|
||||
"templates.field.required-data",
|
||||
"templates.action.publish",
|
||||
"templates.action.delete",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"save_revision": "Creates a new immutable Template revision.",
|
||||
"publish_revision": "Makes the selected immutable revision eligible for final consumer output.",
|
||||
"render_final": "Creates retained render evidence and may persist an artifact through Files.",
|
||||
"delete_template": "Prevents future selection without rewriting retained revisions or render evidence.",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_templates.backend.manifest import manifest
|
||||
|
||||
|
||||
class TemplatesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
self.assertEqual({"/templates"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
{
|
||||
"templates.page",
|
||||
"templates.library",
|
||||
"templates.editor",
|
||||
"templates.preview",
|
||||
},
|
||||
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||
)
|
||||
|
||||
def test_help_and_consequence_metadata_remain_published(self) -> None:
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
library = topics["templates.library"]
|
||||
output = topics["templates.printable-output"]
|
||||
reference = topics["templates.reference.fields-and-consequences"]
|
||||
|
||||
self.assertIn("templates.state.read-only", library.metadata["help_contexts"])
|
||||
self.assertIn("templates.action.render-final", output.metadata["help_contexts"])
|
||||
self.assertIn("templates.field.usages", reference.metadata["help_contexts"])
|
||||
self.assertIn("publish_revision", reference.metadata["consequence_classes"])
|
||||
self.assertIn("delete_template", reference.metadata["consequence_classes"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const TEMPLATES_DOCUMENTATION = {
|
||||
topicId: "templates.library",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const TEMPLATE_FIELDS_DOCUMENTATION = {
|
||||
topicId: "templates.reference.fields-and-consequences",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const TEMPLATE_OUTPUT_DOCUMENTATION = {
|
||||
topicId: "templates.printable-output",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const TEMPLATES_I18N = {
|
||||
loading: "i18n:govoplan-templates.loading_reason",
|
||||
busy: "i18n:govoplan-templates.busy_reason",
|
||||
writeReason: "i18n:govoplan-templates.write_permission_reason",
|
||||
publishReason: "i18n:govoplan-templates.publish_permission_reason",
|
||||
renderReason: "i18n:govoplan-templates.render_permission_reason",
|
||||
readOnlyReason: "i18n:govoplan-templates.read_only_reason",
|
||||
noSelection: "i18n:govoplan-templates.no_selection_reason",
|
||||
noChanges: "i18n:govoplan-templates.no_changes_reason",
|
||||
incomplete: "i18n:govoplan-templates.incomplete_reason",
|
||||
saveBeforeAction: "i18n:govoplan-templates.save_before_action_reason",
|
||||
requiredAction: "i18n:govoplan-templates.required_action",
|
||||
actor: "i18n:govoplan-templates.actor",
|
||||
destination: "i18n:govoplan-templates.destination",
|
||||
permissionAction: "i18n:govoplan-templates.permission_action",
|
||||
permissionActor: "i18n:govoplan-templates.permission_actor",
|
||||
permissionDestination: "i18n:govoplan-templates.permission_destination"
|
||||
} as const;
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
const en = {
|
||||
"i18n:govoplan-templates.templates": "Templates",
|
||||
"i18n:govoplan-templates.library": "Template library",
|
||||
"i18n:govoplan-templates.editor": "Template editor",
|
||||
"i18n:govoplan-templates.preview": "Template preview and output",
|
||||
"i18n:govoplan-templates.loading_reason": "Templates are still loading.",
|
||||
"i18n:govoplan-templates.busy_reason": "Another Template action is still running.",
|
||||
"i18n:govoplan-templates.write_permission_reason": "Your account may not create or revise Templates.",
|
||||
"i18n:govoplan-templates.publish_permission_reason": "Your account may not publish Template revisions.",
|
||||
"i18n:govoplan-templates.render_permission_reason": "Your account may not render Template output.",
|
||||
"i18n:govoplan-templates.read_only_reason": "This Template is inherited or otherwise read-only in the current scope.",
|
||||
"i18n:govoplan-templates.no_selection_reason": "Select or create a Template first.",
|
||||
"i18n:govoplan-templates.no_changes_reason": "There are no definition changes to save.",
|
||||
"i18n:govoplan-templates.incomplete_reason": "Complete the required name and usage fields first.",
|
||||
"i18n:govoplan-templates.save_before_action_reason": "Save the current revision before publishing or rendering it.",
|
||||
"i18n:govoplan-templates.required_action": "Required action",
|
||||
"i18n:govoplan-templates.actor": "Responsible actor",
|
||||
"i18n:govoplan-templates.destination": "Where to continue",
|
||||
"i18n:govoplan-templates.permission_action": "Ask for Template management permission or select a writable Template.",
|
||||
"i18n:govoplan-templates.permission_actor": "A tenant administrator or the owner of the governing scope",
|
||||
"i18n:govoplan-templates.permission_destination": "Access and Template scope administration",
|
||||
"i18n:govoplan-templates.unsaved_title": "Unsaved Template revision",
|
||||
"i18n:govoplan-templates.unsaved_message": "Save or discard this Template revision before leaving the editor.",
|
||||
"i18n:govoplan-templates.create_unsaved_title": "Uncreated Template",
|
||||
"i18n:govoplan-templates.create_unsaved_message": "Create the Template or discard its name before leaving this dialog.",
|
||||
"i18n:govoplan-templates.publish_title": "Publish Template revision",
|
||||
"i18n:govoplan-templates.publish_message": "Publish this immutable revision? Consumers may use it for final output until another revision is published.",
|
||||
"i18n:govoplan-templates.render_title": "Render final output",
|
||||
"i18n:govoplan-templates.render_message": "Render final output from this published revision and the current sample input? The render hashes and output evidence will be retained.",
|
||||
"Template is read-only": "Template is read-only",
|
||||
"Template library": "Template library",
|
||||
"Search templates": "Search templates",
|
||||
"No matching templates.": "No matching templates.",
|
||||
"Select a template": "Select a template",
|
||||
"Discard and reload": "Discard and reload",
|
||||
"Save revision": "Save revision",
|
||||
"Publish": "Publish",
|
||||
"Delete template": "Delete template",
|
||||
"Definition": "Definition",
|
||||
"Preview": "Preview",
|
||||
"Loading templates": "Loading templates",
|
||||
"Create or select a reusable template.": "Create or select a reusable template.",
|
||||
"Add template": "Add template",
|
||||
"Name": "Name",
|
||||
"Type": "Type",
|
||||
"Locale": "Locale",
|
||||
"Visibility": "Visibility",
|
||||
"Usages": "Usages",
|
||||
"Description": "Description",
|
||||
"Required data contract": "Required data contract",
|
||||
"Add field": "Add field",
|
||||
"No required fields. Tokens still resolve from supplied parameters and items.": "No required fields. Tokens still resolve from supplied parameters and items.",
|
||||
"Page and media": "Page and media",
|
||||
"Page size": "Page size",
|
||||
"Margin (mm)": "Margin (mm)",
|
||||
"Columns": "Columns",
|
||||
"Rows": "Rows",
|
||||
"Gap (mm)": "Gap (mm)",
|
||||
"Template body": "Template body",
|
||||
"Revision history": "Revision history",
|
||||
"Output history": "Output history",
|
||||
"Validated sample input": "Validated sample input",
|
||||
"Usage": "Usage",
|
||||
"Output": "Output",
|
||||
"Store in Files when available": "Store in Files when available",
|
||||
"Validate and preview": "Validate and preview",
|
||||
"Render final output": "Render final output",
|
||||
"Render evidence": "Render evidence",
|
||||
"Download": "Download",
|
||||
"Delete template?": "Delete template?"
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
"i18n:govoplan-templates.templates": "Vorlagen",
|
||||
"i18n:govoplan-templates.library": "Vorlagenbibliothek",
|
||||
"i18n:govoplan-templates.editor": "Vorlageneditor",
|
||||
"i18n:govoplan-templates.preview": "Vorlagenvorschau und Ausgabe",
|
||||
"i18n:govoplan-templates.loading_reason": "Vorlagen werden noch geladen.",
|
||||
"i18n:govoplan-templates.busy_reason": "Eine andere Vorlagenaktion läuft noch.",
|
||||
"i18n:govoplan-templates.write_permission_reason": "Ihr Konto darf Vorlagen nicht erstellen oder überarbeiten.",
|
||||
"i18n:govoplan-templates.publish_permission_reason": "Ihr Konto darf Vorlagenrevisionen nicht veröffentlichen.",
|
||||
"i18n:govoplan-templates.render_permission_reason": "Ihr Konto darf keine Vorlagenausgabe erzeugen.",
|
||||
"i18n:govoplan-templates.read_only_reason": "Diese Vorlage ist im aktuellen Bereich geerbt oder anderweitig schreibgeschützt.",
|
||||
"i18n:govoplan-templates.no_selection_reason": "Wählen oder erstellen Sie zuerst eine Vorlage.",
|
||||
"i18n:govoplan-templates.no_changes_reason": "Es gibt keine Definitionsänderungen zu speichern.",
|
||||
"i18n:govoplan-templates.incomplete_reason": "Füllen Sie zuerst Name und Verwendungszweck aus.",
|
||||
"i18n:govoplan-templates.save_before_action_reason": "Speichern Sie die aktuelle Revision, bevor Sie sie veröffentlichen oder ausgeben.",
|
||||
"i18n:govoplan-templates.required_action": "Erforderliche Aktion",
|
||||
"i18n:govoplan-templates.actor": "Verantwortliche Stelle",
|
||||
"i18n:govoplan-templates.destination": "Fortsetzung",
|
||||
"i18n:govoplan-templates.permission_action": "Fordern Sie die Vorlagenberechtigung an oder wählen Sie eine beschreibbare Vorlage.",
|
||||
"i18n:govoplan-templates.permission_actor": "Mandantenadministration oder Eigentümer des maßgeblichen Bereichs",
|
||||
"i18n:govoplan-templates.permission_destination": "Zugriffs- und Vorlagenbereichsverwaltung",
|
||||
"i18n:govoplan-templates.unsaved_title": "Ungespeicherte Vorlagenrevision",
|
||||
"i18n:govoplan-templates.unsaved_message": "Speichern oder verwerfen Sie diese Vorlagenrevision, bevor Sie den Editor verlassen.",
|
||||
"i18n:govoplan-templates.create_unsaved_title": "Nicht erstellte Vorlage",
|
||||
"i18n:govoplan-templates.create_unsaved_message": "Erstellen Sie die Vorlage oder verwerfen Sie ihren Namen, bevor Sie diesen Dialog verlassen.",
|
||||
"i18n:govoplan-templates.publish_title": "Vorlagenrevision veröffentlichen",
|
||||
"i18n:govoplan-templates.publish_message": "Diese unveränderliche Revision veröffentlichen? Verbraucher dürfen sie für endgültige Ausgaben nutzen, bis eine andere Revision veröffentlicht wird.",
|
||||
"i18n:govoplan-templates.render_title": "Endgültige Ausgabe erzeugen",
|
||||
"i18n:govoplan-templates.render_message": "Endgültige Ausgabe aus dieser veröffentlichten Revision und den aktuellen Beispieldaten erzeugen? Ausgabe-Hashes und Nachweise werden aufbewahrt.",
|
||||
"Template is read-only": "Vorlage ist schreibgeschützt",
|
||||
"Template library": "Vorlagenbibliothek",
|
||||
"Search templates": "Vorlagen suchen",
|
||||
"No matching templates.": "Keine passenden Vorlagen.",
|
||||
"Select a template": "Vorlage auswählen",
|
||||
"Discard and reload": "Verwerfen und neu laden",
|
||||
"Save revision": "Revision speichern",
|
||||
"Publish": "Veröffentlichen",
|
||||
"Delete template": "Vorlage löschen",
|
||||
"Definition": "Definition",
|
||||
"Preview": "Vorschau",
|
||||
"Loading templates": "Vorlagen werden geladen",
|
||||
"Create or select a reusable template.": "Erstellen oder wählen Sie eine wiederverwendbare Vorlage.",
|
||||
"Add template": "Vorlage hinzufügen",
|
||||
"Name": "Name",
|
||||
"Type": "Typ",
|
||||
"Locale": "Gebietsschema",
|
||||
"Visibility": "Sichtbarkeit",
|
||||
"Usages": "Verwendungen",
|
||||
"Description": "Beschreibung",
|
||||
"Required data contract": "Erforderlicher Datenvertrag",
|
||||
"Add field": "Feld hinzufügen",
|
||||
"No required fields. Tokens still resolve from supplied parameters and items.": "Keine Pflichtfelder. Platzhalter werden weiterhin aus Parametern und Einträgen aufgelöst.",
|
||||
"Page and media": "Seite und Medium",
|
||||
"Page size": "Seitengröße",
|
||||
"Margin (mm)": "Rand (mm)",
|
||||
"Columns": "Spalten",
|
||||
"Rows": "Zeilen",
|
||||
"Gap (mm)": "Abstand (mm)",
|
||||
"Template body": "Vorlageninhalt",
|
||||
"Revision history": "Revisionsverlauf",
|
||||
"Output history": "Ausgabeverlauf",
|
||||
"Validated sample input": "Validierte Beispieldaten",
|
||||
"Usage": "Verwendung",
|
||||
"Output": "Ausgabe",
|
||||
"Store in Files when available": "Wenn verfügbar in Dateien speichern",
|
||||
"Validate and preview": "Validieren und Vorschau erzeugen",
|
||||
"Render final output": "Endgültige Ausgabe erzeugen",
|
||||
"Render evidence": "Ausgabenachweis",
|
||||
"Download": "Herunterladen",
|
||||
"Delete template?": "Vorlage löschen?"
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
+11
-2
@@ -1,5 +1,6 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/templates.css";
|
||||
|
||||
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||
@@ -14,12 +15,19 @@ const readScopes = [
|
||||
|
||||
export const templatesModule: PlatformWebModule = {
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
label: "i18n:govoplan-templates.templates",
|
||||
version: "0.1.14",
|
||||
optionalDependencies: ["files", "dist_lists", "campaigns", "audit"],
|
||||
translations: generatedTranslations,
|
||||
viewSurfaces: [
|
||||
{ id: "templates.page", moduleId: "templates", kind: "route", label: "i18n:govoplan-templates.templates", order: 75 },
|
||||
{ id: "templates.library", moduleId: "templates", kind: "section", label: "i18n:govoplan-templates.library", parentId: "templates.page", order: 10 },
|
||||
{ id: "templates.editor", moduleId: "templates", kind: "section", label: "i18n:govoplan-templates.editor", parentId: "templates.page", order: 20 },
|
||||
{ id: "templates.preview", moduleId: "templates", kind: "section", label: "i18n:govoplan-templates.preview", parentId: "templates.page", order: 30 }
|
||||
],
|
||||
navItems: [{
|
||||
to: "/templates",
|
||||
label: "Templates",
|
||||
label: "i18n:govoplan-templates.templates",
|
||||
iconName: "layout-template",
|
||||
anyOf: readScopes,
|
||||
order: 75
|
||||
@@ -28,6 +36,7 @@ export const templatesModule: PlatformWebModule = {
|
||||
path: "/templates",
|
||||
anyOf: readScopes,
|
||||
order: 75,
|
||||
surfaceId: "templates.page",
|
||||
render: ({ settings, auth }) => createElement(TemplatesPage, { settings, auth })
|
||||
}]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user