Release v0.1.2
This commit is contained in:
@@ -1,29 +1,31 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
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, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
||||
|
||||
type BodyMode = "text" | "html";
|
||||
type TemplateBodyMode = "text" | "html" | "both";
|
||||
type BodyEditorMode = "text" | "html";
|
||||
type EditorTarget = "subject" | "text" | "html";
|
||||
|
||||
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
|
||||
const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
|
||||
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
@@ -51,6 +53,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
onLoaded: () => setPreviewIndex(0)
|
||||
});
|
||||
const template = asRecord(displayDraft.template);
|
||||
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
|
||||
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]);
|
||||
@@ -73,7 +77,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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")}\n${getText(template, "text")}\n${getText(template, "html")}`;
|
||||
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(
|
||||
@@ -90,8 +98,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
[attachmentPreviewRules, selectedPreviewEntryIndex]
|
||||
);
|
||||
const previewAttachments = useMemo(
|
||||
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
|
||||
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
|
||||
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError, displayDraft),
|
||||
[attachmentPreviewError, attachmentPreviewLoading, displayDraft, previewAttachmentRules]
|
||||
);
|
||||
|
||||
|
||||
@@ -99,6 +107,12 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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;
|
||||
@@ -107,7 +121,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
const handle = window.setTimeout(() => {
|
||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||
include_unmatched: false,
|
||||
campaign_json: displayDraft
|
||||
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
||||
})
|
||||
.then((response) => {
|
||||
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
||||
@@ -133,9 +147,18 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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 insertPlaceholder(namespace: TemplateNamespace, name: string) {
|
||||
if (locked) return;
|
||||
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
|
||||
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
|
||||
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
|
||||
const token = `{{${namespace}:${name}}}`;
|
||||
const currentText = getText(template, target);
|
||||
@@ -217,30 +240,39 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
onChange={(event) => patchTemplateText("subject", event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
|
||||
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
|
||||
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
|
||||
</div>
|
||||
{bodyMode === "text" && (
|
||||
<FormField label="Message body format">
|
||||
<div className="template-body-mode" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={templateBodyMode === "text" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("text")}>Text only</button>
|
||||
<button type="button" className={templateBodyMode === "html" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("html")}>HTML only</button>
|
||||
<button type="button" className={templateBodyMode === "both" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("both")}>Both</button>
|
||||
</div>
|
||||
</FormField>
|
||||
{templateBodyMode === "both" && (
|
||||
<div className="template-body-mode template-editor-mode" role="tablist" aria-label="Body editor">
|
||||
<button type="button" className={visibleBodyEditor === "text" ? "active" : ""} onClick={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}>Plain text</button>
|
||||
<button type="button" className={visibleBodyEditor === "html" ? "active" : ""} onClick={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}>HTML</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleBodyEditor === "text" && (
|
||||
<FormField label="Plain text body">
|
||||
<textarea
|
||||
ref={textRef}
|
||||
rows={16}
|
||||
value={getText(template, "text")}
|
||||
disabled={locked}
|
||||
onFocus={() => setActiveEditor("text")}
|
||||
onFocus={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}
|
||||
onChange={(event) => patchTemplateText("text", event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{bodyMode === "html" && (
|
||||
{visibleBodyEditor === "html" && (
|
||||
<FormField label="HTML body">
|
||||
<textarea
|
||||
ref={htmlRef}
|
||||
rows={16}
|
||||
value={getText(template, "html")}
|
||||
disabled={locked}
|
||||
onFocus={() => setActiveEditor("html")}
|
||||
onFocus={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}
|
||||
onChange={(event) => patchTemplateText("html", event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -285,19 +317,17 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
{previewOpen && (
|
||||
<MessagePreviewOverlay
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="Template preview"
|
||||
bodyMode={bodyMode}
|
||||
bodyMode={visibleBodyEditor}
|
||||
subject={previewSubject}
|
||||
text={previewText}
|
||||
html={previewHtml}
|
||||
text={templateBodyMode === "html" ? null : previewText}
|
||||
html={templateBodyMode === "text" ? null : previewHtml}
|
||||
metaItems={templatePreviewMetaItems(previewContext)}
|
||||
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
|
||||
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
|
||||
attachments={previewAttachments}
|
||||
@@ -328,8 +358,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
function mapResolvedAttachmentsToPreviewBoxes(
|
||||
rules: CampaignAttachmentPreviewRule[],
|
||||
loading: boolean,
|
||||
error: string
|
||||
): MessagePreviewAttachment[] {
|
||||
error: string,
|
||||
draft: Record<string, unknown>
|
||||
): CampaignMessagePreviewAttachment[] {
|
||||
if (loading) {
|
||||
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
|
||||
}
|
||||
@@ -337,6 +368,7 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
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,
|
||||
@@ -352,7 +384,9 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
contentType: match.content_type,
|
||||
sizeBytes: match.size_bytes,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
@@ -360,12 +394,46 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
label: rule.label,
|
||||
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function templatePreviewMetaItems(context: Record<string, string>) {
|
||||
return [
|
||||
{ label: "From", value: context["local:from"] || null },
|
||||
{ label: "To", value: context["local:all_to"] || null },
|
||||
{ label: "Reply-To", value: context["local:all_reply_to"] || null },
|
||||
{ label: "Cc", value: context["local:all_cc"] || null },
|
||||
{ label: "Bcc", 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" ? "ZipCrypto" : "AES";
|
||||
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
|
||||
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global" ? "Global" : "Local";
|
||||
}
|
||||
|
||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
const name = valueToPreview(entry.name).trim();
|
||||
@@ -376,6 +444,11 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
return `Recipient ${index + 1}`;
|
||||
}
|
||||
|
||||
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
|
||||
if (value === "text" || value === "html" || value === "both") return value;
|
||||
return "both";
|
||||
}
|
||||
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user