@remit/web-client 0.0.188 → 0.0.189
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/DailyBrief.tsx +4 -15
- package/src/components/mail/FlaggedList.tsx +4 -15
- package/src/components/mail/MessageList.tsx +3 -2
- package/src/components/mail/SelectionWizardHost.tsx +55 -20
- package/src/hooks/cross-account-bulk.render.test.ts +164 -0
- package/src/hooks/useEscalatedActions.ts +27 -13
- package/src/lib/bulk-actions.test.ts +170 -5
- package/src/lib/bulk-actions.ts +61 -4
- package/src/lib/wizard-selection.test.ts +50 -0
- package/src/lib/wizard-selection.ts +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.189",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -106,15 +106,13 @@ import {
|
|
|
106
106
|
type SelectionWizardControl,
|
|
107
107
|
useSelectionWizard,
|
|
108
108
|
} from "@/lib/wizard-history";
|
|
109
|
+
import { wizardSelectionFrom } from "@/lib/wizard-selection";
|
|
109
110
|
import type { OpenThreadTarget } from "@/routing";
|
|
110
111
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
111
112
|
import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
|
|
112
113
|
import type { MessageListCommands } from "./MessageList";
|
|
113
114
|
import { MessageRow } from "./MessageRow";
|
|
114
|
-
import {
|
|
115
|
-
SelectionWizardHost,
|
|
116
|
-
type WizardSelectionMessage,
|
|
117
|
-
} from "./SelectionWizardHost";
|
|
115
|
+
import { SelectionWizardHost } from "./SelectionWizardHost";
|
|
118
116
|
import {
|
|
119
117
|
type OpenMessageOptions,
|
|
120
118
|
ThreadListInteraction,
|
|
@@ -283,17 +281,8 @@ function BriefSelectionChrome({
|
|
|
283
281
|
);
|
|
284
282
|
// The ticked rows as the wizard reads them — the sample under every screen
|
|
285
283
|
// that names a match, and the senders its widen falls back to.
|
|
286
|
-
const wizardSelection = useMemo
|
|
287
|
-
() =>
|
|
288
|
-
rows
|
|
289
|
-
.filter((row) => selectedIds.has(row.id))
|
|
290
|
-
.map((row) => ({
|
|
291
|
-
id: row.id,
|
|
292
|
-
sender: row.fromName,
|
|
293
|
-
email: row.fromEmail,
|
|
294
|
-
subject: row.subject,
|
|
295
|
-
date: row.timeLabel,
|
|
296
|
-
})),
|
|
284
|
+
const wizardSelection = useMemo(
|
|
285
|
+
() => wizardSelectionFrom(rows, selectedIds),
|
|
297
286
|
[rows, selectedIds],
|
|
298
287
|
);
|
|
299
288
|
|
|
@@ -38,14 +38,12 @@ import { rowToSearchResult } from "@/lib/search-result";
|
|
|
38
38
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
39
39
|
import { dedupeByThread } from "@/lib/starred-rows";
|
|
40
40
|
import { useSelectionWizard } from "@/lib/wizard-history";
|
|
41
|
+
import { wizardSelectionFrom } from "@/lib/wizard-selection";
|
|
41
42
|
import type { OpenThreadTarget } from "@/routing";
|
|
42
43
|
import { MailViewChrome } from "./MailViewChrome";
|
|
43
44
|
import type { MessageListCommands } from "./MessageList";
|
|
44
45
|
import { MessageRow } from "./MessageRow";
|
|
45
|
-
import {
|
|
46
|
-
SelectionWizardHost,
|
|
47
|
-
type WizardSelectionMessage,
|
|
48
|
-
} from "./SelectionWizardHost";
|
|
46
|
+
import { SelectionWizardHost } from "./SelectionWizardHost";
|
|
49
47
|
import {
|
|
50
48
|
type OpenMessageOptions,
|
|
51
49
|
ThreadListInteraction,
|
|
@@ -78,17 +76,8 @@ function StarredWizardHost({
|
|
|
78
76
|
verb: Verb;
|
|
79
77
|
}) {
|
|
80
78
|
const { selectedIds, exitSelection } = useThreadListSelection();
|
|
81
|
-
const selection = useMemo
|
|
82
|
-
() =>
|
|
83
|
-
rows
|
|
84
|
-
.filter((row) => selectedIds.has(row.id))
|
|
85
|
-
.map((row) => ({
|
|
86
|
-
id: row.id,
|
|
87
|
-
sender: row.fromName,
|
|
88
|
-
email: row.fromEmail,
|
|
89
|
-
subject: row.subject,
|
|
90
|
-
date: row.timeLabel,
|
|
91
|
-
})),
|
|
79
|
+
const selection = useMemo(
|
|
80
|
+
() => wizardSelectionFrom(rows, selectedIds),
|
|
92
81
|
[rows, selectedIds],
|
|
93
82
|
);
|
|
94
83
|
return (
|
|
@@ -52,13 +52,13 @@ import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
52
52
|
import { listVerbRequest } from "@/lib/list-verb-request";
|
|
53
53
|
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
54
54
|
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
55
|
+
import type { WizardSelectionMessage } from "@/lib/wizard-selection";
|
|
55
56
|
import { useRetainOpenPanels } from "@/routing";
|
|
56
57
|
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
|
57
58
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
58
59
|
import {
|
|
59
60
|
type EscalatedSelection,
|
|
60
61
|
SelectionWizardHost,
|
|
61
|
-
type WizardSelectionMessage,
|
|
62
62
|
} from "./SelectionWizardHost";
|
|
63
63
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
64
64
|
|
|
@@ -497,10 +497,11 @@ export const MessageList = ({
|
|
|
497
497
|
email: thread.fromEmail ?? "",
|
|
498
498
|
subject: thread.subject ?? "(No subject)",
|
|
499
499
|
date: formatEmailDate(thread.sentDate),
|
|
500
|
+
accountId,
|
|
500
501
|
});
|
|
501
502
|
}
|
|
502
503
|
return rows;
|
|
503
|
-
}, [threads, selectedIds]);
|
|
504
|
+
}, [threads, selectedIds, accountId]);
|
|
504
505
|
const handleRowSelect = useCallback(
|
|
505
506
|
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
506
507
|
if (modifiers.shiftKey) {
|
|
@@ -47,7 +47,11 @@ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
|
47
47
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
48
48
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
49
49
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
50
|
-
import type {
|
|
50
|
+
import type {
|
|
51
|
+
BulkActionProgress,
|
|
52
|
+
BulkActionTarget,
|
|
53
|
+
BulkRunOutcome,
|
|
54
|
+
} from "@/lib/bulk-actions";
|
|
51
55
|
import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
|
|
52
56
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
53
57
|
import { useMailContext } from "@/lib/mail-context";
|
|
@@ -66,16 +70,11 @@ import {
|
|
|
66
70
|
import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
|
|
67
71
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
68
72
|
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
73
|
+
import type { WizardSelectionMessage } from "@/lib/wizard-selection";
|
|
69
74
|
import { organizeRunState } from "./organize-run-state";
|
|
70
75
|
|
|
71
76
|
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
|
|
72
77
|
|
|
73
|
-
/** A ticked row, as the wizard's samples and its clause prefill read it. */
|
|
74
|
-
export interface WizardSelectionMessage extends WizardMessage {
|
|
75
|
-
/** Sender address — the widen's literal fallback and the prefill match on it. */
|
|
76
|
-
email: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
78
|
interface SelectionWizardSessionProps extends SelectionWizardHostProps {
|
|
80
79
|
/** The step the URL holds. The session is mounted only while there is one. */
|
|
81
80
|
step: StepId;
|
|
@@ -307,7 +306,14 @@ function SelectionWizardSession({
|
|
|
307
306
|
OrganizeScope | undefined
|
|
308
307
|
>(undefined);
|
|
309
308
|
const [bulkRun, setBulkRun] = useState<
|
|
310
|
-
| {
|
|
309
|
+
| {
|
|
310
|
+
matched: number;
|
|
311
|
+
/** What the run was sent, so a retry of what it missed keeps each
|
|
312
|
+
* id's account. Empty for an escalated run, which has no id list. */
|
|
313
|
+
sent: readonly BulkActionTarget[];
|
|
314
|
+
outcome?: BulkRunOutcome;
|
|
315
|
+
failureReason?: string;
|
|
316
|
+
}
|
|
311
317
|
| undefined
|
|
312
318
|
>(undefined);
|
|
313
319
|
// The predicate the create chains its pass to. Undefined when the rule cannot
|
|
@@ -326,6 +332,16 @@ function SelectionWizardSession({
|
|
|
326
332
|
() => selection.map((message) => message.id),
|
|
327
333
|
[selection],
|
|
328
334
|
);
|
|
335
|
+
// What a bulk run over the ticked rows covers, each row still naming its
|
|
336
|
+
// account so the run can batch per account.
|
|
337
|
+
const bulkTargets = useMemo<BulkActionTarget[]>(
|
|
338
|
+
() =>
|
|
339
|
+
selection.map((message) => ({
|
|
340
|
+
id: message.id,
|
|
341
|
+
accountId: message.accountId,
|
|
342
|
+
})),
|
|
343
|
+
[selection],
|
|
344
|
+
);
|
|
329
345
|
const senders = useMemo(
|
|
330
346
|
() => selection.map((message) => message.email).filter(Boolean),
|
|
331
347
|
[selection],
|
|
@@ -586,19 +602,22 @@ function SelectionWizardSession({
|
|
|
586
602
|
}, [blockedReason, current, steps, goToStep]);
|
|
587
603
|
|
|
588
604
|
const runBulk = useCallback(
|
|
589
|
-
async (
|
|
605
|
+
async (targets: readonly BulkActionTarget[]) => {
|
|
590
606
|
const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
|
|
591
607
|
if (!action) {
|
|
592
608
|
setBulkRun({
|
|
593
|
-
matched:
|
|
609
|
+
matched: targets.length,
|
|
610
|
+
sent: targets,
|
|
594
611
|
failureReason: noDestinationReason(verb),
|
|
595
612
|
});
|
|
596
613
|
return;
|
|
597
614
|
}
|
|
598
|
-
setBulkRun({ matched:
|
|
599
|
-
const outcome = await runAction(action,
|
|
600
|
-
setBulkRun({ matched:
|
|
601
|
-
if (walkedAway.current)
|
|
615
|
+
setBulkRun({ matched: targets.length, sent: targets });
|
|
616
|
+
const outcome = await runAction(action, targets);
|
|
617
|
+
setBulkRun({ matched: targets.length, sent: targets, outcome });
|
|
618
|
+
if (walkedAway.current) {
|
|
619
|
+
onRunEnded?.(action.kind, targets.length, outcome);
|
|
620
|
+
}
|
|
602
621
|
},
|
|
603
622
|
[verb, named.moveMailboxId, junkMailboxId, runAction, onRunEnded],
|
|
604
623
|
);
|
|
@@ -612,13 +631,14 @@ function SelectionWizardSession({
|
|
|
612
631
|
if (!action) {
|
|
613
632
|
setBulkRun({
|
|
614
633
|
matched: escalated.total,
|
|
634
|
+
sent: [],
|
|
615
635
|
failureReason: noDestinationReason(verb),
|
|
616
636
|
});
|
|
617
637
|
return;
|
|
618
638
|
}
|
|
619
|
-
setBulkRun({ matched: escalated.total });
|
|
639
|
+
setBulkRun({ matched: escalated.total, sent: [] });
|
|
620
640
|
const outcome = await escalated.run(action);
|
|
621
|
-
setBulkRun({ matched: escalated.total, outcome });
|
|
641
|
+
setBulkRun({ matched: escalated.total, sent: [], outcome });
|
|
622
642
|
if (walkedAway.current) {
|
|
623
643
|
onRunEnded?.(action.kind, escalated.total, outcome);
|
|
624
644
|
}
|
|
@@ -670,7 +690,13 @@ function SelectionWizardSession({
|
|
|
670
690
|
startJob(organizeDraft);
|
|
671
691
|
return;
|
|
672
692
|
}
|
|
673
|
-
|
|
693
|
+
// A widened match is resolved by the account's own preview, so every id it
|
|
694
|
+
// returned belongs to the account the wizard is scoped to.
|
|
695
|
+
void runBulk(
|
|
696
|
+
scope === "just-these"
|
|
697
|
+
? bulkTargets
|
|
698
|
+
: matchedIds.map((id) => ({ id, accountId })),
|
|
699
|
+
);
|
|
674
700
|
}, [
|
|
675
701
|
escalated,
|
|
676
702
|
runEscalated,
|
|
@@ -678,7 +704,8 @@ function SelectionWizardSession({
|
|
|
678
704
|
named,
|
|
679
705
|
anchorMessageId,
|
|
680
706
|
verb,
|
|
681
|
-
|
|
707
|
+
accountId,
|
|
708
|
+
bulkTargets,
|
|
682
709
|
matchedIds,
|
|
683
710
|
createFilterAsync,
|
|
684
711
|
startJob,
|
|
@@ -850,8 +877,16 @@ function SelectionWizardSession({
|
|
|
850
877
|
sendCommit();
|
|
851
878
|
return;
|
|
852
879
|
}
|
|
853
|
-
const outstanding = bulkRun?.outcome?.failedIds ?? [];
|
|
854
|
-
|
|
880
|
+
const outstanding = new Set(bulkRun?.outcome?.failedIds ?? []);
|
|
881
|
+
// A run hands back ids, and each one's account came from the batch it was
|
|
882
|
+
// sent in — so a retry re-sends the targets the run was given, filtered to
|
|
883
|
+
// what it never reached.
|
|
884
|
+
const sent = bulkRun?.sent ?? bulkTargets;
|
|
885
|
+
void runBulk(
|
|
886
|
+
outstanding.size > 0
|
|
887
|
+
? sent.filter((target) => outstanding.has(target.id))
|
|
888
|
+
: sent,
|
|
889
|
+
);
|
|
855
890
|
};
|
|
856
891
|
|
|
857
892
|
// Cancel rewinds the entries the wizard owns and leaves the selection where
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brief and Flagged are the two cross-account lists, and both offer Delete
|
|
3
|
+
* and Mark read over whatever is ticked (#872).
|
|
4
|
+
*
|
|
5
|
+
* The bulk endpoints refuse a batch spanning accounts before applying any of
|
|
6
|
+
* it, so a selection with one row from each account deleted nothing at all and
|
|
7
|
+
* marked nothing read — the user got "Couldn't delete these messages" and no
|
|
8
|
+
* mail moved. The split has to happen where the call is made, so this mounts
|
|
9
|
+
* the real hook against the real fetch seam and reads the requests that
|
|
10
|
+
* actually left: every batch carries one account, and between them they carry
|
|
11
|
+
* every ticked row.
|
|
12
|
+
*
|
|
13
|
+
* Which account each ticked row belongs to is the surfaces' half of the same
|
|
14
|
+
* fix, and it is pinned in `../lib/wizard-selection.test.ts`. How the run
|
|
15
|
+
* sequences the batches it splits into — progress over the whole selection,
|
|
16
|
+
* cancellation at whichever boundary comes next — is `../lib/bulk-actions.test.ts`,
|
|
17
|
+
* where a batch is a value rather than a request in flight.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
22
|
+
import { act, createElement } from "react";
|
|
23
|
+
import type { BulkActionTarget } from "../lib/bulk-actions";
|
|
24
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
25
|
+
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
26
|
+
import {
|
|
27
|
+
type EscalatedAction,
|
|
28
|
+
type UseEscalatedActionsResult,
|
|
29
|
+
useEscalatedActions,
|
|
30
|
+
} from "./useEscalatedActions";
|
|
31
|
+
|
|
32
|
+
const ACCOUNT_A = "acc-work";
|
|
33
|
+
const ACCOUNT_B = "acc-personal";
|
|
34
|
+
|
|
35
|
+
/** A brief selection: two rows from one account, one from another. */
|
|
36
|
+
const MIXED: BulkActionTarget[] = [
|
|
37
|
+
{ id: "msg-work-1", accountId: ACCOUNT_A },
|
|
38
|
+
{ id: "msg-personal-1", accountId: ACCOUNT_B },
|
|
39
|
+
{ id: "msg-work-2", accountId: ACCOUNT_A },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
let harness: DomHarness | undefined;
|
|
43
|
+
let http: HttpMock;
|
|
44
|
+
|
|
45
|
+
const mountRunner = (): (() => UseEscalatedActionsResult) => {
|
|
46
|
+
let value: UseEscalatedActionsResult | undefined;
|
|
47
|
+
const Probe = () => {
|
|
48
|
+
value = useEscalatedActions({
|
|
49
|
+
// The brief's selection belongs to no single mailbox, which is how the
|
|
50
|
+
// wizard mounts this hook over a cross-account list.
|
|
51
|
+
mailboxId: "",
|
|
52
|
+
enabled: false,
|
|
53
|
+
predicateKey: "selection-wizard",
|
|
54
|
+
searchQuery: {},
|
|
55
|
+
});
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
harness = createDomHarness();
|
|
59
|
+
harness.renderApp(createElement(Probe));
|
|
60
|
+
return () => {
|
|
61
|
+
if (!value) throw new Error("hook did not render");
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Enough turns for a run of sequential batches to finish. */
|
|
67
|
+
const settle = async (): Promise<void> => {
|
|
68
|
+
if (!harness) throw new Error("nothing mounted");
|
|
69
|
+
for (let round = 0; round < 10; round += 1) await harness.flush();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const run = async (
|
|
73
|
+
hook: () => UseEscalatedActionsResult,
|
|
74
|
+
action: EscalatedAction,
|
|
75
|
+
targets: readonly BulkActionTarget[],
|
|
76
|
+
) => {
|
|
77
|
+
let started: ReturnType<UseEscalatedActionsResult["runAction"]> | undefined;
|
|
78
|
+
act(() => {
|
|
79
|
+
started = hook().runAction(action, targets);
|
|
80
|
+
});
|
|
81
|
+
await settle();
|
|
82
|
+
if (!started) throw new Error("the run never started");
|
|
83
|
+
return started;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The message-id list of every bulk request that left, in order. */
|
|
87
|
+
const batches = (suffix: string): string[][] =>
|
|
88
|
+
http
|
|
89
|
+
.to(suffix)
|
|
90
|
+
.map((call) => (call.body as { messageIds?: string[] })?.messageIds ?? []);
|
|
91
|
+
|
|
92
|
+
const accountOf = (id: string): string | undefined =>
|
|
93
|
+
MIXED.find((target) => target.id === id)?.accountId;
|
|
94
|
+
|
|
95
|
+
const assertOneBatchPerAccount = (sent: string[][]): void => {
|
|
96
|
+
assert.equal(sent.length, 2, "the selection was not split by account");
|
|
97
|
+
for (const batch of sent) {
|
|
98
|
+
assert.equal(
|
|
99
|
+
new Set(batch.map(accountOf)).size,
|
|
100
|
+
1,
|
|
101
|
+
"a batch spanned accounts, which the endpoint refuses whole",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
[...sent.flat()].sort(),
|
|
106
|
+
MIXED.map((target) => target.id).sort(),
|
|
107
|
+
"a ticked row was never sent",
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
http = mockFetch(() => ({ successCount: 1, failureCount: 0 }));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
harness?.close();
|
|
117
|
+
harness = undefined;
|
|
118
|
+
http.restore();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("a selection spanning accounts", () => {
|
|
122
|
+
it("deletes every ticked row, one batch per account", async () => {
|
|
123
|
+
const hook = mountRunner();
|
|
124
|
+
|
|
125
|
+
const outcome = await run(hook, { kind: "delete" }, MIXED);
|
|
126
|
+
|
|
127
|
+
assertOneBatchPerAccount(batches("/messages/delete"));
|
|
128
|
+
assert.equal(outcome.done, MIXED.length);
|
|
129
|
+
assert.deepEqual(outcome.failedIds, []);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("marks every ticked row read, one batch per account", async () => {
|
|
133
|
+
const hook = mountRunner();
|
|
134
|
+
|
|
135
|
+
const outcome = await run(hook, { kind: "markRead" }, MIXED);
|
|
136
|
+
|
|
137
|
+
assertOneBatchPerAccount(batches("/messages/flags"));
|
|
138
|
+
for (const call of http.calls) {
|
|
139
|
+
assert.equal((call.body as { isRead?: boolean })?.isRead, true);
|
|
140
|
+
}
|
|
141
|
+
assert.equal(outcome.done, MIXED.length);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reports how far it got when one account's batch fails", async () => {
|
|
145
|
+
http.restore();
|
|
146
|
+
http = mockFetch((call) =>
|
|
147
|
+
(call.body as { messageIds?: string[] })?.messageIds?.[0] ===
|
|
148
|
+
"msg-personal-1"
|
|
149
|
+
? httpError(409, "mailbox is locked")
|
|
150
|
+
: { successCount: 1, failureCount: 0 },
|
|
151
|
+
);
|
|
152
|
+
const hook = mountRunner();
|
|
153
|
+
|
|
154
|
+
const outcome = await run(hook, { kind: "delete" }, MIXED);
|
|
155
|
+
|
|
156
|
+
assert.equal(outcome.done, 2, "the account that succeeded is not counted");
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
outcome.failedIds,
|
|
159
|
+
["msg-personal-1"],
|
|
160
|
+
"the untouched rows are not handed back to retry",
|
|
161
|
+
);
|
|
162
|
+
assert.notEqual(outcome.error, undefined);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import {
|
|
18
18
|
type ApplyBatch,
|
|
19
19
|
type BulkActionProgress,
|
|
20
|
+
type BulkActionTarget,
|
|
20
21
|
type BulkRunOutcome,
|
|
21
22
|
type FetchIdsPage,
|
|
22
23
|
honestProgress,
|
|
@@ -100,18 +101,21 @@ export interface UseEscalatedActionsResult {
|
|
|
100
101
|
runningAction: EscalatedAction | undefined;
|
|
101
102
|
progress: BulkActionProgress | undefined;
|
|
102
103
|
/**
|
|
103
|
-
* Runs `action` in chunks. Pass `
|
|
104
|
-
* selection; omit
|
|
105
|
-
* be "escalated").
|
|
106
|
-
*
|
|
107
|
-
*
|
|
104
|
+
* Runs `action` in chunks. Pass `targets` for a materialized (bounded)
|
|
105
|
+
* selection; omit them to run against the escalated predicate (`phase` must
|
|
106
|
+
* be "escalated"). Each target names the account that owns it, so a
|
|
107
|
+
* selection spanning accounts is sent as one batch per account rather than
|
|
108
|
+
* as one batch the endpoint refuses whole (#872). Resolves once the run ends
|
|
109
|
+
* for any reason — cancelled, errored, or complete — with a
|
|
110
|
+
* `done`/`failedIds` outcome the caller reads to decide what is still
|
|
111
|
+
* outstanding.
|
|
108
112
|
* Infrastructure failures are reported through the app's existing
|
|
109
113
|
* escalation seam (`pushError`, which itself escalates a 5xx/exception to
|
|
110
114
|
* the fatal overlay) — not swallowed here.
|
|
111
115
|
*/
|
|
112
116
|
runAction: (
|
|
113
117
|
action: EscalatedAction,
|
|
114
|
-
|
|
118
|
+
targets?: readonly BulkActionTarget[],
|
|
115
119
|
) => Promise<BulkRunOutcome>;
|
|
116
120
|
}
|
|
117
121
|
|
|
@@ -225,16 +229,26 @@ export const useEscalatedActions = ({
|
|
|
225
229
|
[],
|
|
226
230
|
);
|
|
227
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The unseen counts a run moved, per account. A cross-account selection has
|
|
234
|
+
* no single owning account — the surface leaves the option undefined exactly
|
|
235
|
+
* then — so the run's own targets are what name the accounts to refresh.
|
|
236
|
+
*/
|
|
228
237
|
const invalidateAfterRun = useCallback(
|
|
229
|
-
(action: EscalatedAction) => {
|
|
238
|
+
(action: EscalatedAction, targets: readonly BulkActionTarget[]) => {
|
|
230
239
|
invalidateThreadListQueries(
|
|
231
240
|
queryClient,
|
|
232
241
|
threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
|
|
233
242
|
);
|
|
234
|
-
|
|
243
|
+
const touched = new Set<string>();
|
|
244
|
+
if (accountId) touched.add(accountId);
|
|
245
|
+
for (const target of targets) {
|
|
246
|
+
if (target.accountId) touched.add(target.accountId);
|
|
247
|
+
}
|
|
248
|
+
for (const touchedAccountId of touched) {
|
|
235
249
|
queryClient.invalidateQueries({
|
|
236
250
|
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
237
|
-
path: { accountId },
|
|
251
|
+
path: { accountId: touchedAccountId },
|
|
238
252
|
}),
|
|
239
253
|
});
|
|
240
254
|
}
|
|
@@ -279,7 +293,7 @@ export const useEscalatedActions = ({
|
|
|
279
293
|
const runAction = useCallback(
|
|
280
294
|
async (
|
|
281
295
|
action: EscalatedAction,
|
|
282
|
-
|
|
296
|
+
targets?: readonly BulkActionTarget[],
|
|
283
297
|
): Promise<BulkRunOutcome> => {
|
|
284
298
|
cancelRef.current = false;
|
|
285
299
|
runningRef.current = true;
|
|
@@ -301,9 +315,9 @@ export const useEscalatedActions = ({
|
|
|
301
315
|
let outcome: BulkRunOutcome;
|
|
302
316
|
try {
|
|
303
317
|
outcome =
|
|
304
|
-
|
|
318
|
+
targets !== undefined
|
|
305
319
|
? await runChunkedAction(
|
|
306
|
-
|
|
320
|
+
targets,
|
|
307
321
|
applyBatch,
|
|
308
322
|
onProgress,
|
|
309
323
|
() => cancelRef.current,
|
|
@@ -333,7 +347,7 @@ export const useEscalatedActions = ({
|
|
|
333
347
|
);
|
|
334
348
|
}
|
|
335
349
|
if (outcome.done > 0) {
|
|
336
|
-
invalidateAfterRun(action);
|
|
350
|
+
invalidateAfterRun(action, targets ?? []);
|
|
337
351
|
}
|
|
338
352
|
return outcome;
|
|
339
353
|
},
|
|
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
BULK_ACTION_CHUNK_SIZE,
|
|
5
|
+
type BulkActionTarget,
|
|
5
6
|
chunkIds,
|
|
7
|
+
chunkTargets,
|
|
6
8
|
type FetchIdsPageResult,
|
|
7
9
|
honestProgress,
|
|
8
10
|
runChunkedAction,
|
|
@@ -12,6 +14,13 @@ import {
|
|
|
12
14
|
const ids = (count: number, prefix = "m"): string[] =>
|
|
13
15
|
Array.from({ length: count }, (_, i) => `${prefix}${i}`);
|
|
14
16
|
|
|
17
|
+
/** Ids from one account, as a materialized selection hands them over. */
|
|
18
|
+
const targets = (
|
|
19
|
+
count: number,
|
|
20
|
+
accountId: string | undefined = undefined,
|
|
21
|
+
prefix = "m",
|
|
22
|
+
): BulkActionTarget[] => ids(count, prefix).map((id) => ({ id, accountId }));
|
|
23
|
+
|
|
15
24
|
describe("chunkIds", () => {
|
|
16
25
|
test("empty input yields no chunks", () => {
|
|
17
26
|
assert.deepEqual(chunkIds([]), []);
|
|
@@ -45,6 +54,63 @@ describe("chunkIds", () => {
|
|
|
45
54
|
});
|
|
46
55
|
});
|
|
47
56
|
|
|
57
|
+
describe("chunkTargets", () => {
|
|
58
|
+
// Regression for #872: the bulk endpoints reject a batch spanning accounts
|
|
59
|
+
// before applying any of it, and the brief and Flagged both span accounts.
|
|
60
|
+
test("never puts two accounts in one chunk", () => {
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
chunkTargets(
|
|
63
|
+
[
|
|
64
|
+
{ id: "a1", accountId: "acct-a" },
|
|
65
|
+
{ id: "b1", accountId: "acct-b" },
|
|
66
|
+
{ id: "a2", accountId: "acct-a" },
|
|
67
|
+
],
|
|
68
|
+
100,
|
|
69
|
+
),
|
|
70
|
+
[["a1", "a2"], ["b1"]],
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("chunks each account by size on its own", () => {
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
chunkTargets(
|
|
77
|
+
[
|
|
78
|
+
{ id: "a1", accountId: "acct-a" },
|
|
79
|
+
{ id: "a2", accountId: "acct-a" },
|
|
80
|
+
{ id: "a3", accountId: "acct-a" },
|
|
81
|
+
{ id: "b1", accountId: "acct-b" },
|
|
82
|
+
],
|
|
83
|
+
2,
|
|
84
|
+
),
|
|
85
|
+
[["a1", "a2"], ["a3"], ["b1"]],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("targets with no account keep their place in the run as a group of their own", () => {
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
chunkTargets(
|
|
92
|
+
[
|
|
93
|
+
{ id: "u1", accountId: undefined },
|
|
94
|
+
{ id: "a1", accountId: "acct-a" },
|
|
95
|
+
{ id: "u2", accountId: undefined },
|
|
96
|
+
],
|
|
97
|
+
100,
|
|
98
|
+
),
|
|
99
|
+
[["u1", "u2"], ["a1"]],
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a single-account selection is one run of chunks, as before", () => {
|
|
104
|
+
const got = chunkTargets(targets(BULK_ACTION_CHUNK_SIZE + 1, "acct-a"));
|
|
105
|
+
assert.equal(got.length, 2);
|
|
106
|
+
assert.equal(got[0].length, BULK_ACTION_CHUNK_SIZE);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("empty input yields no chunks", () => {
|
|
110
|
+
assert.deepEqual(chunkTargets([]), []);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
48
114
|
describe("runChunkedAction", () => {
|
|
49
115
|
const neverCancelled = () => false;
|
|
50
116
|
const noopProgress = () => undefined;
|
|
@@ -69,7 +135,7 @@ describe("runChunkedAction", () => {
|
|
|
69
135
|
});
|
|
70
136
|
|
|
71
137
|
test("sequences one call per 100-id chunk, in order", async () => {
|
|
72
|
-
const input =
|
|
138
|
+
const input = targets(BULK_ACTION_CHUNK_SIZE + 1);
|
|
73
139
|
const calls: string[][] = [];
|
|
74
140
|
const outcome = await runChunkedAction(
|
|
75
141
|
input,
|
|
@@ -88,7 +154,7 @@ describe("runChunkedAction", () => {
|
|
|
88
154
|
});
|
|
89
155
|
|
|
90
156
|
test("a returned batch counts every id in it as accepted", async () => {
|
|
91
|
-
const input =
|
|
157
|
+
const input = targets(5);
|
|
92
158
|
const outcome = await runChunkedAction(
|
|
93
159
|
input,
|
|
94
160
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
@@ -100,7 +166,7 @@ describe("runChunkedAction", () => {
|
|
|
100
166
|
});
|
|
101
167
|
|
|
102
168
|
test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
|
|
103
|
-
const input =
|
|
169
|
+
const input = targets(BULK_ACTION_CHUNK_SIZE * 3);
|
|
104
170
|
let calls = 0;
|
|
105
171
|
let cancelled = false;
|
|
106
172
|
const outcome = await runChunkedAction(
|
|
@@ -121,7 +187,7 @@ describe("runChunkedAction", () => {
|
|
|
121
187
|
});
|
|
122
188
|
|
|
123
189
|
test("an infra failure mid-run stops the run and reports the error", async () => {
|
|
124
|
-
const input =
|
|
190
|
+
const input = targets(BULK_ACTION_CHUNK_SIZE * 2);
|
|
125
191
|
const boom = new Error("network blip");
|
|
126
192
|
const outcome = await runChunkedAction(
|
|
127
193
|
input,
|
|
@@ -136,8 +202,107 @@ describe("runChunkedAction", () => {
|
|
|
136
202
|
assert.equal(outcome.failedIds.length, input.length);
|
|
137
203
|
});
|
|
138
204
|
|
|
205
|
+
test("a selection spanning accounts is sent as one batch per account", async () => {
|
|
206
|
+
const calls: string[][] = [];
|
|
207
|
+
const outcome = await runChunkedAction(
|
|
208
|
+
[
|
|
209
|
+
{ id: "a1", accountId: "acct-a" },
|
|
210
|
+
{ id: "b1", accountId: "acct-b" },
|
|
211
|
+
{ id: "a2", accountId: "acct-a" },
|
|
212
|
+
],
|
|
213
|
+
async (chunk) => {
|
|
214
|
+
calls.push(chunk);
|
|
215
|
+
return { successCount: chunk.length, failureCount: 0 };
|
|
216
|
+
},
|
|
217
|
+
noopProgress,
|
|
218
|
+
neverCancelled,
|
|
219
|
+
);
|
|
220
|
+
assert.deepEqual(calls, [["a1", "a2"], ["b1"]]);
|
|
221
|
+
assert.equal(outcome.done, 3);
|
|
222
|
+
assert.deepEqual(outcome.failedIds, []);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("progress counts toward the whole selection, not toward each account", async () => {
|
|
226
|
+
const seen: { done: number; total: number }[] = [];
|
|
227
|
+
await runChunkedAction(
|
|
228
|
+
[
|
|
229
|
+
{ id: "a1", accountId: "acct-a" },
|
|
230
|
+
{ id: "b1", accountId: "acct-b" },
|
|
231
|
+
],
|
|
232
|
+
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
233
|
+
(p) => seen.push(p),
|
|
234
|
+
neverCancelled,
|
|
235
|
+
);
|
|
236
|
+
assert.deepEqual(seen, [
|
|
237
|
+
{ done: 1, total: 2 },
|
|
238
|
+
{ done: 2, total: 2 },
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("cancelling at an account boundary hands back the accounts never reached", async () => {
|
|
243
|
+
let cancelled = false;
|
|
244
|
+
const outcome = await runChunkedAction(
|
|
245
|
+
[
|
|
246
|
+
{ id: "a1", accountId: "acct-a" },
|
|
247
|
+
{ id: "b1", accountId: "acct-b" },
|
|
248
|
+
{ id: "c1", accountId: "acct-c" },
|
|
249
|
+
],
|
|
250
|
+
async (chunk) => {
|
|
251
|
+
cancelled = true;
|
|
252
|
+
return { successCount: chunk.length, failureCount: 0 };
|
|
253
|
+
},
|
|
254
|
+
() => undefined,
|
|
255
|
+
() => cancelled,
|
|
256
|
+
);
|
|
257
|
+
assert.equal(outcome.cancelled, true);
|
|
258
|
+
assert.equal(outcome.done, 1);
|
|
259
|
+
assert.deepEqual(outcome.failedIds, ["b1", "c1"]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("one account's batch failing leaves the rest unsent and says how far it got", async () => {
|
|
263
|
+
const boom = new Error("500");
|
|
264
|
+
const outcome = await runChunkedAction(
|
|
265
|
+
[
|
|
266
|
+
{ id: "a1", accountId: "acct-a" },
|
|
267
|
+
{ id: "b1", accountId: "acct-b" },
|
|
268
|
+
],
|
|
269
|
+
async (chunk) => {
|
|
270
|
+
if (chunk[0] === "b1") throw boom;
|
|
271
|
+
return { successCount: chunk.length, failureCount: 0 };
|
|
272
|
+
},
|
|
273
|
+
() => undefined,
|
|
274
|
+
neverCancelled,
|
|
275
|
+
);
|
|
276
|
+
assert.equal(outcome.error, boom);
|
|
277
|
+
assert.equal(outcome.done, 1);
|
|
278
|
+
assert.deepEqual(outcome.failedIds, ["b1"]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("the accounts behind a failed one are handed back whole, never half-sent", async () => {
|
|
282
|
+
const calls: string[][] = [];
|
|
283
|
+
const outcome = await runChunkedAction(
|
|
284
|
+
[
|
|
285
|
+
{ id: "a1", accountId: "acct-a" },
|
|
286
|
+
{ id: "b1", accountId: "acct-b" },
|
|
287
|
+
{ id: "c1", accountId: "acct-c" },
|
|
288
|
+
],
|
|
289
|
+
async (chunk) => {
|
|
290
|
+
calls.push(chunk);
|
|
291
|
+
throw new Error("auth expired");
|
|
292
|
+
},
|
|
293
|
+
() => undefined,
|
|
294
|
+
neverCancelled,
|
|
295
|
+
);
|
|
296
|
+
// The run stops where it threw rather than trying the accounts behind it:
|
|
297
|
+
// what it hands back is exactly what is still untouched, which is what a
|
|
298
|
+
// retry re-sends.
|
|
299
|
+
assert.deepEqual(calls, [["a1"]]);
|
|
300
|
+
assert.equal(outcome.done, 0);
|
|
301
|
+
assert.deepEqual(outcome.failedIds, ["a1", "b1", "c1"]);
|
|
302
|
+
});
|
|
303
|
+
|
|
139
304
|
test("reports progress after each chunk", async () => {
|
|
140
|
-
const input =
|
|
305
|
+
const input = targets(BULK_ACTION_CHUNK_SIZE + 1);
|
|
141
306
|
const progressCalls: { done: number; total: number }[] = [];
|
|
142
307
|
await runChunkedAction(
|
|
143
308
|
input,
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -51,6 +51,53 @@ export const chunkIds = (
|
|
|
51
51
|
return chunks;
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
/** One message a run covers, as the surface that selected it knows it. */
|
|
55
|
+
export interface BulkActionTarget {
|
|
56
|
+
id: string;
|
|
57
|
+
/**
|
|
58
|
+
* Owning IMAP account — the `accountId` of the account API, never the
|
|
59
|
+
* caller's `accountConfigId` (#456). Undefined on a row from a per-mailbox
|
|
60
|
+
* listing, which does not carry one because every row in it shares the same
|
|
61
|
+
* account.
|
|
62
|
+
*/
|
|
63
|
+
accountId: string | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Split `targets` into chunks of at most `size` that each carry exactly one
|
|
68
|
+
* account (#872).
|
|
69
|
+
*
|
|
70
|
+
* The bulk endpoints reject a batch spanning accounts outright, before applying
|
|
71
|
+
* any of it, and the daily brief and Flagged are both cross-account lists — so
|
|
72
|
+
* a selection ticked across two accounts sent as one batch deleted nothing.
|
|
73
|
+
* Account is a property of the batch, not of the verb: delete and mark-read
|
|
74
|
+
* cover whatever was ticked, and this is where that becomes one call per
|
|
75
|
+
* account.
|
|
76
|
+
*
|
|
77
|
+
* Targets with no account form a group of their own, which keeps the id in the
|
|
78
|
+
* run rather than dropping a message the user asked to be acted on. A surface
|
|
79
|
+
* that carries no account is single-account by construction; one that carries
|
|
80
|
+
* it for some rows and not others sends the unattributed ones together, and if
|
|
81
|
+
* the server refuses that batch the run reports it like any other failure.
|
|
82
|
+
*
|
|
83
|
+
* Accounts and ids keep the order they were selected in.
|
|
84
|
+
*/
|
|
85
|
+
export const chunkTargets = (
|
|
86
|
+
targets: readonly BulkActionTarget[],
|
|
87
|
+
size = BULK_ACTION_CHUNK_SIZE,
|
|
88
|
+
): string[][] => {
|
|
89
|
+
const byAccount = new Map<string | undefined, string[]>();
|
|
90
|
+
for (const target of targets) {
|
|
91
|
+
const held = byAccount.get(target.accountId);
|
|
92
|
+
if (held) {
|
|
93
|
+
held.push(target.id);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
byAccount.set(target.accountId, [target.id]);
|
|
97
|
+
}
|
|
98
|
+
return [...byAccount.values()].flatMap((ids) => chunkIds(ids, size));
|
|
99
|
+
};
|
|
100
|
+
|
|
54
101
|
export interface BatchResult {
|
|
55
102
|
successCount: number;
|
|
56
103
|
failureCount: number;
|
|
@@ -93,15 +140,20 @@ export interface BulkActionOutcome {
|
|
|
93
140
|
* caller always gets back exactly the ids the action never reached, ready to
|
|
94
141
|
* retry as-is: every action here is idempotent (re-trashing, re-moving or
|
|
95
142
|
* re-marking a message it already applied to is a no-op).
|
|
143
|
+
*
|
|
144
|
+
* Chunks never mix accounts (see `chunkTargets`), and the run walks them as one
|
|
145
|
+
* sequence: cancellation lands at whichever boundary comes next, whether or not
|
|
146
|
+
* that is an account boundary, and progress counts toward the whole selection
|
|
147
|
+
* rather than restarting per account.
|
|
96
148
|
*/
|
|
97
149
|
export const runChunkedAction = async (
|
|
98
|
-
|
|
150
|
+
targets: readonly BulkActionTarget[],
|
|
99
151
|
applyBatch: ApplyBatch,
|
|
100
152
|
onProgress: (progress: BulkActionProgress) => void,
|
|
101
153
|
isCancelled: () => boolean,
|
|
102
154
|
): Promise<BulkActionOutcome> => {
|
|
103
|
-
const chunks =
|
|
104
|
-
const total =
|
|
155
|
+
const chunks = chunkTargets(targets);
|
|
156
|
+
const total = targets.length;
|
|
105
157
|
let done = 0;
|
|
106
158
|
const failedIds: string[] = [];
|
|
107
159
|
|
|
@@ -137,13 +189,18 @@ export const runChunkedAction = async (
|
|
|
137
189
|
* Each chunk is a full mutation of its own, so it keeps that hook's optimistic
|
|
138
190
|
* patch, rollback and error banner. The run stops at the first rejected chunk
|
|
139
191
|
* and reports nothing itself: the hook that owns the call has already raised it.
|
|
192
|
+
*
|
|
193
|
+
* Every call site on this path hands over ids from a single account — a
|
|
194
|
+
* focused row, or a selection the surface already scoped — so nothing here
|
|
195
|
+
* carries an account to split by. A caller with a cross-account selection
|
|
196
|
+
* belongs on `runChunkedAction`, whose targets name one (#872).
|
|
140
197
|
*/
|
|
141
198
|
export const runChunkedMutation = async (
|
|
142
199
|
ids: readonly string[],
|
|
143
200
|
send: (chunk: string[]) => Promise<unknown>,
|
|
144
201
|
): Promise<void> => {
|
|
145
202
|
await runChunkedAction(
|
|
146
|
-
ids,
|
|
203
|
+
ids.map((id) => ({ id, accountId: undefined })),
|
|
147
204
|
async (chunk) => {
|
|
148
205
|
await send(chunk);
|
|
149
206
|
return { successCount: chunk.length, failureCount: 0 };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { ThreadRowData } from "@remit/ui";
|
|
4
|
+
import { wizardSelectionFrom } from "./wizard-selection.js";
|
|
5
|
+
|
|
6
|
+
const row = (over: Partial<ThreadRowData> & { id: string }): ThreadRowData => ({
|
|
7
|
+
fromName: "Alice",
|
|
8
|
+
fromEmail: "alice@example.com",
|
|
9
|
+
subject: "Quarterly report",
|
|
10
|
+
snippet: "",
|
|
11
|
+
timeLabel: "Jan 1",
|
|
12
|
+
...over,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("the ticked rows the wizard is handed", () => {
|
|
16
|
+
// Regression for #872: a bulk run batches per account, and a row that
|
|
17
|
+
// arrives without its own account is one the run cannot place.
|
|
18
|
+
it("carries each row's own account, not the first one it saw", () => {
|
|
19
|
+
const got = wizardSelectionFrom(
|
|
20
|
+
[
|
|
21
|
+
row({ id: "m1", accountId: "acc-work" }),
|
|
22
|
+
row({ id: "m2", accountId: "acc-personal" }),
|
|
23
|
+
],
|
|
24
|
+
new Set(["m1", "m2"]),
|
|
25
|
+
);
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
got.map((message) => message.accountId),
|
|
28
|
+
["acc-work", "acc-personal"],
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps a row whose list carries no account rather than dropping it", () => {
|
|
33
|
+
const got = wizardSelectionFrom([row({ id: "m1" })], new Set(["m1"]));
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
got.map((message) => ({ id: message.id, accountId: message.accountId })),
|
|
36
|
+
[{ id: "m1", accountId: undefined }],
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("takes only the ticked rows", () => {
|
|
41
|
+
const got = wizardSelectionFrom(
|
|
42
|
+
[row({ id: "m1" }), row({ id: "m2" })],
|
|
43
|
+
new Set(["m2"]),
|
|
44
|
+
);
|
|
45
|
+
assert.deepEqual(
|
|
46
|
+
got.map((message) => message.id),
|
|
47
|
+
["m2"],
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ThreadRowData, WizardMessage } from "@remit/ui";
|
|
2
|
+
|
|
3
|
+
/** A ticked row, as the wizard's samples and its clause prefill read it. */
|
|
4
|
+
export interface WizardSelectionMessage extends WizardMessage {
|
|
5
|
+
/** Sender address — the widen's literal fallback and the prefill match on it. */
|
|
6
|
+
email: string;
|
|
7
|
+
/**
|
|
8
|
+
* Owning account, which a bulk run splits its batches by (#872) — the bulk
|
|
9
|
+
* endpoints refuse a batch spanning accounts before applying any of it.
|
|
10
|
+
* Stated by every surface rather than inferred: a per-mailbox list has one
|
|
11
|
+
* account for all its rows and carries none on the row, while the brief and
|
|
12
|
+
* Flagged span accounts and carry each row's own. Never `accountConfigId`,
|
|
13
|
+
* which every account of one user shares (#456).
|
|
14
|
+
*/
|
|
15
|
+
accountId: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The ticked rows of a thread list, as the wizard reads them. The brief and
|
|
20
|
+
* Flagged both list rows from every account and hand their selection over the
|
|
21
|
+
* same way, so they read it from here rather than each keeping a copy that has
|
|
22
|
+
* to learn about a new field twice.
|
|
23
|
+
*/
|
|
24
|
+
export const wizardSelectionFrom = (
|
|
25
|
+
rows: readonly ThreadRowData[],
|
|
26
|
+
selectedIds: ReadonlySet<string>,
|
|
27
|
+
): WizardSelectionMessage[] =>
|
|
28
|
+
rows
|
|
29
|
+
.filter((row) => selectedIds.has(row.id))
|
|
30
|
+
.map((row) => ({
|
|
31
|
+
id: row.id,
|
|
32
|
+
sender: row.fromName,
|
|
33
|
+
email: row.fromEmail,
|
|
34
|
+
subject: row.subject,
|
|
35
|
+
date: row.timeLabel,
|
|
36
|
+
accountId: row.accountId,
|
|
37
|
+
}));
|