feat(campaign): filter reports from outcome counts
This commit is contained in:
@@ -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<Record<string, string | string[]>>(() => initialReportGridFilters());
|
||||
const initialGridQuery = useMemo<DataGridQueryState>(() => ({
|
||||
sort: DEFAULT_JOB_GRID_SORT,
|
||||
sort: DEFAULT_REPORT_GRID_SORT,
|
||||
filters: serializeInitialGridFilters(initialGridFilters)
|
||||
}), [initialGridFilters]);
|
||||
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(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:
|
||||
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19</dt><dd>{cards?.skipped ?? jobs.counts.send?.skipped ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||
const active = activeJobGridShortcut === shortcutId;
|
||||
return (
|
||||
<div key={shortcutId}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
|
||||
{value}
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_skipped.5a97b542</dt><dd>{cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0}</dd></div>
|
||||
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||
const active = activeJobGridShortcut === shortcutId;
|
||||
return (
|
||||
<div key={shortcutId}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
|
||||
{value}
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
||||
@@ -435,7 +486,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
getRowKey={(row: Record<string, unknown>) => 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";
|
||||
}
|
||||
|
||||
85
webui/src/features/campaigns/utils/reportGridShortcuts.ts
Normal file
85
webui/src/features/campaigns/utils/reportGridShortcuts.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type ReportGridQueryState = {
|
||||
sort: { columnId: string; direction: "asc" | "desc" } | null;
|
||||
filters: Record<string, string>;
|
||||
};
|
||||
|
||||
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<ReportGridShortcutId, Record<string, string>> = {
|
||||
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<string, string>, right: Record<string, string>): 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;
|
||||
}
|
||||
@@ -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('<Button type="button" variant={active ? "primary" : "ghost"}'), "count shortcuts render the central Button directly");
|
||||
assert(reportSource.includes('aria-pressed={active}'), "count shortcuts expose their selected state accessibly");
|
||||
assert(reportSource.includes('"skipped",\n"queued"'), "SMTP skipped is a first-class report filter option");
|
||||
assert(reportSource.includes("i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00"), "the report explains excluded transport semantics through the bilingual catalog");
|
||||
assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7"'), "the new skipped filter and status badges use the localized label");
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"include": [
|
||||
"tests/report-grid-query.test.ts",
|
||||
"src/features/campaigns/utils/jobListQuery.ts"
|
||||
"src/features/campaigns/utils/jobListQuery.ts",
|
||||
"src/features/campaigns/utils/reportGridShortcuts.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user