initial commit after split
This commit is contained in:
387
webui/src/features/campaigns/RecipientDataPage.tsx
Normal file
387
webui/src/features/campaigns/RecipientDataPage.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
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 LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { getBool } from "./utils/draftEditor";
|
||||
import {
|
||||
addressesFromValue,
|
||||
collectCampaignAddressSuggestions,
|
||||
type MailboxAddress
|
||||
} from "../../utils/emailAddresses";
|
||||
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||
|
||||
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 RecipientAddressKey = "to" | "cc" | "bcc";
|
||||
|
||||
type EntryAddressColumn = {
|
||||
key: RecipientAddressKey | "from" | "reply_to";
|
||||
label: string;
|
||||
allowMultiple: boolean;
|
||||
addLabel: string;
|
||||
emptyText: string;
|
||||
mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to";
|
||||
};
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
locked,
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "recipients",
|
||||
unsavedTitle: "Unsaved recipient changes",
|
||||
unsavedMessage: "Recipient address data has unsaved changes. Save it before leaving, or discard it and continue."
|
||||
});
|
||||
const recipientsSection = asRecord(displayDraft.recipients);
|
||||
const entries = asRecord(displayDraft.entries);
|
||||
const entryDefaults = asRecord(entries.defaults);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
|
||||
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
to: addressesFromValue(recipientsSection.to),
|
||||
cc: addressesFromValue(recipientsSection.cc),
|
||||
bcc: addressesFromValue(recipientsSection.bcc)
|
||||
};
|
||||
const entryAddressColumns = useMemo<EntryAddressColumn[]>(() => {
|
||||
const columns: EntryAddressColumn[] = [];
|
||||
if (getBool(recipientsSection, "allow_individual_from")) {
|
||||
columns.push({ key: "from", label: "From", allowMultiple: false, addLabel: "Set From", emptyText: "Uses global From." });
|
||||
}
|
||||
if (getBool(recipientsSection, "allow_individual_reply_to")) {
|
||||
columns.push({ key: "reply_to", label: "Reply-To", allowMultiple: true, addLabel: "Add Reply-To", emptyText: "Uses global Reply-To.", mergeKey: "merge_reply_to" });
|
||||
}
|
||||
if (getBool(recipientsSection, "allow_individual_to")) {
|
||||
columns.push({ key: "to", label: "To", allowMultiple: true, addLabel: "Add To", emptyText: "No recipient address.", mergeKey: "merge_to" });
|
||||
}
|
||||
if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "CC", allowMultiple: true, addLabel: "Add CC", emptyText: "No CC.", mergeKey: "merge_cc" });
|
||||
if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "BCC", allowMultiple: true, addLabel: "Add BCC", emptyText: "No BCC.", mergeKey: "merge_bcc" });
|
||||
return columns;
|
||||
}, [recipientsSection]);
|
||||
|
||||
|
||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
||||
patch(["entries", "inline"], nextEntries);
|
||||
}
|
||||
|
||||
function addRecipient(afterIndex = inlineEntries.length - 1) {
|
||||
const nextIndex = inlineEntries.length + 1;
|
||||
const newEntry = {
|
||||
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
|
||||
active: true,
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
name: "",
|
||||
email: "",
|
||||
from: [],
|
||||
reply_to: [],
|
||||
merge_to: false,
|
||||
merge_cc: true,
|
||||
merge_bcc: true,
|
||||
merge_reply_to: true
|
||||
};
|
||||
replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry));
|
||||
}
|
||||
|
||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
||||
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
|
||||
updateEntry(index, (entry) => {
|
||||
if (key === "from") {
|
||||
return { ...entry, from: addresses.slice(0, 1) };
|
||||
}
|
||||
const nextEntry = { ...entry, [key]: addresses };
|
||||
if (key === "to") {
|
||||
const address = addresses[0] ?? { name: "", email: "" };
|
||||
return {
|
||||
...nextEntry,
|
||||
name: address.name ?? "",
|
||||
email: address.email
|
||||
};
|
||||
}
|
||||
return nextEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
|
||||
updateEntry(index, (entry) => {
|
||||
const next = { ...entry, [mergeKey]: checked };
|
||||
delete next[mergeKey.replace("merge_", "combine_")];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function removeEntry(index: number) {
|
||||
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
function moveEntry(index: number, targetIndex: number) {
|
||||
if (locked || index === targetIndex) return;
|
||||
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Sender & Recipients</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{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="This page is read-only for the selected version." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
<Card title="Campaign sender">
|
||||
<div className="campaign-header-stack">
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="Default From address">
|
||||
<EmailAddressInput
|
||||
value={defaultFrom}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
disabled={locked}
|
||||
addLabel="Set From"
|
||||
emptyText="No global From address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses.slice(0, 1))}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label="Allow individual senders"
|
||||
checked={getBool(recipientsSection, "allow_individual_from")}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="Global Reply-To address">
|
||||
<EmailAddressInput
|
||||
value={globalReplyTo}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
disabled={locked}
|
||||
addLabel="Add Reply-To"
|
||||
emptyText="No global Reply-To address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label="Allow individual Reply-To"
|
||||
checked={getBool(recipientsSection, "allow_individual_reply_to")}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Global recipient headers">
|
||||
<div className="campaign-header-stack">
|
||||
{recipientHeaderRows.map((row) => (
|
||||
<div className="campaign-header-grid" key={row.key}>
|
||||
<FormField label={row.label}>
|
||||
<EmailAddressInput
|
||||
value={globalRecipientValues[row.key] ?? []}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
disabled={locked}
|
||||
addLabel={row.addLabel}
|
||||
emptyText={row.emptyText}
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", row.key], addresses)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label={row.toggleLabel}
|
||||
checked={getBool(recipientsSection, row.toggleKey)}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", row.toggleKey], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section className="recipient-profiles-section">
|
||||
<div className="subsection-heading split">
|
||||
<h3>Recipient profiles</h3>
|
||||
<Button disabled>Import</Button>
|
||||
</div>
|
||||
{inlineEntries.length === 0 && Boolean(source.type) && (
|
||||
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
|
||||
)}
|
||||
{!source.type && (
|
||||
<div className="admin-table-surface recipient-profiles-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-recipient-profiles`}
|
||||
rows={inlineEntries.slice(0, 100)}
|
||||
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
|
||||
getRowKey={(entry, index) => String(entry.id || index)}
|
||||
emptyText="No recipient profiles are stored in the current version yet."
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="Add first recipient" />}
|
||||
className="recipient-table-wrap recipient-address-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RecipientProfileColumnContext = {
|
||||
locked: boolean;
|
||||
entries: Record<string, unknown>[];
|
||||
entryDefaults: Record<string, unknown>;
|
||||
entryAddressColumns: EntryAddressColumn[];
|
||||
addressSuggestions: MailboxAddress[];
|
||||
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
||||
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
||||
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
||||
addRecipient: (afterIndex?: number) => void;
|
||||
moveEntry: (index: number, targetIndex: number) => void;
|
||||
removeEntry: (index: number) => void;
|
||||
};
|
||||
|
||||
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
|
||||
id: column.key,
|
||||
header: column.label,
|
||||
width: column.key === "to" ? "minmax(260px, 1.2fr)" : 250,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
render: (entry, index) => (
|
||||
<div className="recipient-address-cell">
|
||||
<EmailAddressInput
|
||||
value={getEntryAddresses(entry, column.key)}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={column.allowMultiple}
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel={column.addLabel}
|
||||
emptyText={column.emptyText}
|
||||
onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
|
||||
/>
|
||||
{column.mergeKey && (
|
||||
<div className="recipient-address-merge">
|
||||
<ToggleSwitch
|
||||
label="Merge"
|
||||
checked={getEntryMerge(entry, entryDefaults, column.mergeKey)}
|
||||
disabled={locked}
|
||||
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)}
|
||||
/>
|
||||
<span className="muted">Merge with global {column.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
|
||||
})),
|
||||
{ id: "active", header: "Active", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }] }, render: (entry, index) => <ToggleSwitch label="Active" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_entry, index) => (
|
||||
<DataGridRowActions
|
||||
disabled={locked}
|
||||
onAddBelow={() => addRecipient(index)}
|
||||
onRemove={() => removeEntry(index)}
|
||||
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
|
||||
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
|
||||
addLabel="Add recipient below"
|
||||
removeLabel="Remove recipient"
|
||||
moveUpLabel="Move recipient up"
|
||||
moveDownLabel="Move recipient down"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getEntryMerge(entry: Record<string, unknown>, defaults: Record<string, unknown>, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>): boolean {
|
||||
const legacyKey = mergeKey.replace("merge_", "combine_");
|
||||
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
|
||||
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
|
||||
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
|
||||
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
return addressesFromValue(entry[key]);
|
||||
}
|
||||
|
||||
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
|
||||
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
|
||||
return direct?.email ? [direct] : [];
|
||||
}
|
||||
|
||||
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;
|
||||
let counter = entries.length + 1;
|
||||
let candidate = `recipient-${counter}`;
|
||||
while (existing.has(candidate)) {
|
||||
counter += 1;
|
||||
candidate = `recipient-${counter}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
Reference in New Issue
Block a user