feat(campaign): complete reusable template workflow
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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]}`);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user