Integrate Distribution Lists with Campaign recipients

This commit is contained in:
2026-08-02 11:53:35 +02:00
parent 4eeba62bbc
commit b38597f2be
15 changed files with 1552 additions and 3 deletions
@@ -3,7 +3,10 @@ import type { ApiSettings } from "../../types";
import {
getCampaignPostboxCatalog,
listCampaignRecipientAddressSources,
listCampaignRecipientDistributionLists,
snapshotCampaignRecipientAddressSource,
type CampaignDistributionListExpansion,
type CampaignDistributionListSource,
type CampaignPostboxCatalog,
type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot } from
@@ -38,6 +41,11 @@ import {
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
import { insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
import {
distributionListDrift,
materializeDistributionListExpansion
} from "./utils/distributionListImport";
import {
AddressHeaderControl,
HeaderAddressEditorDialog,
@@ -71,6 +79,11 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
const [distributionListImportOpen, setDistributionListImportOpen] = useState(false);
const [distributionListImportInitialId, setDistributionListImportInitialId] = useState("");
const [distributionListsAvailable, setDistributionListsAvailable] = useState(false);
const [distributionListsLoading, setDistributionListsLoading] = useState(false);
const [distributionLists, setDistributionLists] = useState<CampaignDistributionListSource[]>([]);
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
@@ -130,6 +143,10 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
}).
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
}, [addressSourceRevisionById, entries.imports]);
const staleDistributionListImports = useMemo(
() => distributionListDrift(entries.imports, distributionLists),
[distributionLists, entries.imports]
);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
@@ -159,6 +176,26 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
setDistributionListsLoading(true);
void listCampaignRecipientDistributionLists(settings, campaignId)
.then((response) => {
if (cancelled) return;
setDistributionListsAvailable(response.available && response.expand_available);
setDistributionLists(response.available ? response.sources ?? [] : []);
})
.catch(() => {
if (cancelled) return;
setDistributionListsAvailable(false);
setDistributionLists([]);
})
.finally(() => {
if (!cancelled) setDistributionListsLoading(false);
});
return () => { cancelled = true; };
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!postboxModuleInstalled) {
setPostboxCatalog({
@@ -322,6 +359,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
setAddressSourceImportOpen(false);
}
function applyDistributionListImport(snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode) {
if (locked || !draft) return;
setDraft(materializeDistributionListExpansion(draft, snapshot, mode));
markDirty();
setDistributionListImportOpen(false);
}
function saveHeaderAddresses(values: HeaderAddressValues) {
const nextRecipients = { ...recipientsSection };
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
@@ -435,6 +479,11 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
Import address book/list
</Button>
}
{(distributionListsAvailable || distributionListsLoading) &&
<Button disabled={locked || distributionListsLoading || !distributionListsAvailable} onClick={() => {setDistributionListImportInitialId("");setDistributionListImportOpen(true);}}>
Import Distribution List
</Button>
}
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
</div>
@@ -459,6 +508,27 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</div>
</DismissibleAlert>
}
{staleDistributionListImports.length > 0 &&
<DismissibleAlert tone="warning" dismissible={false}>
<div className="stale-address-import-warning">
<span>Frozen Distribution List imports have changed or become unavailable. Campaign recipients remain unchanged until an explicit refresh.</span>
<div className="button-row compact-actions">
{staleDistributionListImports.map((item) =>
<span key={`${item.sourceId}:${item.importedRevision ?? "unknown"}`} className="button-row compact-actions">
<span>{item.sourceLabel}: {item.reason}</span>
{item.currentRevision !== null &&
<Button
type="button"
onClick={() => {setDistributionListImportInitialId(item.sourceId);setDistributionListImportOpen(true);}}>
Refresh {item.sourceLabel}
</Button>
}
</span>
)}
</div>
</div>
</DismissibleAlert>
}
{!source.type &&
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
@@ -527,6 +597,16 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
onCancel={() => setAddressSourceImportOpen(false)}
onImport={applyAddressSourceImport} />
}
{distributionListImportOpen &&
<DistributionListImportDialog
settings={settings}
campaignId={campaignId}
sources={distributionLists}
initialSourceId={distributionListImportInitialId}
onCancel={() => setDistributionListImportOpen(false)}
onImport={applyDistributionListImport} />
}
{recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] &&
<RecipientAddressEditorDialog