fix(ui): align campaign tables, recipient sizing and contextual help

Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
This commit is contained in:
2026-09-09 02:03:36 +02:00
parent 19437ce378
commit 11598b7b5b
9 changed files with 108 additions and 25 deletions
@@ -444,8 +444,10 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
}
</Card>
<Card id="campaign-global-attachments" tabIndex={-1} title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<AttachmentRulesDataGrid
<Card id="campaign-global-attachments" tabIndex={-1} title="i18n:govoplan-campaign.global_attachments.492bd841" bodyLayout="table" collapsible>
{/* The loading draft has no ZIP columns yet. Do not let that
temporary signature overwrite the user's saved table layout. */}
{version && draft && <AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
disabled={locked}
@@ -456,7 +458,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)} />
onChange={(rules) => patch(["attachments", "global"], rules)} />}
</Card>
@@ -24,7 +24,13 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
/>}
>
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d" titleHelp={<DocumentationHelpLink
reference={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "user"
}}
label="Open Campaign assurance documentation"
/>}>
<ActionBlockerHint
tone="info"
reason={{
@@ -39,13 +45,6 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
documentationType: "admin"
}}
/>
<DocumentationHelpLink
reference={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "user"
}}
label="Open Campaign assurance documentation"
/>
</Card>
</PageLayout>);
@@ -5,7 +5,6 @@ import {
getCampaignPostboxCatalog,
listCampaignRecipientAddressSources,
listCampaignRecipientDistributionLists,
snapshotCampaignRecipientAddressSource,
type CampaignDistributionListExpansion,
type CampaignDistributionListSource,
type CampaignPostboxCatalog,
@@ -41,7 +40,7 @@ import {
createAddressSourceImportProvenance
} from "./utils/addressSourceImport";
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
import { i18nMessage, insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
import { i18nMessage, insertAfter, moveArrayItem, usePlatformLanguage } from "@govoplan/core-webui";
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
import {
@@ -56,11 +55,7 @@ import {
entryWithAddressValues,
formatAddressCollectionForClipboard,
getAddressColumn,
getEntryAddresses,
headerAddressValues,
hiddenRecipientAddressMatch,
recipientAddressFilterValue,
recipientAddressSummary,
recipientHeaderRows,
type AddressFieldKey,
type EntryAddressColumn,
@@ -563,7 +558,9 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</div>
</DismissibleAlert>
}
{!source.type &&
{/* Mount with the real draft's delivery/attachment/field columns;
an empty loading signature would erase personal column widths. */}
{version && draft && !source.type &&
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
@@ -23,7 +23,6 @@ import {
import { getDraftFields } from "../utils/fieldDefinitions";
import { asRecord } from "../utils/campaignView";
import {
getEntryAddresses,
hiddenRecipientAddressMatch,
recipientAddressFilterValue,
recipientAddressSummary
@@ -74,7 +73,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
id: "recipients",
header: "Recipient(s)",
width: "minmax(320px, 1.4fr)",
maxWidth: 640,
preferredMaxWidth: 640,
resizable: true,
filterable: true,
render: (entry, index) => {
@@ -114,7 +113,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
id: "delivery",
header: "Delivery",
width: "minmax(260px, 0.9fr)",
maxWidth: 480,
preferredMaxWidth: 480,
resizable: true,
filterable: true,
render: (entry, index) => {
@@ -151,7 +150,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
Postboxes ({targets.length})
</Button>
)}
{printTarget.target && (
{Boolean(printTarget.target) && (
<span className="muted small-note" title={String(printTarget.target)}>
{printTarget.channel === "internal_mail" ? "Internal mail" : "Postal"}: {String(printTarget.target)}
</span>
@@ -185,13 +184,13 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
}] : []),
} satisfies DataGridColumn<Record<string, unknown>>] : []),
...fieldDefinitions.filter((field) => field.can_override !== false).map((field): DataGridColumn<Record<string, unknown>> => ({
id: `field-${field.name}`,
header: field.label || field.name,
width: 190,
minWidth: 160,
maxWidth: 360,
preferredMaxWidth: 360,
resizable: true,
sortable: true,
filterable: true,
@@ -183,7 +183,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
/>}
>
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403" bodyLayout="table">
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
<DataGrid
id="campaign-aggregate-report-list"
@@ -28,6 +28,8 @@ assert(!page.includes("downloadCampaignJobsCsv"), "the aggregate page has no exp
assert(!page.includes("localStorage"), "the aggregate page does not persist report data in local storage");
assert(!page.includes("sessionStorage"), "the aggregate page does not persist report data in session storage");
assert(page.includes("TableActionGroup"), "campaign selection uses the central icon-only table action group");
assert.match(page, /<Card\s+title="i18n:govoplan-campaign\.campaign_reports_available_to_you\.f14fa403"\s+bodyLayout="table">\s*<LoadingFrame[\s\S]*?<DataGrid/, "the report selector uses the shared edge-to-edge table body through its loading frame");
assert.match(attachmentsPage, /<Card\s+id="campaign-global-attachments"[^>]*bodyLayout="table"[^>]*collapsible>[\s\S]*?\{version && draft && <AttachmentRulesDataGrid/, "global attachments retain their collapsible table body and wait for real draft columns before restoring personal widths");
assert(page.includes("disabled: campaign.id === selectedFromUrl"), "the selected row action stays visible and disabled");
assert(page.includes('columnType: "from-list"'), "campaign status uses the stable shared list-filter model");
const expectedCampaignStatuses = [
@@ -29,4 +29,14 @@ assertIncludes(
"a row must explain when its match came from a hidden address"
);
assertIncludes("version && draft && !source.type", "recipient grids wait for the initial draft before restoring column widths");
const profileColumns = readFileSync(new URL("../src/features/campaigns/recipients/recipientProfileColumns.tsx", import.meta.url), "utf8");
for (const id of ["recipients", "delivery"]) {
const start = profileColumns.indexOf(`id: "${id}"`);
const declarations = profileColumns.slice(start, profileColumns.indexOf("render:", start));
if (!declarations.includes("resizable: true") || declarations.includes("maxWidth:")) {
throw new Error(`${id} remains user-expandable without an arbitrary hard display cap`);
}
}
console.log("Campaign recipient search covers and explains hidden address matches.");