feat(campaign): complete reusable template workflow
This commit is contained in:
@@ -199,7 +199,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
topic_id="campaigns.workflow.reuse-content-library",
|
||||
title="Reuse Campaign content through Templates",
|
||||
summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.",
|
||||
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body in the editable Campaign draft. Saving from Campaign creates a personal or tenant Templates draft and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
|
||||
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body only after explicit confirmation. Required fields are compared with the current Campaign fields and shown before content is applied. Saving from Campaign creates a personal or tenant Templates draft, retains placeholder requirements as its data contract, and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"),
|
||||
@@ -217,7 +217,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
),
|
||||
steps=(
|
||||
"Open Template and choose Load from library to search content visible in the active Templates scope.",
|
||||
"Insert a fragment into its declared subject, text, or HTML target, or explicitly apply a complete Campaign part.",
|
||||
"Review any missing or incompatible required fields, then insert a fragment into its declared subject, text, or HTML target, or explicitly confirm replacement by a complete Campaign part.",
|
||||
"Review placeholders and save the Campaign draft normally.",
|
||||
"To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.",
|
||||
"Open Templates to review, revise, and publish shared content.",
|
||||
@@ -230,7 +230,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
"de": {
|
||||
"title": "Kampagneninhalte über Templates wiederverwenden",
|
||||
"summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.",
|
||||
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext im bearbeitbaren Kampagnenentwurf. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
|
||||
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext erst nach ausdrücklicher Bestätigung. Pflichtfelder werden vor dem Anwenden mit den aktuellen Kampagnenfeldern verglichen und angezeigt. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an, bewahrt Platzhalteranforderungen als Datenvertrag und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
@@ -43,7 +44,11 @@ from govoplan_campaign.backend.schemas import (
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.templates import TemplateContentDraftRequest, TemplateRef
|
||||
from govoplan_core.core.templates import (
|
||||
TemplateContentDraftRequest,
|
||||
TemplateFieldRequirement,
|
||||
TemplateRef,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
@@ -80,6 +85,7 @@ from govoplan_campaign.backend.integrations import (
|
||||
postbox_integration,
|
||||
templates_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.template_rendering import find_unresolved_placeholders
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
@@ -975,9 +981,45 @@ def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]:
|
||||
"text": revision.content_text if revision else None,
|
||||
"html": revision.content_html if revision else None,
|
||||
"body_mode": revision_metadata.get("campaign_body_mode") or "both",
|
||||
"required_fields": [
|
||||
dataclasses.asdict(field) for field in revision.required_fields
|
||||
] if revision else [],
|
||||
}
|
||||
|
||||
|
||||
def _campaign_content_required_fields(
|
||||
payload: CampaignContentLibrarySaveRequest,
|
||||
) -> tuple[TemplateFieldRequirement, ...]:
|
||||
if payload.kind == "fragment":
|
||||
values = (
|
||||
payload.subject if payload.target == "subject"
|
||||
else payload.html if payload.target == "html"
|
||||
else payload.text,
|
||||
)
|
||||
else:
|
||||
values = (
|
||||
payload.subject,
|
||||
payload.text if payload.body_mode != "html" else None,
|
||||
payload.html if payload.body_mode != "text" else None,
|
||||
)
|
||||
paths = {
|
||||
path
|
||||
for value in values
|
||||
for key in find_unresolved_placeholders(value)
|
||||
if (path := _campaign_content_requirement_path(key)) is not None
|
||||
}
|
||||
return tuple(
|
||||
TemplateFieldRequirement(path=path, label=path)
|
||||
for path in sorted(paths)
|
||||
)
|
||||
|
||||
|
||||
def _campaign_content_requirement_path(key: str) -> str | None:
|
||||
path = key.replace("local::", "local.", 1).replace("global::", "global.", 1)
|
||||
path = re.sub(r"\[([1-9][0-9]*)\]", r".\1", path)
|
||||
return path if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", path) else None
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/content-library")
|
||||
def campaign_content_library(
|
||||
campaign_id: str,
|
||||
@@ -1081,6 +1123,7 @@ def save_campaign_content_library_item(
|
||||
content_text=content_text,
|
||||
content_html=content_html,
|
||||
locale=payload.locale,
|
||||
required_fields=_campaign_content_required_fields(payload),
|
||||
scope_type=("user" if payload.visibility == "personal" else "tenant"),
|
||||
scope_id=(
|
||||
principal.account_id if payload.visibility == "personal" else None
|
||||
|
||||
@@ -3,7 +3,10 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_campaign.backend.integrations import TemplatesCampaignIntegration
|
||||
from govoplan_campaign.backend.routes.campaigns import _campaign_content_library_item
|
||||
from govoplan_campaign.backend.routes.campaigns import (
|
||||
_campaign_content_library_item,
|
||||
_campaign_content_required_fields,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import CampaignContentLibrarySaveRequest
|
||||
from govoplan_core.core.templates import (
|
||||
TemplateContentDraftRequest,
|
||||
@@ -109,6 +112,26 @@ def test_campaign_content_payload_keeps_revision_and_declared_target() -> None:
|
||||
assert payload["text"] == "Mit freundlichen Grussen"
|
||||
assert payload["published"] is True
|
||||
assert payload["revision"] == 3
|
||||
assert payload["required_fields"] == []
|
||||
|
||||
|
||||
def test_campaign_content_required_fields_are_normalized_and_deduplicated() -> None:
|
||||
payload = CampaignContentLibrarySaveRequest(
|
||||
name="Greeting",
|
||||
kind="campaign_part",
|
||||
subject="Hello {{ local:display_name }}",
|
||||
text="Reference ${global.case_reference}; hello {{local::display_name}}",
|
||||
html="<p>{{fields.salutation}} {{local:to[2].email}} {{unsupported namespace}}</p>",
|
||||
)
|
||||
|
||||
requirements = _campaign_content_required_fields(payload)
|
||||
|
||||
assert [item.path for item in requirements] == [
|
||||
"global.case_reference",
|
||||
"local.display_name",
|
||||
"local.to.2.email",
|
||||
"salutation",
|
||||
]
|
||||
|
||||
|
||||
def test_content_save_request_rejects_empty_selected_fragment() -> None:
|
||||
|
||||
+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