DataGrid - initial commit

This commit is contained in:
2026-06-11 18:21:15 +02:00
parent fdab7cd362
commit 2fc4648515
27 changed files with 1813 additions and 648 deletions

View File

@@ -9,6 +9,8 @@ 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, { type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -134,8 +136,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing sender or recipient profiles." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
@@ -224,51 +226,17 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
>
{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>
<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>
)}
{inlineEntries.length > 0 && (
<div className="app-table-wrap recipient-table-wrap">
<table className="app-table recipient-table recipient-address-table">
<thead>
<tr>
<th>#</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) => (
<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}
addLabel={column.addLabel}
emptyText={column.emptyText}
onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
/>
</td>
))}
<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>
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries.slice(0, 100)}
columns={recipientProfileColumns({ locked, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient profiles are stored in the current version yet."
className="recipient-table-wrap recipient-address-table"
/>
)}
</Card>
@@ -281,6 +249,43 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
);
}
type RecipientProfileColumnContext = {
locked: boolean;
entryAddressColumns: EntryAddressColumn[];
addressSuggestions: MailboxAddress[];
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
removeEntry: (index: number) => void;
};
function recipientProfileColumns({ locked, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, 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) => (
<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)}
/>
),
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
})),
{ id: "active", header: "Active", width: 130, sortable: true, filterable: true, 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: 120, sticky: "end", render: (_entry, index) => <Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button> }
];
}
function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
if (key === "to") {
const explicit = addressesFromValue(entry.to);