Integrate Distribution Lists with Campaign recipients
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
ToggleSwitch,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
previewCampaignRecipientDistributionList,
|
||||
snapshotCampaignRecipientDistributionList,
|
||||
type CampaignDistributionListExpansion,
|
||||
type CampaignDistributionListExpansionInput,
|
||||
type CampaignDistributionListParameter,
|
||||
type CampaignDistributionListSource,
|
||||
type CampaignDistributionRecipient
|
||||
} from "../../../api/campaigns";
|
||||
import type { RecipientImportMode } from "../utils/bulkImport";
|
||||
import { usableChannelSummary } from "../utils/distributionListImport";
|
||||
|
||||
type RequestedChannel = "email" | "postal" | "internal_mail" | "portal";
|
||||
type PreviewRow = CampaignDistributionRecipient & { included: boolean };
|
||||
|
||||
const channelOptions: Array<{id: RequestedChannel;label: string;}> = [
|
||||
{ id: "email", label: "Email" },
|
||||
{ id: "postal", label: "Postal" },
|
||||
{ id: "internal_mail", label: "Internal mail" },
|
||||
{ id: "portal", label: "Portal" }
|
||||
];
|
||||
|
||||
export default function DistributionListImportDialog({
|
||||
settings,
|
||||
campaignId,
|
||||
sources,
|
||||
initialSourceId = "",
|
||||
onCancel,
|
||||
onImport
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignDistributionListSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) => void;
|
||||
}) {
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.id || "");
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [requestedChannels, setRequestedChannels] = useState<RequestedChannel[]>(channelOptions.map((item) => item.id));
|
||||
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
||||
const [preview, setPreview] = useState<CampaignDistributionListExpansion | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = sourceQuery.trim().toLowerCase();
|
||||
if (!query) return sources;
|
||||
return sources.filter((source) => [source.name, source.description, source.definition_kind]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query)));
|
||||
}, [sourceQuery, sources]);
|
||||
const previewRows = useMemo<PreviewRow[]>(() => [
|
||||
...(preview?.recipients ?? []).map((recipient) => ({ ...recipient, included: true })),
|
||||
...(preview?.excluded ?? []).map((recipient) => ({ ...recipient, included: false }))
|
||||
], [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.id === selectedSourceId)) return;
|
||||
setSelectedSourceId(filteredSources[0]?.id ?? "");
|
||||
}, [filteredSources, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSource) {
|
||||
setParameters({});
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setParameters(Object.fromEntries(
|
||||
selectedSource.parameters
|
||||
.filter((parameter) => parameter.default !== null && parameter.default !== undefined)
|
||||
.map((parameter) => [parameter.key, parameter.default])
|
||||
));
|
||||
setPreview(null);
|
||||
setError("");
|
||||
}, [selectedSource?.id, selectedSource?.revision_id]);
|
||||
|
||||
function toggleChannel(channel: RequestedChannel, checked: boolean) {
|
||||
setRequestedChannels((current) => checked
|
||||
? [...new Set([...current, channel])]
|
||||
: current.filter((item) => item !== channel));
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
function updateParameter(parameter: CampaignDistributionListParameter, value: unknown) {
|
||||
setParameters((current) => ({ ...current, [parameter.key]: normalizeParameterValue(parameter, value) }));
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
function requestPayload(idempotencyKey?: string): CampaignDistributionListExpansionInput {
|
||||
return {
|
||||
list_id: selectedSourceId,
|
||||
revision: selectedSource?.revision ?? null,
|
||||
purpose: "campaign_delivery",
|
||||
requested_channels: requestedChannels,
|
||||
parameters,
|
||||
idempotency_key: idempotencyKey ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
if (!selectedSourceId || requestedChannels.length === 0) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setPreview(await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload()));
|
||||
} catch (reason) {
|
||||
setPreview(null);
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function freezeAndImport() {
|
||||
if (!preview || !selectedSourceId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const idempotencyKey = `campaign:${campaignId}:distribution:${selectedSourceId}:${randomId()}`;
|
||||
const snapshot = await snapshotCampaignRecipientDistributionList(
|
||||
settings,
|
||||
campaignId,
|
||||
requestPayload(idempotencyKey)
|
||||
);
|
||||
onImport(snapshot, mode);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Import a Distribution List"
|
||||
className="recipient-import-modal"
|
||||
bodyClassName="recipient-import-body"
|
||||
closeDisabled={loading}
|
||||
closeOnBackdrop={!loading}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated}
|
||||
onClick={() => void freezeAndImport()}
|
||||
>
|
||||
Freeze and import
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="address-source-import-controls">
|
||||
<input
|
||||
type="search"
|
||||
value={sourceQuery}
|
||||
disabled={loading || sources.length === 0}
|
||||
placeholder="Search Distribution Lists"
|
||||
aria-label="Search Distribution Lists"
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
/>
|
||||
<FormField label="Import mode">
|
||||
<SegmentedControl
|
||||
ariaLabel="Distribution List import mode"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
size="content"
|
||||
width="inline"
|
||||
disabled={loading}
|
||||
options={[
|
||||
{ id: "append", label: "Append" },
|
||||
{ id: "replace", label: "Replace" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<div className="address-source-picker" role="radiogroup" aria-label="Distribution List">
|
||||
{filteredSources.map((source) => (
|
||||
<button
|
||||
type="button"
|
||||
key={source.id}
|
||||
className={`address-source-option ${source.id === selectedSourceId ? "is-selected" : ""}`}
|
||||
disabled={loading}
|
||||
role="radio"
|
||||
aria-checked={source.id === selectedSourceId}
|
||||
onClick={() => setSelectedSourceId(source.id)}
|
||||
>
|
||||
<span className="address-source-option-main">
|
||||
<strong>{source.name}</strong>
|
||||
<span>{source.definition_kind} · revision {source.revision}</span>
|
||||
</span>
|
||||
<span className="address-source-option-meta">
|
||||
<span>{source.entry_count} entries</span>
|
||||
{source.stale && <span>Stale source</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{sources.length > 0 && filteredSources.length === 0 && (
|
||||
<div className="empty-state compact-empty">No Distribution Lists match the search.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="distribution-list-import-settings">
|
||||
<fieldset className="form-section compact-form-section">
|
||||
<legend>Requested channels</legend>
|
||||
{channelOptions.map((channel) => (
|
||||
<ToggleSwitch
|
||||
key={channel.id}
|
||||
label={channel.label}
|
||||
checked={requestedChannels.includes(channel.id)}
|
||||
disabled={loading}
|
||||
onChange={(checked) => toggleChannel(channel.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
{selectedSource?.parameters.map((parameter) => (
|
||||
<DistributionParameterField
|
||||
key={parameter.key}
|
||||
parameter={parameter}
|
||||
value={parameters[parameter.key]}
|
||||
disabled={loading}
|
||||
onChange={(value) => updateParameter(parameter, value)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || !selectedSourceId || requestedChannels.length === 0}
|
||||
onClick={() => void loadPreview()}
|
||||
>
|
||||
{preview ? "Refresh preview" : "Preview expansion"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Resolving Distribution List...</DismissibleAlert>}
|
||||
{!loading && sources.length === 0 && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
No Distribution Lists are available to this Campaign.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div><dt>List</dt><dd>{preview.source.name}</dd></div>
|
||||
<div><dt>Revision</dt><dd>{preview.source.revision}</dd></div>
|
||||
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
|
||||
<div><dt>Excluded</dt><dd>{preview.excluded.length}</dd></div>
|
||||
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
|
||||
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
|
||||
</dl>
|
||||
{preview.stale && (
|
||||
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||
At least one provider result is stale. Review its diagnostics before freezing this expansion.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview.truncated && (
|
||||
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||
The expansion reached a safety limit and cannot be frozen from this dialog.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{preview.diagnostics.map((diagnostic) => (
|
||||
<DismissibleAlert
|
||||
key={`${diagnostic.code}:${diagnostic.message}`}
|
||||
tone={diagnostic.severity === "error" ? "danger" : diagnostic.severity === "warning" ? "warning" : "info"}
|
||||
compact
|
||||
dismissible={false}
|
||||
>
|
||||
{diagnostic.message}
|
||||
</DismissibleAlert>
|
||||
))}
|
||||
<DistributionPreviewGrid rows={previewRows.slice(0, 100)} />
|
||||
{previewRows.length > 100 && (
|
||||
<p className="muted small-note">{previewRows.length - 100} more decisions are included in the frozen evidence.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionParameterField({
|
||||
parameter,
|
||||
value,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
parameter: CampaignDistributionListParameter;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const label = parameter.label || parameter.key;
|
||||
if (parameter.value_type === "boolean") {
|
||||
return <ToggleSwitch label={label} checked={Boolean(value)} disabled={disabled} onChange={onChange} />;
|
||||
}
|
||||
if (parameter.allowed_values.length > 0) {
|
||||
return (
|
||||
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
||||
<select value={scalarInputValue(value)} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
||||
{!parameter.required && <option value="">Any</option>}
|
||||
{parameter.allowed_values.map((option) => (
|
||||
<option key={String(option)} value={String(option)}>{String(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
const inputType = parameter.value_type === "date"
|
||||
? "date"
|
||||
: parameter.value_type === "datetime"
|
||||
? "datetime-local"
|
||||
: ["integer", "number"].includes(parameter.value_type)
|
||||
? "number"
|
||||
: "text";
|
||||
return (
|
||||
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={scalarInputValue(value)}
|
||||
disabled={disabled}
|
||||
min={parameter.minimum ?? undefined}
|
||||
max={parameter.maximum ?? undefined}
|
||||
pattern={parameter.pattern ?? undefined}
|
||||
placeholder={parameter.value_type === "string_list" ? "Value 1, Value 2" : undefined}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionPreviewGrid({ rows }: {rows: PreviewRow[];}) {
|
||||
const columns: DataGridColumn<PreviewRow>[] = [
|
||||
{ id: "name", header: "Recipient", width: "minmax(180px, 1fr)", value: (row) => row.display_name || row.recipient_key },
|
||||
{ id: "result", header: "Decision", width: 120, value: (row) => row.included ? "Included" : row.status },
|
||||
{ id: "channels", header: "Usable channels", width: "minmax(160px, 0.8fr)", value: (row) => usableChannelSummary(row.channels) },
|
||||
{ id: "source", header: "Source entries", width: "minmax(160px, 0.8fr)", value: (row) => row.source_entry_ids.join(", ") },
|
||||
{ id: "reason", header: "Explanation", width: "minmax(220px, 1.2fr)", value: (row) => row.explanations.map((item) => item.message).join(" · ") }
|
||||
];
|
||||
return (
|
||||
<DataGrid
|
||||
id="campaign-distribution-list-preview"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => `${row.included ? "included" : "excluded"}:${row.recipient_key}`}
|
||||
emptyText="No recipients resolved from this Distribution List."
|
||||
className="recipient-table-wrap"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeParameterValue(parameter: CampaignDistributionListParameter, value: unknown): unknown {
|
||||
if (value === "" || value === null || value === undefined) return null;
|
||||
if (parameter.value_type === "integer") return Number.parseInt(String(value), 10);
|
||||
if (parameter.value_type === "number") return Number(String(value));
|
||||
if (parameter.value_type === "string_list") return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
||||
return value;
|
||||
}
|
||||
|
||||
function scalarInputValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user