From 4eb651c6ac6fbbe6e247e99716960e6bdc117878 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 09:11:10 +0200 Subject: [PATCH] feat(campaign): filter reports from outcome counts --- .../features/campaigns/CampaignReportPage.tsx | 92 ++++++++++++++----- .../campaigns/utils/reportGridShortcuts.ts | 85 +++++++++++++++++ webui/tests/report-grid-query.test.ts | 40 ++++++++ webui/tsconfig.report-grid-tests.json | 3 +- 4 files changed, 194 insertions(+), 26 deletions(-) create mode 100644 webui/src/features/campaigns/utils/reportGridShortcuts.ts diff --git a/webui/src/features/campaigns/CampaignReportPage.tsx b/webui/src/features/campaigns/CampaignReportPage.tsx index d63eed6..7fba5f6 100644 --- a/webui/src/features/campaigns/CampaignReportPage.tsx +++ b/webui/src/features/campaigns/CampaignReportPage.tsx @@ -28,6 +28,13 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, formatDateTime, humanize } from "./utils/campaignView"; import { emptyCampaignJobsResponse } from "./utils/jobDeltas"; import type { CampaignJobSortColumn } from "./utils/jobListQuery"; +import { + DEFAULT_REPORT_GRID_SORT, + activeReportGridShortcut, + reportGridQueriesEqual, + toggleReportGridShortcut, + type ReportGridShortcutId +} from "./utils/reportGridShortcuts"; const SEND_STATUS_OPTIONS: DataGridListOption[] = [ "not_queued", @@ -70,7 +77,6 @@ const QUEUE_STATUS_OPTIONS: DataGridListOption[] = [ "cancelled"]. map((value) => ({ value, label: humanize(value) })); -const DEFAULT_JOB_GRID_SORT = { columnId: "number", direction: "asc" as const }; const JOB_GRID_QUERY_DELAY_MS = 300; type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null; @@ -90,7 +96,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: const [pageSize, setPageSize] = useState(50); const [initialGridFilters] = useState>(() => initialReportGridFilters()); const initialGridQuery = useMemo(() => ({ - sort: DEFAULT_JOB_GRID_SORT, + sort: DEFAULT_REPORT_GRID_SORT, filters: serializeInitialGridFilters(initialGridFilters) }), [initialGridFilters]); const [jobGridQuery, setJobGridQuery] = useState(initialGridQuery); @@ -110,16 +116,45 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: useEffect(() => { const handle = window.setTimeout(() => { setAppliedQuery(query.trim()); - setAppliedJobGridQuery((current) => dataGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery); + setAppliedJobGridQuery((current) => reportGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery); setPage(1); }, JOB_GRID_QUERY_DELAY_MS); return () => window.clearTimeout(handle); }, [query, jobGridQuery]); const handleJobGridQuery = useCallback((next: DataGridQueryState) => { - setJobGridQuery((current) => dataGridQueriesEqual(current, next) ? current : next); + setJobGridQuery((current) => reportGridQueriesEqual(current, next) ? current : next); }, []); + const activeJobGridShortcut = useMemo( + () => query.trim() ? null : activeReportGridShortcut(jobGridQuery), + [jobGridQuery, query] + ); + + const applyJobGridShortcut = useCallback((shortcutId: ReportGridShortcutId) => { + const next = toggleReportGridShortcut(jobGridQuery, shortcutId); + setQuery(""); + setAppliedQuery(""); + setJobGridQuery(next); + setAppliedJobGridQuery(next); + setPage(1); + }, [jobGridQuery]); + + const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [ + { label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" }, + { label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" }, + { label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" }, + { label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" }, + { label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" }, + { label: "i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19", value: cards?.skipped ?? jobs.counts.send?.skipped ?? 0, shortcutId: "smtp_skipped" }, + { label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" } + ]; + const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [ + { label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" }, + { label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" }, + { label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" } + ]; + const loadJobs = useCallback(async () => { if (!campaignId) return; const requestId = ++jobsRequestRef.current; @@ -383,20 +418,36 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
i18n:govoplan-campaign.generated.8eefdd52
{formatDateTime(data.summary?.generated_at)}
-
i18n:govoplan-campaign.jobs_total.98da65bc
{cards?.jobs_total ?? "—"}
-
i18n:govoplan-campaign.smtp_accepted.e3aa7603
{cards?.smtp_accepted ?? cards?.sent ?? 0}
-
i18n:govoplan-campaign.failed.09fef5d8
{cards?.failed ?? 0}
-
i18n:govoplan-campaign.outcome_unknown.6e929fca
{cards?.outcome_unknown ?? 0}
-
i18n:govoplan-campaign.not_attempted.e1be3c69
{cards?.not_attempted ?? 0}
-
i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19
{cards?.skipped ?? jobs.counts.send?.skipped ?? 0}
-
i18n:govoplan-campaign.cancelled.a1bf92ef
{cards?.cancelled ?? 0}
+ {deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => { + const active = activeJobGridShortcut === shortcutId; + return ( +
+
{label}
+
+ +
+
+ ); + })}
-
i18n:govoplan-campaign.imap_appended.56017ea3
{cards?.imap_appended ?? 0}
-
i18n:govoplan-campaign.imap_failed.50dbca55
{cards?.imap_failed ?? 0}
-
i18n:govoplan-campaign.imap_skipped.5a97b542
{cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0}
+ {imapOutcomeShortcuts.map(({ label, value, shortcutId }) => { + const active = activeJobGridShortcut === shortcutId; + return ( +
+
{label}
+
+ +
+
+ ); + })}
i18n:govoplan-campaign.append_policy.f195cb05
{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}
i18n:govoplan-campaign.rate_limit.d08e55f5
{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}
i18n:govoplan-campaign.minimum_remaining_duration.639b792c
{String(delivery.estimated_remaining_send_human ?? "—")}
@@ -435,7 +486,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: getRowKey={(row: Record) => String(row.id ?? "")} emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" initialFilters={initialGridFilters} - initialSort={DEFAULT_JOB_GRID_SORT} + initialSort={DEFAULT_REPORT_GRID_SORT} + query={jobGridQuery} pagination={{ mode: "server", page, @@ -589,16 +641,6 @@ function campaignJobSortColumn(value?: string): CampaignJobSortColumn { return "number"; } -function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean { - if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false; - if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false; - const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]); - for (const key of keys) { - if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false; - } - return true; -} - function retryableFailedStatus(status: string): boolean { return status === "failed_temporary" || status === "failed_permanent"; } diff --git a/webui/src/features/campaigns/utils/reportGridShortcuts.ts b/webui/src/features/campaigns/utils/reportGridShortcuts.ts new file mode 100644 index 0000000..cad2889 --- /dev/null +++ b/webui/src/features/campaigns/utils/reportGridShortcuts.ts @@ -0,0 +1,85 @@ +export type ReportGridQueryState = { + sort: { columnId: string; direction: "asc" | "desc" } | null; + filters: Record; +}; + +export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" as const }; + +export type ReportGridShortcutId = + | "all" + | "smtp_accepted" + | "failed" + | "outcome_unknown" + | "not_attempted" + | "smtp_skipped" + | "cancelled" + | "imap_appended" + | "imap_failed" + | "imap_skipped"; + +const REPORT_GRID_SHORTCUT_FILTERS: Record> = { + all: {}, + smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) }, + failed: { send: listFilter(["failed_temporary", "failed_permanent"]) }, + outcome_unknown: { send: listFilter(["outcome_unknown"]) }, + not_attempted: { send: listFilter(["not_queued"]) }, + smtp_skipped: { send: listFilter(["skipped"]) }, + cancelled: { send: listFilter(["cancelled"]) }, + imap_appended: { imap: listFilter(["appended"]) }, + imap_failed: { imap: listFilter(["failed"]) }, + imap_skipped: { imap: listFilter(["skipped"]) } +}; + +/** + * Return the complete grid query for a count shortcut. Shortcuts are exact + * report views, so applying one intentionally clears every unrelated filter + * and restores the stable report ordering. + */ +export function reportGridQueryForShortcut(shortcutId: ReportGridShortcutId): ReportGridQueryState { + return { + sort: { ...DEFAULT_REPORT_GRID_SORT }, + filters: { ...REPORT_GRID_SHORTCUT_FILTERS[shortcutId] } + }; +} + +/** Applying an already selected outcome shortcut returns to the unfiltered report. */ +export function toggleReportGridShortcut( + current: ReportGridQueryState, + shortcutId: ReportGridShortcutId +): ReportGridQueryState { + const target = reportGridQueryForShortcut(shortcutId); + if (shortcutId !== "all" && reportGridFiltersEqual(current.filters, target.filters)) { + return reportGridQueryForShortcut("all"); + } + return target; +} + +export function activeReportGridShortcut(query: ReportGridQueryState): ReportGridShortcutId | null { + const shortcutIds = Object.keys(REPORT_GRID_SHORTCUT_FILTERS) as ReportGridShortcutId[]; + return shortcutIds.find((shortcutId) => reportGridFiltersEqual( + query.filters, + REPORT_GRID_SHORTCUT_FILTERS[shortcutId] + )) ?? null; +} + +export function reportGridQueriesEqual(left: ReportGridQueryState, right: ReportGridQueryState): boolean { + if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false; + if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false; + const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]); + for (const key of keys) { + if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false; + } + return true; +} + +function listFilter(values: string[]): string { + return `list:${JSON.stringify(values)}`; +} + +function reportGridFiltersEqual(left: Record, right: Record): boolean { + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) { + if ((left[key] ?? "") !== (right[key] ?? "")) return false; + } + return true; +} diff --git a/webui/tests/report-grid-query.test.ts b/webui/tests/report-grid-query.test.ts index abdec48..66238d5 100644 --- a/webui/tests/report-grid-query.test.ts +++ b/webui/tests/report-grid-query.test.ts @@ -1,4 +1,9 @@ import { campaignJobsQueryParams } from "../src/features/campaigns/utils/jobListQuery"; +import { + activeReportGridShortcut, + reportGridQueryForShortcut, + toggleReportGridShortcut +} from "../src/features/campaigns/utils/reportGridShortcuts"; declare function require(name: string): { readFileSync(path: string, encoding: string): string; @@ -34,12 +39,47 @@ assert(params.get("filter_send") === 'list:["failed_temporary","outcome_unknown" assert(params.get("filter_attempts") === "gte:2", "typed number filters retain their operator"); assert(!params.toString().includes("unsupported") && !params.toString().includes("must-not-leak"), "only the declared backend filter contract is serialized"); +const shortcutFilters = { + smtp_accepted: { send: 'list:["smtp_accepted","sent"]' }, + failed: { send: 'list:["failed_temporary","failed_permanent"]' }, + outcome_unknown: { send: 'list:["outcome_unknown"]' }, + not_attempted: { send: 'list:["not_queued"]' }, + smtp_skipped: { send: 'list:["skipped"]' }, + cancelled: { send: 'list:["cancelled"]' }, + imap_appended: { imap: 'list:["appended"]' }, + imap_failed: { imap: 'list:["failed"]' }, + imap_skipped: { imap: 'list:["skipped"]' } +} as const; + +for (const [shortcutId, filters] of Object.entries(shortcutFilters)) { + const shortcut = reportGridQueryForShortcut(shortcutId as keyof typeof shortcutFilters); + assert(JSON.stringify(shortcut.filters) === JSON.stringify(filters), `${shortcutId} uses the exact backend list filter`); + assert(shortcut.sort?.columnId === "number" && shortcut.sort.direction === "asc", `${shortcutId} restores stable report ordering`); +} + +const failedShortcut = toggleReportGridShortcut({ + sort: { columnId: "updated", direction: "desc" }, + filters: { recipient: "old search", imap: 'list:["appended"]' } +}, "failed"); +assert(JSON.stringify(failedShortcut.filters) === JSON.stringify(shortcutFilters.failed), "a shortcut clears unrelated filters"); +assert(activeReportGridShortcut(failedShortcut) === "failed", "the exact shortcut query is recognized as active"); +const toggledBack = toggleReportGridShortcut(failedShortcut, "failed"); +assert(Object.keys(toggledBack.filters).length === 0, "selecting an active count returns to all jobs"); +assert(activeReportGridShortcut(toggledBack) === "all", "the cleared query activates the all-jobs shortcut"); +const sortedFailedShortcut = { ...failedShortcut, sort: { columnId: "recipient", direction: "desc" as const } }; +assert(Object.keys(toggleReportGridShortcut(sortedFailedShortcut, "failed").filters).length === 0, "the active shortcut toggles to all even after the user changes sorting"); + const reportSource = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8"); assert(reportSource.includes('mode: "server"'), "the report DataGrid declares server query ownership"); assert(reportSource.includes("totalRows: jobs.total"), "the shared pagination count uses the filtered backend total"); assert(reportSource.includes("onQueryChange={handleJobGridQuery}"), "header sort and filter changes drive the backend query"); assert(reportSource.includes("initialReportGridFilters()"), "status deep links initialize the DataGrid filters"); assert(reportSource.includes("initialReportQuery()"), "q deep links initialize the report search"); +assert(reportSource.includes("query={jobGridQuery}"), "external count shortcuts synchronize the visible DataGrid query"); +assert(reportSource.includes('setQuery("");') && reportSource.includes('setAppliedQuery("");'), "count shortcuts clear both visible and applied search terms"); +assert(reportSource.includes("deliveryOutcomeShortcuts.map") && reportSource.includes("imapOutcomeShortcuts.map"), "top-level delivery counts use one coherent shortcut model"); +assert(reportSource.includes('