feat(campaign): complete reusable template workflow

This commit is contained in:
2026-08-19 21:56:08 +02:00
parent 14e94873a9
commit b6af7665f4
11 changed files with 294 additions and 14 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
},
"scripts": {
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js && node tests/content-library-ui-structure.test.mjs",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
+7
View File
@@ -513,6 +513,13 @@ export type CampaignContentLibraryItem = {
text?: string | null;
html?: string | null;
body_mode: "text" | "html" | "both";
required_fields: Array<{
path: string;
value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "object" | "array";
label?: string | null;
required: boolean;
description?: string | null;
}>;
};
export type CampaignContentLibraryResponse = {
@@ -13,7 +13,7 @@ import {
} from "../../api/campaigns";
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { ConfirmDialog, Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
@@ -31,11 +31,19 @@ import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
import { campaignContentFieldType, checkContentLibraryCompatibility, type ContentLibraryCompatibility } from "./utils/contentLibraryCompatibility";
type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
type ContentLibrarySaveKind = "fragment" | "campaign_part";
type PendingContentApply = {
item: CampaignContentLibraryItem;
target?: CampaignContentLibraryTarget;
value?: string;
overwrites: boolean;
compatibility: ContentLibraryCompatibility;
};
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
@@ -64,6 +72,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const [contentSaveTarget, setContentSaveTarget] = useState<CampaignContentLibraryTarget>("text");
const [contentSaveVisibility, setContentSaveVisibility] = useState<"personal" | "tenant">("personal");
const [contentLibraryNotice, setContentLibraryNotice] = useState("");
const [pendingContentApply, setPendingContentApply] = useState<PendingContentApply | null>(null);
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
@@ -98,6 +107,25 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]);
const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]);
const contentAvailableFields = useMemo(() => {
const available = new Map<string, string>();
for (const field of fields) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
const valueType = campaignFieldDefinitionType(String(field.type || "string"));
available.set(name, valueType);
available.set(`local:${name}`, valueType);
available.set(`global:${name}`, valueType);
}
for (const [name, value] of Object.entries(asRecord(displayDraft.global_values))) {
const valueType = available.get(name) ?? campaignContentFieldType(value);
available.set(name, valueType);
available.set(`local:${name}`, valueType);
available.set(`global:${name}`, valueType);
}
for (const name of builtInAddressNames) available.set(`local:${name}`, "string");
return available;
}, [builtInAddressNames, displayDraft.global_values, fields]);
const localFieldOptions = useMemo(() => {
const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))];
return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index);
@@ -363,6 +391,15 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
setContentLibraryOpen(false);
}
function requestContentFragment(item: CampaignContentLibraryItem, target: CampaignContentLibraryTarget, value: string) {
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
if (compatibility.compatible) {
insertContentFragment(target, value);
return;
}
setPendingContentApply({ item, target, value, overwrites: false, compatibility });
}
function applyCampaignPart(item: CampaignContentLibraryItem) {
if (locked) return;
setDraft((current) => {
@@ -382,6 +419,31 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
setContentLibraryOpen(false);
}
function requestCampaignPart(item: CampaignContentLibraryItem) {
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
const overwrites = ["subject", "text", "html"].some((field) => {
const current = getText(template, field);
const replacement = field === "subject" ? item.subject : field === "text" ? item.text : item.html;
return Boolean(current.trim()) && current !== (replacement ?? "");
});
if (!overwrites && compatibility.compatible) {
applyCampaignPart(item);
return;
}
setPendingContentApply({ item, overwrites, compatibility });
}
function confirmContentApply() {
const pending = pendingContentApply;
if (!pending) return;
setPendingContentApply(null);
if (pending.target && pending.value !== undefined) {
insertContentFragment(pending.target, pending.value);
} else {
applyCampaignPart(pending.item);
}
}
async function saveReusableContent() {
if (contentSaveBusy || !contentSaveName.trim()) return;
setContentSaveBusy(true);
@@ -656,8 +718,9 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
{!contentLibraryError && contentLibrary?.available && contentLibrary.items.length === 0 && (
<p className="muted">No reusable Campaign content matches this search.</p>
)}
{contentLibrary?.items.map((item) => (
<div className="campaign-content-library-item" key={`${item.id}:${item.revision_id}`}>
{contentLibrary?.items.map((item) => {
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
return <div className="campaign-content-library-item" key={`${item.id}:${item.revision_id}`}>
<div className="campaign-content-library-item-copy">
<div className="campaign-content-library-item-title">
<strong>{item.name}</strong>
@@ -665,13 +728,20 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
</div>
{item.description && <p>{item.description}</p>}
<small>{item.kind === "fragment" ? "Content fragment" : "Complete campaign part"} · {item.scope_type} · {item.locale || "unspecified locale"}</small>
{!compatibility.compatible && (
<div className="campaign-content-library-compatibility" role="status">
<strong>Field compatibility needs attention.</strong>
{compatibility.missing.length > 0 && <span>Missing: {compatibility.missing.map((field) => field.label).join(", ")}.</span>}
{compatibility.incompatible.length > 0 && <span>Wrong type: {compatibility.incompatible.map((field) => `${field.label} (${field.actual} instead of ${field.expected})`).join(", ")}.</span>}
</div>
)}
</div>
<div className="button-row campaign-content-library-item-actions">
{item.kind === "campaign_part" ? (
<Button
variant="primary"
disabled={locked}
onClick={() => applyCampaignPart(item)}
onClick={() => requestCampaignPart(item)}
title="Replaces the current subject and body fields in this draft"
>Apply part</Button>
) : item.targets.map((target) => {
@@ -680,18 +750,29 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
<Button
key={target}
disabled={locked || !value}
onClick={() => value && insertContentFragment(target, value)}
onClick={() => value && requestContentFragment(item, target, value)}
>Insert in {target}</Button>
);
})}
</div>
</div>
))}
</div>;
})}
</div>
</LoadingFrame>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingContentApply)}
title={pendingContentApply?.overwrites ? "Replace current Campaign content?" : "Apply content with field mismatches?"}
message={contentApplyConfirmationMessage(pendingContentApply)}
confirmLabel={pendingContentApply?.overwrites ? "Replace content" : "Apply content"}
cancelLabel="Cancel"
helpContextId="campaign.template.content-library"
onConfirm={confirmContentApply}
onCancel={() => setPendingContentApply(null)}
/>
<Dialog
open={contentSaveOpen}
title="Save reusable content"
@@ -895,6 +976,27 @@ function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
return "both";
}
function campaignFieldDefinitionType(value: string): string {
if (value === "double") return "number";
if (value === "integer" || value === "date") return value;
return "string";
}
function contentApplyConfirmationMessage(pending: PendingContentApply | null): string {
if (!pending) return "";
const messages = pending.overwrites
? [`Applying ${pending.item.name} replaces the current subject and body in this editable Campaign draft.`]
: [`Applying ${pending.item.name} inserts content into this editable Campaign draft.`];
if (pending.compatibility.missing.length > 0) {
messages.push(`Missing required fields: ${pending.compatibility.missing.map((field) => field.label).join(", ")}.`);
}
if (pending.compatibility.incompatible.length > 0) {
messages.push(`Fields with incompatible types: ${pending.compatibility.incompatible.map((field) => `${field.label} (${field.actual} instead of ${field.expected})`).join(", ")}.`);
}
messages.push("Review the resulting placeholders before saving the Campaign draft.");
return messages.join(" ");
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}
@@ -0,0 +1,75 @@
export type ContentFieldRequirement = {
path: string;
value_type: string;
label?: string | null;
required: boolean;
};
export type ContentFieldMismatch = {
path: string;
label: string;
expected: string;
actual?: string;
};
export type ContentLibraryCompatibility = {
compatible: boolean;
missing: ContentFieldMismatch[];
incompatible: ContentFieldMismatch[];
};
export function checkContentLibraryCompatibility(
requirements: readonly ContentFieldRequirement[],
availableFields: ReadonlyMap<string, string>
): ContentLibraryCompatibility {
const missing: ContentFieldMismatch[] = [];
const incompatible: ContentFieldMismatch[] = [];
for (const requirement of requirements) {
if (!requirement.required || !requirement.path.trim()) continue;
const path = normalizeContentFieldPath(requirement.path);
const actual = availableFields.get(path) ?? recipientAddressRequirementType(path, availableFields);
const mismatch = {
path,
label: requirement.label?.trim() || path,
expected: requirement.value_type || "string"
};
if (!actual) {
missing.push(mismatch);
} else if (!fieldTypesAreCompatible(mismatch.expected, actual)) {
incompatible.push({ ...mismatch, actual });
}
}
return {
compatible: missing.length === 0 && incompatible.length === 0,
missing,
incompatible
};
}
export function normalizeContentFieldPath(path: string): string {
return path.trim()
.replace(/^fields\./, "")
.replace(/^local\./, "local:")
.replace(/^global\./, "global:")
.replace(/^local::/, "local:")
.replace(/^global::/, "global:");
}
export function campaignContentFieldType(value: unknown): string {
if (Array.isArray(value)) return "array";
if (value && typeof value === "object") return "object";
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
return "string";
}
function fieldTypesAreCompatible(expected: string, actual: string): boolean {
if (actual === "unknown" || expected === actual) return true;
return expected === "number" && (actual === "integer" || actual === "number");
}
function recipientAddressRequirementType(path: string, availableFields: ReadonlyMap<string, string>): string | undefined {
const match = /^local:(all_)?(from|to|reply_to|cc|bcc)(?:\.|$)/.exec(path);
if (!match) return undefined;
return availableFields.get(`local:${match[1] ?? ""}${match[2]}`);
}
+1
View File
@@ -2736,6 +2736,7 @@
.campaign-content-library-item-title span { color: var(--muted); font-size: var(--font-size-sm); }
.campaign-content-library-item-actions { justify-content: flex-end; }
.campaign-content-save-identity { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; }
.campaign-content-library-compatibility { display: grid; gap: 3px; margin-top: 8px; color: var(--warning-text); font-size: .875rem; }
.campaign-content-save-form > p { margin: 0; }
@media (max-width: 760px) {
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/features/campaigns/TemplateDataPage.tsx", import.meta.url), "utf8");
assert.match(source, /checkContentLibraryCompatibility\(item\.required_fields, contentAvailableFields\)/);
assert.match(source, /Field compatibility needs attention\./);
assert.match(source, /requestCampaignPart\(item\)/);
assert.match(source, /<ConfirmDialog[\s\S]*Replace current Campaign content\?/);
assert.doesNotMatch(source, /onClick=\{\(\) => applyCampaignPart\(item\)\}/);
console.log("Campaign content-library compatibility and overwrite-confirmation contract passed.");
@@ -1,4 +1,5 @@
import { campaignJsonForAttachmentPreview } from "../src/features/campaigns/utils/templatePreviewDraft";
import { checkContentLibraryCompatibility } from "../src/features/campaigns/utils/contentLibraryCompatibility";
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
@@ -52,3 +53,18 @@ assert(to.length === 1 && to[0].email === "alice@example.org", "empty recipient
assert(defaults.email === undefined, "empty defaults email is stripped");
assert(Array.isArray(defaults.to) && (defaults.to as unknown[]).length === 0, "empty defaults recipients are removed");
assert((asRecord(draft.entries).inline as Record<string, unknown>[])[0].email === "", "original draft is not mutated");
const compatibility = checkContentLibraryCompatibility([
{ path: "local::display_name", value_type: "string", label: "Display name", required: true },
{ path: "global.case_reference", value_type: "string", label: "Case reference", required: true },
{ path: "count", value_type: "number", label: "Count", required: true },
{ path: "local.to.2.email", value_type: "string", label: "Second recipient", required: true },
{ path: "optional", value_type: "string", required: false }
], new Map([
["local:display_name", "string"],
["local:to", "string"],
["count", "boolean"]
]));
assert(!compatibility.compatible, "missing or wrong-typed fields are incompatible");
assert(compatibility.missing.length === 1 && compatibility.missing[0].path === "global:case_reference", "namespaced missing fields are normalized");
assert(compatibility.incompatible.length === 1 && compatibility.incompatible[0].actual === "boolean", "type mismatches are reported");
+2 -1
View File
@@ -18,6 +18,7 @@
},
"include": [
"tests/template-preview-draft.test.ts",
"src/features/campaigns/utils/templatePreviewDraft.ts"
"src/features/campaigns/utils/templatePreviewDraft.ts",
"src/features/campaigns/utils/contentLibraryCompatibility.ts"
]
}