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:
@@ -836,6 +836,68 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=localize_documentation_topics((
|
||||
DocumentationTopic(
|
||||
id="campaigns.module-navigation-and-table-layout",
|
||||
title="Distinguish module reports from one campaign's report",
|
||||
summary="Use accurate module breadcrumbs and shared edge-to-edge report and attachment tables.",
|
||||
body=(
|
||||
"The Campaign audit documentation book sits immediately to the right of the Recent audit "
|
||||
"events heading. "
|
||||
"The Campaigns module has separate Reports (/campaigns/reports) and Operator queue (/campaigns/queue) views. "
|
||||
"Their breadcrumbs name the module section, not a campaign record. A selected campaign's own report remains under "
|
||||
"/campaigns/{campaign_id}/report (including its reports alias) with the singular Campaign and Report context. "
|
||||
"The legacy /operator link still redirects to the queue; report selection query parameters, editor deep links and "
|
||||
"Quick Access return-to-origin history behavior remain unchanged. These labels grant no extra report or delivery permissions. "
|
||||
"The Campaign reports available to you and Global Attachments cards use the shared table-body layout: tables reach "
|
||||
"the card boundary, including while loading, without module-local negative margins. Global Attachments remains collapsible; "
|
||||
"chooser warnings, row actions, empty-state actions and existing filters remain available. Layout changes do not change "
|
||||
"recipients, attachment rules or bytes, report privacy suppression, saved data, exports or delivery state. "
|
||||
"In Recipient data, automatic fitting prefers at most 640/480 pixels for Recipient(s)/Delivery and 360 pixels "
|
||||
"for configurable fields before distributing spare space. These are starting-layout preferences, not manual "
|
||||
"resize limits: all three kinds of column can be widened further and reduced again. "
|
||||
"The table scrolls horizontally while manual resizing keeps the non-resizable Active and Attachments columns "
|
||||
"at their current widths. These are personal "
|
||||
"browser layout changes, not campaign autosaves. Recipient and global-attachment grids wait for the initial draft "
|
||||
"before restoring their saved column layout, so a reload does not replace personal widths with temporary loading columns."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("campaign_manager", "campaign_operator", "campaign_admin"),
|
||||
order=19,
|
||||
conditions=(DocumentationCondition(required_modules=("campaigns",), any_scopes=CAMPAIGN_MODULE_REQUIRED_ANY),),
|
||||
links=(
|
||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||
DocumentationLink(label="Reports", href="/campaigns/reports", kind="runtime"),
|
||||
DocumentationLink(label="Operator queue", href="/campaigns/queue", kind="runtime"),
|
||||
),
|
||||
metadata={"kind": "reference", "related_topic_ids": ["campaigns.workflow.prepare-validate-and-build"]},
|
||||
translations={"de": {
|
||||
"title": "Modulberichte vom Bericht einer einzelnen Kampagne unterscheiden",
|
||||
"summary": "Eindeutige Modul-Breadcrumbs sowie gemeinsame, bündige Berichts- und Anhangstabellen verwenden.",
|
||||
"body": (
|
||||
"Das Dokumentationsbuch im Kampagnen-Audit steht unmittelbar rechts neben der Überschrift "
|
||||
"Letzte Audit-Ereignisse. "
|
||||
"Das Modul Kampagnen besitzt getrennte Ansichten für Berichte (/campaigns/reports) und die Operator-Warteschlange "
|
||||
"(/campaigns/queue). Ihre Breadcrumbs benennen den Modulbereich und keinen Kampagnendatensatz. Der Bericht einer "
|
||||
"ausgewählten Kampagne bleibt unter /campaigns/{campaign_id}/report (einschließlich des reports-Alias) im Kontext "
|
||||
"Kampagne und Bericht. Der bisherige Link /operator leitet weiterhin zur Warteschlange um; Auswahlparameter für "
|
||||
"Berichte, Editor-Deep-Links und die Rückkehr zum Ursprung über Quick Access bleiben unverändert. Die Beschriftung "
|
||||
"vergibt keine zusätzlichen Berichts- oder Versandberechtigungen. Die Karten für verfügbare Kampagnenberichte und "
|
||||
"globale Anhänge verwenden das gemeinsame Tabellenlayout: Tabellen reichen auch beim Laden bis an den Kartenrand, "
|
||||
"ohne negative modulspezifische Abstände. Globale Anhänge bleiben einklappbar; Auswahlwarnungen, Zeilenaktionen, "
|
||||
"Aktionen für leere Tabellen und vorhandene Filter bleiben verfügbar. Das Layout ändert weder Empfänger, "
|
||||
"Anhangsregeln oder Bytes noch Datenschutzunterdrückung, gespeicherte Daten, Exporte oder Versandzustand. "
|
||||
"In den Empfängerdaten bevorzugt die automatische Anpassung zunächst höchstens 640/480 Pixel für Empfänger/Zustellung "
|
||||
"und 360 Pixel für konfigurierbare Felder, bevor sie freien Platz verteilt. Diese Werte bestimmen nur das "
|
||||
"Ausgangslayout und begrenzen nicht die manuelle Größenänderung: Alle drei Spaltenarten lassen sich weiter "
|
||||
"verbreitern und wieder verkleinern. Die Tabelle wird horizontal scrollbar; bei der manuellen Größenänderung "
|
||||
"behalten die nicht verstellbaren Spalten Aktiv und Anhänge ihre aktuellen Breiten. Dies sind persönliche "
|
||||
"Browser-Einstellungen und keine automatische Speicherung der Kampagne. "
|
||||
"Empfänger- und globale Anhangstabellen warten beim ersten Laden auf den Entwurf, bevor sie gespeicherte "
|
||||
"Spaltenbreiten wiederherstellen. Vorläufige Ladespalten überschreiben dadurch beim Neuladen keine persönlichen Breiten."
|
||||
),
|
||||
}},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.attachment-filename-fidelity",
|
||||
title="Deterministic attachment and ZIP names",
|
||||
|
||||
@@ -5,3 +5,15 @@ def test_static_documentation_has_complete_german_reference_copy() -> None:
|
||||
for topic in get_manifest().documentation:
|
||||
german = topic.translations.get("de", {})
|
||||
assert all(german.get(field, "").strip() for field in ("title", "summary", "body")), topic.id
|
||||
|
||||
|
||||
def test_module_breadcrumb_and_table_layout_contract_is_bilingual_and_non_mutating() -> None:
|
||||
topic = next(item for item in get_manifest().documentation if item.id == "campaigns.module-navigation-and-table-layout")
|
||||
assert set(topic.documentation_types) == {"user", "admin"}
|
||||
assert topic.layer == "available"
|
||||
for body in (topic.body, topic.translations["de"]["body"]):
|
||||
for route in ("/campaigns/reports", "/campaigns/queue", "/campaigns/{campaign_id}/report", "/operator"):
|
||||
assert route in body
|
||||
assert "Quick Access" in body
|
||||
assert "report privacy suppression" in topic.body
|
||||
assert "without module-local negative margins" in topic.body
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user