Structural changes

This commit is contained in:
2026-06-10 15:30:45 +02:00
parent 7de516c5e3
commit fcc46b06fe
16 changed files with 825 additions and 347 deletions

View File

@@ -9,11 +9,9 @@ import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -24,13 +22,16 @@ const recipientHeaderRows = [
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
];
] as const;
type FieldDefinition = {
name: string;
type RecipientAddressKey = "to" | "cc" | "bcc";
type EntryAddressColumn = {
key: RecipientAddressKey | "from" | "reply_to";
label: string;
type: string;
can_override: boolean;
allowMultiple: boolean;
addLabel: string;
emptyText: string;
};
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
@@ -47,21 +48,20 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => {
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
return enabled.length > 0 ? enabled : attachmentBasePaths;
}, [attachmentBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc)
};
const allowIndividualCc = getBool(recipientsSection, "allow_individual_cc");
const allowIndividualBcc = getBool(recipientsSection, "allow_individual_bcc");
const entryAddressColumns = useMemo<EntryAddressColumn[]>(() => {
const columns: EntryAddressColumn[] = [{ key: "to", label: "To", allowMultiple: true, addLabel: "Add To", emptyText: "No recipient address." }];
if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "CC", allowMultiple: true, addLabel: "Add CC", emptyText: "No CC." });
if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "BCC", allowMultiple: true, addLabel: "Add BCC", emptyText: "No BCC." });
if (getBool(recipientsSection, "allow_individual_from")) columns.push({ key: "from", label: "Sender", allowMultiple: false, addLabel: "Set sender", emptyText: "Uses default sender." });
if (getBool(recipientsSection, "allow_individual_reply_to")) columns.push({ key: "reply_to", label: "Reply-To", allowMultiple: false, addLabel: "Set Reply-To", emptyText: "Uses global Reply-To." });
return columns;
}, [recipientsSection]);
useEffect(() => {
if (!version) return;
@@ -87,10 +87,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true,
to: [],
cc: [],
bcc: [],
name: "",
email: "",
fields: {},
attachments: []
from: {},
reply_to: []
};
replaceInlineEntries([...inlineEntries, newEntry]);
}
@@ -100,9 +102,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(nextEntries);
}
function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) {
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
updateEntry(index, (entry) => {
const nextEntry = { ...entry, [key]: addresses };
if (key === "from") {
return { ...entry, from: addresses[0] ?? { name: "", email: "" } };
}
const nextEntry = { ...entry, [key]: key === "reply_to" ? addresses.slice(0, 1) : addresses };
if (key === "to") {
const address = addresses[0] ?? { name: "", email: "" };
return {
@@ -115,20 +120,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
});
}
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
...asRecord(entry.fields),
[field]: value
}
}));
}
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
function removeEntry(index: number) {
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
}
@@ -142,7 +133,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "recipient-data",
current_step: "recipients",
workflow_state: "editing",
is_complete: false
});
@@ -160,7 +151,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved recipient changes",
message: "Recipients or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
message: "Recipient addresses or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveRecipients("manual"),
onDiscard: () => setDirty(false)
} : null);
@@ -213,113 +204,53 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</Card>
<Card
title="Recipients"
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
title="Recipient profiles"
actions={[<Button key="import" disabled>Import</Button>, <Button key="add" variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
>
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && (
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
)}
{inlineEntries.length > 0 && (
<div className="app-table-wrap recipient-table-wrap">
<table className="app-table recipient-table recipient-editor-table">
<table className="app-table recipient-table recipient-address-table">
<thead>
<tr>
<th>#</th>
<th>Recipients</th>
<th>Attachments</th>
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
{entryAddressColumns.map((column) => <th key={column.key}>{column.label}</th>)}
<th>Active</th>
<th aria-label="Actions"></th>
</tr>
</thead>
<tbody>
{inlineEntries.slice(0, 100).map((entry, index) => {
const to = addressesFromValue(entry.to);
const cc = addressesFromValue(entry.cc);
const bcc = addressesFromValue(entry.bcc);
const fields = asRecord(entry.fields);
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<tr key={String(entry.id || index)}>
<td className="mono-small">{index + 1}</td>
<td className="recipient-cell">
<div className="recipient-address-stack">
<div className="recipient-address-line">
<span className="recipient-address-label">To</span>
<EmailAddressInput
value={to.length ? to : fallbackRecipientAddress(entry)}
suggestions={addressSuggestions}
allowMultiple={false}
compact
disabled={locked}
addLabel={to.length ? "Replace" : "Add"}
emptyText="No recipient address."
onChange={(addresses) => updateEntryAddressList(index, "to", addresses)}
/>
</div>
{allowIndividualCc && (
<div className="recipient-address-line">
<span className="recipient-address-label">CC</span>
<EmailAddressInput
value={cc}
suggestions={addressSuggestions}
allowMultiple
compact
disabled={locked}
addLabel="Add CC"
emptyText="No CC."
onChange={(addresses) => updateEntryAddressList(index, "cc", addresses)}
/>
</div>
)}
{allowIndividualBcc && (
<div className="recipient-address-line">
<span className="recipient-address-label">BCC</span>
<EmailAddressInput
value={bcc}
suggestions={addressSuggestions}
allowMultiple
compact
disabled={locked}
addLabel="Add BCC"
emptyText="No BCC."
onChange={(addresses) => updateEntryAddressList(index, "bcc", addresses)}
/>
</div>
)}
<ToggleSwitch
label="Active"
checked={entry.active !== false}
disabled={locked}
onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))}
/>
</div>
</td>
<td>
<AttachmentRulesOverlay
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
{inlineEntries.slice(0, 100).map((entry, index) => (
<tr key={String(entry.id || index)}>
<td className="mono-small">{index + 1}</td>
{entryAddressColumns.map((column) => (
<td key={column.key}>
<EmailAddressInput
value={getEntryAddresses(entry, column.key)}
suggestions={addressSuggestions}
allowMultiple={column.allowMultiple}
compact
disabled={locked}
basePaths={individualAttachmentBasePaths}
onChange={(rules) => updateEntryAttachments(index, rules)}
addLabel={column.addLabel}
emptyText={column.emptyText}
onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
/>
</td>
{fieldDefinitions.map((field) => (
<td key={field.name}>
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked || field.can_override === false}
placeholder={field.can_override === false ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
</td>
))}
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
</tr>
);
})}
))}
<td>
<ToggleSwitch
label="Active"
checked={entry.active !== false}
disabled={locked}
onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))}
/>
</td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
</tr>
))}
</tbody>
</table>
</div>
@@ -335,20 +266,14 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
);
}
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
return asArray(draft?.fields)
.map((field) => asRecord(field))
.map((field) => ({
name: getText(field, "name") || getText(field, "id"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type", "string")),
can_override: getBool(field, "can_override", true)
}))
.filter((field) => Boolean(field.name));
}
function normalizeFieldType(value: string): string {
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
if (key === "to") {
const explicit = addressesFromValue(entry.to);
return explicit.length ? explicit : fallbackRecipientAddress(entry);
}
if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
if (key === "reply_to") return addressesFromValue(entry.reply_to).slice(0, 1);
return addressesFromValue(entry[key]);
}
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
@@ -356,30 +281,6 @@ function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddres
return direct?.email ? [direct] : [];
}
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", true)
}];
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({ ...rule }));
}
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
if (!existing.has(preferred)) return preferred;