|
|
|
@@ -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();
|
|
|
|
|
}
|
|
|
|
|