UI changes, attachment page redesign

This commit is contained in:
2026-06-10 11:14:16 +02:00
parent d666dd90ee
commit 1f34435893
12 changed files with 725 additions and 209 deletions

View File

@@ -1,18 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
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 StatusBadge from "../../components/StatusBadge";
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, stringifyPreview, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, parseJsonTextarea, stringifyJson, updateNested } from "./utils/draftEditor";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, 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 AttachmentRule } from "./components/AttachmentRulesOverlay";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -25,6 +25,13 @@ const recipientHeaderRows = [
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
];
type FieldDefinition = {
name: string;
label: string;
type: string;
can_override: boolean;
};
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
@@ -38,13 +45,15 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const entries = asRecord(draft?.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldNames = useMemo(() => getDraftFieldNames(draft), [draft]);
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
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");
useEffect(() => {
if (!version) return;
@@ -53,7 +62,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
@@ -65,17 +73,18 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
patch(["entries", "inline"], nextEntries);
}
function appendRecipient(address: MailboxAddress) {
const nextEntry = {
id: `recipient-${inlineEntries.length + 1}`,
function addRecipient() {
const nextIndex = inlineEntries.length + 1;
const newEntry = {
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true,
to: [address],
name: address.name ?? "",
email: address.email,
to: [],
name: "",
email: "",
fields: {},
attachments: []
};
replaceInlineEntries([...inlineEntries, nextEntry]);
replaceInlineEntries([...inlineEntries, newEntry]);
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
@@ -83,17 +92,22 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(nextEntries);
}
function updateEntryRecipient(index: number, addresses: MailboxAddress[]) {
const address = addresses[0] ?? { name: "", email: "" };
updateEntry(index, (entry) => ({
...entry,
to: address.email ? [address] : [],
name: address.name ?? "",
email: address.email
}));
function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) {
updateEntry(index, (entry) => {
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 updateEntryField(index: number, field: string, value: string) {
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
@@ -103,13 +117,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
}));
}
function updateEntryAttachments(index: number, text: string) {
const parsed = parseJsonTextarea(text, asArray(inlineEntries[index]?.attachments));
if (parsed.error) {
setLocalError(`Invalid attachment JSON in row ${index + 1}: ${parsed.error}`);
return;
}
updateEntry(index, (entry) => ({ ...entry, attachments: parsed.value }));
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
function removeEntry(index: number) {
@@ -195,80 +204,109 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</Card>
<Card title="Recipients" actions={<span className="muted small-note">Editable inline recipients with mail-style address chips, field values and individual attachment config.</span>}>
<Card
title="Recipients"
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
>
{draft && inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
{draft && 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>
)}
{draft && (
{draft && inlineEntries.length > 0 && (
<div className="app-table-wrap recipient-table-wrap">
<table className="app-table recipient-table recipient-editor-table">
<thead>
<tr>
<th>#</th>
<th>Recipient</th>
<th>Status</th>
{fieldNames.map((field) => <th key={field}>{field}</th>)}
<th>Individual attachments</th>
<th>Recipients</th>
<th>Attachments</th>
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
<th aria-label="Actions"></th>
</tr>
</thead>
<tbody>
<tr className="recipient-add-row">
<td className="mono-small">+</td>
<td colSpan={Math.max(2, fieldNames.length + 3)}>
<EmailAddressInput
value={[]}
suggestions={addressSuggestions}
clearOnAdd
disabled={locked || !draft}
addLabel="Add recipient"
emptyText="Add a new inline recipient."
onAddressAdded={appendRecipient}
/>
</td>
<td></td>
</tr>
{inlineEntries.slice(0, 100).map((entry, index) => {
const recipient = primaryRecipient(entry);
const to = addressesFromValue(entry.to);
const cc = addressesFromValue(entry.cc);
const bcc = addressesFromValue(entry.bcc);
const fields = asRecord(entry.fields);
const attachments = asArray(entry.attachments);
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>
<EmailAddressInput
value={recipient.email ? [recipient] : []}
suggestions={addressSuggestions}
allowMultiple={false}
compact
<AttachmentRulesOverlay
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
disabled={locked}
addLabel={recipient.email ? "Replace" : "Add"}
emptyText="No recipient address."
onChange={(addresses) => updateEntryRecipient(index, addresses)}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
</td>
<td><StatusBadge status={String(entry.active === false ? "inactive" : "active")} /></td>
{fieldNames.map((field) => (
<td key={field}>
<input
{fieldDefinitions.map((field) => (
<td key={field.name}>
<FieldValueInput
className="recipient-field-input"
value={String(fields[field] ?? "")}
disabled={locked}
onChange={(event) => updateEntryField(index, field, event.target.value)}
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>
<textarea
className="recipient-attachments-input"
rows={2}
value={attachments.length ? stringifyJson(attachments) : "[]"}
disabled={locked}
title={attachments.length ? stringifyPreview(attachments, 180) : undefined}
onChange={(event) => updateEntryAttachments(index, event.target.value)}
/>
</td>
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
</tr>
);
@@ -288,15 +326,40 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
);
}
function getDraftFieldNames(draft: Record<string, unknown> | null): string[] {
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
return asArray(draft?.fields)
.map((field) => asRecord(field))
.map((field) => String(field.name || field.id || ""))
.filter(Boolean);
.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 primaryRecipient(entry: Record<string, unknown>): MailboxAddress {
const to = addressesFromValue(entry.to)[0];
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return to ?? direct ?? { name: "", email: "" };
function normalizeFieldType(value: string): string {
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
}
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return direct?.email ? [direct] : [];
}
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;
let counter = entries.length + 1;
let candidate = `recipient-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `recipient-${counter}`;
}
return candidate;
}