Files
govoplan-campaign/webui/src/features/campaigns/TemplateDataPage.tsx
T

1003 lines
50 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import {
listCampaignContentLibrary,
listCampaignPrintTemplates,
previewCampaignAttachments,
saveCampaignContentLibraryItem,
type CampaignAttachmentPreviewRule,
type CampaignContentLibraryItem,
type CampaignContentLibraryResponse,
type CampaignContentLibraryTarget,
type CampaignPrintTemplate
} from "../../api/campaigns";
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
import { Card } 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";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView";
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);
const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]);
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
const [printTemplatesAvailable, setPrintTemplatesAvailable] = useState(false);
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
const [printTemplatesError, setPrintTemplatesError] = useState("");
const [contentLibraryOpen, setContentLibraryOpen] = useState(false);
const [contentLibraryQuery, setContentLibraryQuery] = useState("");
const [contentLibrary, setContentLibrary] = useState<CampaignContentLibraryResponse | null>(null);
const [contentLibraryLoading, setContentLibraryLoading] = useState(false);
const [contentLibraryError, setContentLibraryError] = useState("");
const [contentSaveOpen, setContentSaveOpen] = useState(false);
const [contentSaveBusy, setContentSaveBusy] = useState(false);
const [contentSaveName, setContentSaveName] = useState("");
const [contentSaveDescription, setContentSaveDescription] = useState("");
const [contentSaveKind, setContentSaveKind] = useState<ContentLibrarySaveKind>("fragment");
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);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "template",
unsavedTitle: "i18n:govoplan-campaign.unsaved_template_changes.4209cdec",
unsavedMessage: "i18n:govoplan-campaign.the_template_has_unsaved_changes_save_them_befor.89f74fc1",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a",
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const delivery = asRecord(displayDraft.delivery);
const printConfig = asRecord(delivery.print);
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
const contentSaveSelectedValue = getText(template, contentSaveTarget);
const contentSaveHasBody = Boolean(getText(template, "text").trim() || getText(template, "html").trim());
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
const builtInAddressNames = useMemo(() => recipientAddressTemplateFieldOptions().map((field) => field.name), []);
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);
}, [localFieldNames]);
const globalFieldOptions = useMemo(() => globalFieldNames.map((name) => ({ name, label: name })), [globalFieldNames]);
const entries = asRecord(displayDraft.entries);
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
const activePreviewEntries = useMemo(
() => inlineEntries.map((entry, sourceIndex) => ({ entry, sourceIndex: sourceIndex + 1 })).filter(({ entry }) => entry.active !== false),
[inlineEntries]
);
const previewEntries = activePreviewEntries.length > 0 ? activePreviewEntries : [{ entry: {}, sourceIndex: 0 }];
const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0];
const previewEntry = previewSelection.entry;
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
const templateText = [
getText(template, "subject"),
templateBodyMode !== "html" ? getText(template, "text") : "",
templateBodyMode !== "text" ? getText(template, "html") : ""].
join("\n");
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(
() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames, { local: localAvailableNames, global: globalAvailableNames }),
[usedPlaceholders, allAvailableNames, globalAvailableNames, localAvailableNames]
);
const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
const selectedPreviewEntryIndex = previewSelection.sourceIndex;
const previewAttachmentRules = useMemo(
() => attachmentPreviewRules.filter((rule) => rule.entry_index === selectedPreviewEntryIndex),
[attachmentPreviewRules, selectedPreviewEntryIndex]
);
const previewAttachments = useMemo(
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError, displayDraft),
[attachmentPreviewError, attachmentPreviewLoading, displayDraft, previewAttachmentRules]
);
useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
useEffect(() => {
if (templateBodyMode === "text" && activeBodyEditor !== "text") setActiveBodyEditor("text");
if (templateBodyMode === "html" && activeBodyEditor !== "html") setActiveBodyEditor("html");
if (activeEditor !== "subject" && activeEditor !== visibleBodyEditor) setActiveEditor(visibleBodyEditor);
}, [activeBodyEditor, activeEditor, templateBodyMode, visibleBodyEditor]);
useEffect(() => {
if (!previewOpen || !version?.id || !draft) return;
let cancelled = false;
setAttachmentPreviewLoading(true);
setAttachmentPreviewError("");
const handle = window.setTimeout(() => {
void previewCampaignAttachments(settings, campaignId, version.id, {
include_unmatched: false,
include_unlinked_candidates: true,
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
}).
then((response) => {
if (!cancelled) setAttachmentPreviewRules(response.rules);
}).
catch((reason: unknown) => {
if (!cancelled) {
setAttachmentPreviewRules([]);
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
}
}).
finally(() => {
if (!cancelled) setAttachmentPreviewLoading(false);
});
}, 120);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]);
useEffect(() => {
let cancelled = false;
setPrintTemplatesLoading(true);
setPrintTemplatesError("");
void listCampaignPrintTemplates(settings, campaignId)
.then((response) => {
if (cancelled) return;
setPrintTemplatesAvailable(response.available);
setPrintTemplates(response.templates);
})
.catch((reason: unknown) => {
if (cancelled) return;
setPrintTemplatesAvailable(false);
setPrintTemplates([]);
setPrintTemplatesError(reason instanceof Error ? reason.message : String(reason));
})
.finally(() => {
if (!cancelled) setPrintTemplatesLoading(false);
});
return () => { cancelled = true; };
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!contentLibraryOpen) return;
let cancelled = false;
setContentLibraryLoading(true);
setContentLibraryError("");
const handle = window.setTimeout(() => {
void listCampaignContentLibrary(settings, campaignId, contentLibraryQuery)
.then((response) => {
if (!cancelled) setContentLibrary(response);
})
.catch((reason: unknown) => {
if (!cancelled) {
setContentLibrary(null);
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
}
})
.finally(() => {
if (!cancelled) setContentLibraryLoading(false);
});
}, 180);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [campaignId, contentLibraryOpen, contentLibraryQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
}
function patchTemplateBodyMode(mode: TemplateBodyMode) {
if (locked) return;
patch(["template", "body_mode"], mode);
if (mode !== "both") {
setActiveBodyEditor(mode);
if (activeEditor !== "subject") setActiveEditor(mode);
}
}
function patchPrintConfig(values: Record<string, unknown>) {
if (locked) return;
patch(["delivery", "print"], { ...printConfig, ...values });
}
function selectPrintTemplate(templateId: string) {
const selected = printTemplates.find((item) => item.id === templateId) ?? null;
const profile = selected?.revision?.output_profiles[0] ?? null;
patchPrintConfig({
template_id: selected?.id ?? null,
template_revision: selected?.revision?.revision ?? selected?.current_revision ?? null,
output_format: profile?.output_format ?? getText(printConfig, "output_format", "html"),
profile_id: profile?.id ?? null
});
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
const token = `{{${namespace}:${name}}}`;
if (target === "html") {
htmlRef.current?.insertToken({ value: token, label: `${namespace}:${name}` });
return;
}
const element = target === "subject" ? subjectRef.current : textRef.current;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
const nextText = `${currentText.slice(0, start)}${token}${currentText.slice(end)}`;
patchTemplateText(target, nextText);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + token.length;
element?.setSelectionRange(cursor, cursor);
});
}
function addUndefinedField(field: UndefinedPlaceholder) {
if (!draft || locked || !field.name) return;
const existingFields = asArray(draft.fields).map(asRecord);
const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name);
if (!alreadyDefined) {
patch(["fields"], [
...existingFields,
{
name: field.name,
label: humanizeFieldName(field.name),
type: "string",
required: false,
can_override: true
}]
);
}
setUndefinedDialog(null);
}
function removePlaceholder(field: UndefinedPlaceholder) {
if (locked) return;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = removePlaceholderFromText(getText(nextTemplate, "subject"), field.raw);
nextTemplate.text = removePlaceholderFromText(getText(nextTemplate, "text"), field.raw);
nextTemplate.html = removePlaceholderFromText(getText(nextTemplate, "html"), field.raw);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
function replacePlaceholder(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
if (locked) return;
const replacement = `{{${namespace}:${name}}}`;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = replacePlaceholderInText(getText(nextTemplate, "subject"), field.raw, replacement);
nextTemplate.text = replacePlaceholderInText(getText(nextTemplate, "text"), field.raw, replacement);
nextTemplate.html = replacePlaceholderInText(getText(nextTemplate, "html"), field.raw, replacement);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
function openContentSaveDialog() {
setContentSaveName("");
setContentSaveDescription("");
setContentSaveKind("fragment");
setContentSaveTarget(activeEditor === "subject" ? "subject" : visibleBodyEditor);
setContentSaveVisibility("personal");
setContentLibraryError("");
setContentSaveOpen(true);
}
function insertContentFragment(target: CampaignContentLibraryTarget, value: string) {
if (locked || !value) return;
if (target === "html") {
const inserted = htmlRef.current?.insertText(value) ?? false;
if (!inserted) patchTemplateText("html", `${getText(template, "html")}${value}`);
setActiveBodyEditor("html");
setActiveEditor("html");
setContentLibraryOpen(false);
return;
}
const element = target === "subject" ? subjectRef.current : textRef.current;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
patchTemplateText(target, `${currentText.slice(0, start)}${value}${currentText.slice(end)}`);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + value.length;
element?.setSelectionRange(cursor, cursor);
});
if (target === "text") setActiveBodyEditor("text");
setActiveEditor(target);
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) => {
const next = cloneJson(current ?? {});
next.template = {
...asRecord(next.template),
subject: item.subject ?? "",
text: item.text ?? "",
html: item.html ?? "",
body_mode: item.body_mode
};
return next;
});
markDirty();
setActiveBodyEditor(item.body_mode === "html" ? "html" : "text");
setActiveEditor(item.body_mode === "html" ? "html" : "text");
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);
setContentLibraryError("");
try {
const result = await saveCampaignContentLibraryItem(settings, campaignId, {
name: contentSaveName.trim(),
description: contentSaveDescription.trim() || null,
kind: contentSaveKind,
target: contentSaveKind === "fragment" ? contentSaveTarget : null,
subject: getText(template, "subject"),
text: getText(template, "text"),
html: getText(template, "html"),
body_mode: templateBodyMode,
locale: "de",
visibility: contentSaveVisibility
});
setContentSaveOpen(false);
setContentLibraryNotice(`${result.template.name} was saved as an unpublished Templates draft.`);
setContentLibrary(null);
} catch (reason) {
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
} finally {
setContentSaveBusy(false);
}
}
return (
<PageLayout
archetype="editor"
mode="workspace"
title="i18n:govoplan-campaign.template.3ec1ae06"
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
headerLoading={loading}
error={error}
success={contentLibraryNotice}
actions={<PageActionBar
variant="editor"
state={loading ? "saving" : dirty ? "dirty" : "clean"}
contextActions={<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
/>}
notices={(localError || locked) ? <>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
</> : undefined}
>
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<ContentGrid columns={2} collapseAt="workspace" className="template-editor-grid">
<Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
<input
ref={subjectRef}
value={getText(template, "subject")}
disabled={locked}
onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)} />
</FormField>
<FormField label="i18n:govoplan-campaign.message_body_format.5fec42d2">
<SegmentedControl
className="template-body-mode"
size="content"
width="inline"
ariaLabel="i18n:govoplan-campaign.message_body_format.5fec42d2"
value={templateBodyMode}
disabled={locked}
onChange={patchTemplateBodyMode}
options={[
{ id: "text", label: "i18n:govoplan-campaign.text_only.9ccbd022" },
{ id: "html", label: "i18n:govoplan-campaign.html_only.1c4fbcb1" },
{ id: "both", label: "i18n:govoplan-campaign.both.1f469838" }
]}
/>
</FormField>
{templateBodyMode === "both" &&
<SegmentedControl
className="template-body-mode template-editor-mode"
size="content"
width="inline"
ariaLabel="i18n:govoplan-campaign.body_editor.8615dd7e"
value={visibleBodyEditor}
onChange={(mode) => {setActiveBodyEditor(mode);setActiveEditor(mode);}}
options={[
{ id: "text", label: "i18n:govoplan-campaign.plain_text.9580fcbc" },
{ id: "html", label: "i18n:govoplan-campaign.html.9f738ce8" }
]}
/>
}
{visibleBodyEditor === "text" &&
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => {setActiveBodyEditor("text");setActiveEditor("text");}}
onChange={(event) => patchTemplateText("text", event.target.value)} />
</FormField>
}
{visibleBodyEditor === "html" &&
<div className="form-field">
<FieldLabel className="form-label">i18n:govoplan-campaign.html_body.77b5ba37</FieldLabel>
<WysiwygEditor
ref={htmlRef}
value={getText(template, "html")}
disabled={locked}
ariaLabel="i18n:govoplan-campaign.html_body.77b5ba37"
minHeight={344}
sourceRows={16}
onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}}
onChange={(value) => patchTemplateText("html", value)} />
</div>
}
<div className="button-row template-editor-actions">
<Button onClick={() => setContentLibraryOpen(true)} disabled={locked}>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
<Button onClick={openContentSaveDialog} disabled={locked}>i18n:govoplan-campaign.save_to_library.396649bf</Button>
</div>
</FormGrid>
</Card>
<div className="template-side-stack">
<Card title="Printable output">
<LoadingFrame loading={printTemplatesLoading} label="Loading printable templates">
<FormGrid columns={1} collapseAt="standard" className="">
{printTemplatesError && <DismissibleAlert tone="danger" compact resetKey={printTemplatesError}>{printTemplatesError}</DismissibleAlert>}
{!printTemplatesError && !printTemplatesAvailable && (
<DismissibleAlert tone="info" compact dismissible={false}>
Printable delivery becomes available when the Templates module is enabled. Mail-only Campaigns remain unaffected.
</DismissibleAlert>
)}
{printTemplatesAvailable && (
<>
<FormField label="Print template" help="Used for recipients whose frozen route is postal or internal mail, including safe fallbacks.">
<select
value={getText(printConfig, "template_id")}
disabled={locked}
onChange={(event) => selectPrintTemplate(event.target.value)}
>
<option value="">Select a published template</option>
{printTemplates.map((item) => (
<option key={item.id} value={item.id} disabled={!item.published_revision_id}>
{item.name} · {item.template_type} · r{item.revision?.revision ?? item.current_revision}
</option>
))}
</select>
</FormField>
{selectedPrintTemplate && (
<p className="muted small-note">
{selectedPrintTemplate.description || `${selectedPrintTemplate.template_type} template`}
{selectedPrintTemplate.revision?.required_fields.length
? ` · Required fields: ${selectedPrintTemplate.revision.required_fields.map((field) => field.label || field.path).join(", ")}`
: " · No additional fields required"}
</p>
)}
<FormField label="Output format">
<SegmentedControl
ariaLabel="Printable output format"
value={getText(printConfig, "output_format", "html") as "html" | "text"}
disabled={locked || !selectedPrintTemplate}
size="content"
width="inline"
onChange={(outputFormat) => patchPrintConfig({ output_format: outputFormat })}
options={[
{ id: "html", label: "HTML" },
{ id: "text", label: "Text" }
]}
/>
</FormField>
<ToggleSwitch
label="Store generated output in Files"
checked={getBool(printConfig, "persist_to_files", true)}
disabled={locked || !selectedPrintTemplate}
onChange={(checked) => patchPrintConfig({ persist_to_files: checked })}
/>
</>
)}
</FormGrid>
</LoadingFrame>
</Card>
<Card title="i18n:govoplan-campaign.fields.e8b68527">
{invalidNamespacePlaceholders.length > 0 &&
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
}
{usedPlaceholders.length === 0 && <p className="muted">i18n:govoplan-campaign.no_template_placeholders_detected_yet.56bba9d1</p>}
<p className="muted small-note">i18n:govoplan-campaign.click_a_field_to_insert_it_at_the_current_cursor.643aa7bc</p>
<h3 className="section-mini-heading">i18n:govoplan-campaign.global_fields.07f84ea4</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_defined.3888623d"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">i18n:govoplan-campaign.local_fields.c81bb4de</h3>
<p className="muted small-note">i18n:govoplan-campaign.address_fields_use_the_effective_merged_or_overr.d6930da6 <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_a_specific_additional_address.1199883f</p>
<TemplateFieldChipList
namespace="local"
fields={localFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="i18n:govoplan-campaign.no_campaign_fields_defined.8c2ea4b2"
onInsert={insertPlaceholder} />
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_in_template_but_undefined.57b5f3da</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
</ContentGrid>
</>
</LoadingFrame>
{previewOpen &&
<CampaignMessagePreviewOverlay
title="i18n:govoplan-campaign.template_preview.ea0aa8b2"
bodyMode={visibleBodyEditor}
subject={previewSubject}
text={templateBodyMode === "html" ? null : previewText}
html={templateBodyMode === "text" ? null : previewHtml}
metaItems={templatePreviewMetaItems(previewContext)}
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "i18n:govoplan-campaign.global_preview.f81f09b2"}
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "i18n:govoplan-campaign.no_recipient_preview_is_available.9e93a97d" : "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb"}
attachments={previewAttachments}
navigation={{
index: Math.min(previewIndex, previewEntries.length - 1),
total: previewEntries.length,
onFirst: () => setPreviewIndex(0),
onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)),
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
onLast: () => setPreviewIndex(previewEntries.length - 1)
}}
onClose={() => setPreviewOpen(false)} />
}
<Dialog
open={contentLibraryOpen}
title="Reusable content"
className="campaign-content-library-dialog"
helpContextId="campaign.template.content-library"
onClose={() => setContentLibraryOpen(false)}
footer={<>
<Button onClick={() => window.location.assign("/templates")}>Manage Templates</Button>
<Button variant="primary" onClick={() => setContentLibraryOpen(false)}>Close</Button>
</>}
>
<div className="campaign-content-library">
<FormField label="Search library" help="Searches content published or visible to your Templates scope.">
<input
value={contentLibraryQuery}
onChange={(event) => setContentLibraryQuery(event.target.value)}
placeholder="Name or description"
autoFocus
/>
</FormField>
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
<LoadingFrame loading={contentLibraryLoading} label="Loading reusable content">
<div className="campaign-content-library-list">
{!contentLibraryError && contentLibrary && !contentLibrary.available && (
<DismissibleAlert tone="info" dismissible={false}>{contentLibrary.reason || "Enable Templates to use reusable content."}</DismissibleAlert>
)}
{!contentLibraryError && contentLibrary?.available && contentLibrary.items.length === 0 && (
<p className="muted">No reusable Campaign content matches this search.</p>
)}
{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>
<span>{item.published ? `Published r${item.revision}` : `Draft r${item.revision}`}</span>
</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={() => requestCampaignPart(item)}
title="Replaces the current subject and body fields in this draft"
>Apply part</Button>
) : item.targets.map((target) => {
const value = target === "html" ? item.html : item.text;
return (
<Button
key={target}
disabled={locked || !value}
onClick={() => value && requestContentFragment(item, target, value)}
>Insert in {target}</Button>
);
})}
</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"
size="large"
helpContextId="campaign.template.content-library"
closeDisabled={contentSaveBusy}
onClose={() => setContentSaveOpen(false)}
footer={<>
<Button onClick={() => setContentSaveOpen(false)} disabled={contentSaveBusy}>Cancel</Button>
<Button
variant="primary"
onClick={() => void saveReusableContent()}
disabled={
contentSaveBusy ||
!contentSaveName.trim() ||
(contentSaveKind === "fragment" ? !contentSaveSelectedValue.trim() : !contentSaveHasBody)
}
>{contentSaveBusy ? "Saving..." : "Save draft"}</Button>
</>}
>
<div className="campaign-content-save-form">
<DismissibleAlert tone="info" compact dismissible={false}>
Saving creates an unpublished Templates draft. Publication and later revisions remain governed in Templates.
</DismissibleAlert>
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
<div className="campaign-content-save-identity">
<FormField label="Name">
<input value={contentSaveName} onChange={(event) => setContentSaveName(event.target.value)} autoFocus />
</FormField>
<FormField label="Visibility" help="Personal drafts are visible to you; tenant drafts are available to authorized template users.">
<SegmentedControl
ariaLabel="Template visibility"
value={contentSaveVisibility}
onChange={setContentSaveVisibility}
size="content"
width="inline"
options={[
{ id: "personal", label: "Personal" },
{ id: "tenant", label: "Tenant" }
]}
/>
</FormField>
</div>
<FormField label="Description">
<textarea rows={3} value={contentSaveDescription} onChange={(event) => setContentSaveDescription(event.target.value)} />
</FormField>
<FormField label="Content kind">
<SegmentedControl
ariaLabel="Reusable content kind"
value={contentSaveKind}
onChange={setContentSaveKind}
size="content"
width="inline"
options={[
{ id: "fragment", label: "Fragment" },
{ id: "campaign_part", label: "Complete part" }
]}
/>
</FormField>
{contentSaveKind === "fragment" && (
<FormField label="Source field" help="The selected current field becomes the reusable fragment.">
<SegmentedControl
ariaLabel="Fragment source field"
value={contentSaveTarget}
onChange={setContentSaveTarget}
size="content"
width="inline"
options={[
{ id: "subject", label: "Subject" },
{ id: "text", label: "Text" },
{ id: "html", label: "HTML" }
]}
/>
</FormField>
)}
<p className="muted small-note">
{contentSaveKind === "fragment"
? `${contentSaveSelectedValue.length.toLocaleString()} characters from ${contentSaveTarget}.`
: "Subject, text, HTML, and body mode are stored as one reusable Campaign part."}
</p>
</div>
</Dialog>
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="template"
removeLabel="i18n:govoplan-campaign.remove_from_template.5fb2827b"
onCancel={() => setUndefinedDialog(null)}
onRemove={removePlaceholder}
onReplace={replacePlaceholder}
onAddField={addUndefinedField}
localFields={localFieldOptions}
globalFields={globalFieldOptions} />
</PageLayout>);
}
function mapResolvedAttachmentsToPreviewBoxes(
rules: CampaignAttachmentPreviewRule[],
loading: boolean,
error: string,
draft: Record<string, unknown>)
: CampaignMessagePreviewAttachment[] {
if (loading) {
return [{
filename: "Resolving attachment patterns",
detail: "Managed files are being checked for this recipient preview."
}];
}
if (error) {
return [{ filename: "Attachment preview unavailable", detail: error }];
}
return rules.flatMap((rule) => {
const zipProtection = zipProtectionForRule(rule, draft);
const detailParts = [
rule.source === "global" ? "Global" : "Recipient",
rule.label,
rule.required ? "Required" : "Optional",
rule.pattern].
filter(Boolean);
const detail = detailParts.join(" · ");
const fallbackArchiveLabel = "Recipient attachments ZIP";
if (rule.matches.length > 0) {
return rule.matches.map((match) => ({
filename: match.filename || match.display_path,
label: rule.label,
detail: `${match.linked_to_campaign === false ? "Unlinked candidate" : "Linked"} · ${match.display_path ? `${detail} · ${match.display_path}` : detail}`,
contentType: match.content_type,
sizeBytes: match.size_bytes,
linkedToCampaign: match.linked_to_campaign !== false,
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
const pattern = rule.pattern || "attachment pattern";
return [{
filename: `No file matched ${pattern}`,
label: rule.label,
detail: rule.status !== "ok" ? `${detail} · ${rule.status}` : detail,
archiveGroup: null,
archiveLabel: null,
protected: false,
protectionNote: null
}];
});
}
function templatePreviewMetaItems(context: Record<string, string>) {
return [
{ label: "i18n:govoplan-campaign.from.3f66052a", value: context["local:from"] || null },
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: context["local:all_to"] || null },
{ label: "i18n:govoplan-campaign.reply_to.c1733667", value: context["local:all_reply_to"] || null },
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: context["local:all_cc"] || null },
{ label: "i18n:govoplan-campaign.bcc.8431acad", value: context["local:all_bcc"] || null }];
}
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): {protected: boolean;note: string | null;} {
if (!rule.zip_included) return { protected: false, note: null };
const zipConfig = asRecord(asRecord(draft.attachments).zip);
const archives = asArray(zipConfig.archives).map(asRecord);
const archive = archives.find((item) => {
const id = getText(item, "id");
const name = getText(item, "name", getText(item, "filename_template"));
return rule.zip_archive_id && id === rule.zip_archive_id || rule.zip_filename && name === rule.zip_filename;
}) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0];
if (!archive) return { protected: false, note: null };
const legacyMode = getText(archive, "password_mode");
const protectedArchive = getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode));
if (!protectedArchive) return { protected: false, note: null };
const field = getText(archive, "password_field");
const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
const method = getText(archive, "method", "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
return {
protected: true,
note: source ?
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
};
}
function humanizeScope(scope: string): string {
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return i18nMessage("i18n:govoplan-campaign.recipient_value.d0233fb6", { value0: index + 1 });
}
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
if (value === "text" || value === "html" || value === "both") return value;
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();
}