feat: add campaign copying scheduling and residual handling
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
@@ -29,6 +35,7 @@ import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplat
|
||||
type TemplateBodyMode = "text" | "html" | "both";
|
||||
type BodyEditorMode = "text" | "html";
|
||||
type EditorTarget = "subject" | "text" | "html";
|
||||
type ContentLibrarySaveKind = "fragment" | "campaign_part";
|
||||
|
||||
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
@@ -44,6 +51,19 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
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 subjectRef = useRef<HTMLInputElement | null>(null);
|
||||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
|
||||
@@ -68,6 +88,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
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]);
|
||||
@@ -179,6 +201,32 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
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);
|
||||
@@ -280,6 +328,87 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
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 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);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="content-pad workspace-data-page">
|
||||
@@ -289,7 +418,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
@@ -297,6 +426,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{contentLibraryNotice && <DismissibleAlert tone="success" resetKey={contentLibraryNotice} floating>{contentLibraryNotice}</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" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
@@ -371,8 +501,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
</div>
|
||||
}
|
||||
<div className="button-row template-editor-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -494,6 +624,154 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
}
|
||||
|
||||
<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) => (
|
||||
<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>
|
||||
</div>
|
||||
<div className="button-row campaign-content-library-item-actions">
|
||||
{item.kind === "campaign_part" ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={locked}
|
||||
onClick={() => applyCampaignPart(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 && insertContentFragment(target, value)}
|
||||
>Insert in {target}</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={contentSaveOpen}
|
||||
title="Save reusable content"
|
||||
className="campaign-content-save-dialog"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user