Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

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