@remit/web-client 0.0.187 → 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/MailboxPane.tsx +35 -9
- package/src/components/mail/MessageList.tsx +3 -2
- package/src/components/mail/SelectionWizardHost.tsx +55 -20
- package/src/components/mail/intelligence-shortcut-surface.render.test.ts +343 -0
- package/src/components/mail/intelligence-surface-reach.render.test.ts +9 -32
- package/src/hooks/cross-account-bulk.render.test.ts +164 -0
- package/src/hooks/useEscalatedActions.ts +27 -13
- package/src/hooks/useIntelligenceSurface.ts +41 -7
- 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/src/test-support/intelligence-surface.ts +42 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useAppShellLayout } from "@remit/ui";
|
|
2
|
-
import { useCallback } from "react";
|
|
2
|
+
import { type RefObject, useCallback, useEffect } from "react";
|
|
3
3
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
4
4
|
import { useMailContext } from "@/lib/mail-context";
|
|
5
5
|
|
|
@@ -10,15 +10,11 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
10
10
|
* rail's own gate as the answer for both tiers and offered a disabled control
|
|
11
11
|
* with nothing behind it between 1024 and 1280 (#817).
|
|
12
12
|
*/
|
|
13
|
-
export interface IntelligenceSurface {
|
|
13
|
+
export interface IntelligenceSurface extends IntelligenceCommands {
|
|
14
14
|
/** There is a thread to say anything about, so the toolbar's control acts. */
|
|
15
15
|
canToggle: boolean;
|
|
16
16
|
/** What the toolbar reports: the rail above 1280, the drawer below it. */
|
|
17
17
|
isShowing: boolean;
|
|
18
|
-
/** The toolbar's control, over whichever surface this width has. */
|
|
19
|
-
toggle: () => void;
|
|
20
|
-
/** The banner's "Why?" — an open, never a close. */
|
|
21
|
-
open: () => void;
|
|
22
18
|
drawerOpen: boolean;
|
|
23
19
|
closeDrawer: () => void;
|
|
24
20
|
/** Whether this width has room for the rail, so the rail is the surface. */
|
|
@@ -27,6 +23,19 @@ export interface IntelligenceSurface {
|
|
|
27
23
|
openRail: () => void;
|
|
28
24
|
}
|
|
29
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What a mounted intelligence surface answers for. The width gate is the
|
|
28
|
+
* shell's own measurement, so only a component inside `AppShellSlotted` knows
|
|
29
|
+
* which surface this tier has; a pane provider wrapping the shell reaches it
|
|
30
|
+
* through a ref the mounted surface publishes into.
|
|
31
|
+
*/
|
|
32
|
+
export interface IntelligenceCommands {
|
|
33
|
+
/** The toolbar's control, over whichever surface this width has. */
|
|
34
|
+
toggle: () => void;
|
|
35
|
+
/** The banner's "Why?" — an open, never a close. */
|
|
36
|
+
open: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
export const useIntelligenceSurface = (
|
|
31
40
|
openThreadId: string | undefined,
|
|
32
41
|
): IntelligenceSurface => {
|
|
@@ -50,15 +59,40 @@ export const useIntelligenceSurface = (
|
|
|
50
59
|
if (!railFits || intelligenceOpen) return;
|
|
51
60
|
onToggleIntelligence();
|
|
52
61
|
}, [railFits, intelligenceOpen, onToggleIntelligence]);
|
|
62
|
+
const open = useCallback(() => {
|
|
63
|
+
if (!railFits) {
|
|
64
|
+
openDrawer();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
openRail();
|
|
68
|
+
}, [railFits, openDrawer, openRail]);
|
|
53
69
|
|
|
54
70
|
return {
|
|
55
71
|
canToggle: threadId !== null,
|
|
56
72
|
isShowing: threadId !== null && (railFits ? intelligenceOpen : drawerOpen),
|
|
57
73
|
toggle,
|
|
58
|
-
open
|
|
74
|
+
open,
|
|
59
75
|
drawerOpen,
|
|
60
76
|
closeDrawer: drawer.close,
|
|
61
77
|
railFits,
|
|
62
78
|
openRail,
|
|
63
79
|
};
|
|
64
80
|
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Publish the mounted surface for the pane provider's keyboard handlers. Null
|
|
84
|
+
* while nothing is mounted to serve them, so a shortcut fired with no surface
|
|
85
|
+
* reaches nothing rather than writing `#intelligence` at a width that cannot
|
|
86
|
+
* render it (`docs/architecture/url-state.md`, R6).
|
|
87
|
+
*/
|
|
88
|
+
export const usePublishIntelligenceCommands = (
|
|
89
|
+
ref: RefObject<IntelligenceCommands | null>,
|
|
90
|
+
{ toggle, open }: IntelligenceCommands,
|
|
91
|
+
): void => {
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
ref.current = { toggle, open };
|
|
94
|
+
return () => {
|
|
95
|
+
ref.current = null;
|
|
96
|
+
};
|
|
97
|
+
}, [ref, toggle, open]);
|
|
98
|
+
};
|
|
@@ -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
|
+
}));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every intelligence-surface spec needs to poke one: the message the
|
|
3
|
+
* reading pane reads its headers from, the settle the router and the query
|
|
4
|
+
* client both need, and the drawer as the DOM names it.
|
|
5
|
+
*
|
|
6
|
+
* The thread row itself is each spec's own, because the DKIM mismatch that
|
|
7
|
+
* raises the authenticity banner also raises the rail wherever the rail fits.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DomHarness } from "@/test-support/dom";
|
|
11
|
+
|
|
12
|
+
export const THREAD_ID = "thread-1";
|
|
13
|
+
export const MESSAGE_ID = "msg-1";
|
|
14
|
+
|
|
15
|
+
/** What the reading pane reads each message's own headers and body from. */
|
|
16
|
+
export const describedMessage = {
|
|
17
|
+
messageId: MESSAGE_ID,
|
|
18
|
+
envelope: {
|
|
19
|
+
from: [
|
|
20
|
+
{
|
|
21
|
+
addressId: "addr-1",
|
|
22
|
+
name: "Mondial Relay",
|
|
23
|
+
email: "delivery.notice@gmail.example",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
to: [],
|
|
27
|
+
cc: [],
|
|
28
|
+
bcc: [],
|
|
29
|
+
},
|
|
30
|
+
bodyParts: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Let the router commit, the queries land, and the render that follows run. */
|
|
34
|
+
export const settle = async (mounted: DomHarness): Promise<void> => {
|
|
35
|
+
await mounted.flush();
|
|
36
|
+
await mounted.wait(20);
|
|
37
|
+
await mounted.flush();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The intelligence drawer, by the role and label it publishes. */
|
|
41
|
+
export const intelligenceDrawer = (mounted: DomHarness): HTMLElement | null =>
|
|
42
|
+
mounted.query('[role="dialog"][aria-label="Message details"]');
|