@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
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mailbox's intelligence keys act on the surface the width has (#840).
|
|
3
|
+
*
|
|
4
|
+
* `i` and `b` are registered by the pane provider, which sits above the shell
|
|
5
|
+
* and so cannot see the rail's own width gate. Both reached for the rail's
|
|
6
|
+
* toggle directly, and below 1280 that wrote `#intelligence` into the address
|
|
7
|
+
* with no rail mounted to render it — a panel the address names and nothing
|
|
8
|
+
* answers (`docs/architecture/url-state.md`, R6). The drawer is the surface at
|
|
9
|
+
* those widths, and it is what the toolbar's own control already used.
|
|
10
|
+
*
|
|
11
|
+
* Mounted in the real `AppShellSlotted` at all three tiers, with the `/mail`
|
|
12
|
+
* layout's binding between the fragment and the rail: which panes a width has
|
|
13
|
+
* is the shell's own answer here, and what the address holds is the router's.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
19
|
+
import {
|
|
20
|
+
type AnyRouter,
|
|
21
|
+
createMemoryHistory,
|
|
22
|
+
createRootRoute,
|
|
23
|
+
createRoute,
|
|
24
|
+
createRouter,
|
|
25
|
+
Outlet,
|
|
26
|
+
RouterProvider,
|
|
27
|
+
} from "@tanstack/react-router";
|
|
28
|
+
import { createElement, type ReactNode, useCallback } from "react";
|
|
29
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
30
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
31
|
+
import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
|
|
32
|
+
import { useOpenPanels, useOpenThreadPath, useSetOpenPanels } from "@/routing";
|
|
33
|
+
import {
|
|
34
|
+
createDomHarness,
|
|
35
|
+
type DomHarness,
|
|
36
|
+
type DomOptions,
|
|
37
|
+
} from "@/test-support/dom";
|
|
38
|
+
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
39
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
40
|
+
import {
|
|
41
|
+
describedMessage,
|
|
42
|
+
intelligenceDrawer,
|
|
43
|
+
MESSAGE_ID,
|
|
44
|
+
settle,
|
|
45
|
+
THREAD_ID,
|
|
46
|
+
} from "@/test-support/intelligence-surface";
|
|
47
|
+
import { MailboxPane } from "./MailboxPane";
|
|
48
|
+
|
|
49
|
+
/** Below the rail's 1280px gate, above the reading pane's 1024px one. */
|
|
50
|
+
const TWO_PANE_WIDTH = 1100;
|
|
51
|
+
/** Wide enough for the rail, which is where the fragment raises it. */
|
|
52
|
+
const RAIL_WIDTH = 1400;
|
|
53
|
+
/** One pane, where the list route mounts its phone view instead of the slots. */
|
|
54
|
+
const PHONE_WIDTH = 420;
|
|
55
|
+
|
|
56
|
+
const MAILBOX_ID = "mailbox-1";
|
|
57
|
+
const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* No DKIM mismatch: the auto-open raises the rail on its own where the rail
|
|
61
|
+
* fits, which would put `#intelligence` in the address before any key is
|
|
62
|
+
* pressed.
|
|
63
|
+
*/
|
|
64
|
+
const row = makeThreadMessage({
|
|
65
|
+
messageId: MESSAGE_ID,
|
|
66
|
+
threadId: THREAD_ID,
|
|
67
|
+
subject: "Your parcel could not be delivered",
|
|
68
|
+
fromName: "Mondial Relay",
|
|
69
|
+
fromEmail: "delivery.notice@gmail.example",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let harness: DomHarness | undefined;
|
|
73
|
+
let http: HttpMock | undefined;
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
harness?.close();
|
|
77
|
+
harness = undefined;
|
|
78
|
+
http?.restore();
|
|
79
|
+
http = undefined;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
83
|
+
// `window`.
|
|
84
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What `routes/mail.tsx` binds: the rail's open state is the fragment, and the
|
|
88
|
+
* toggle rewrites the fragment. The device preference is left out — nothing
|
|
89
|
+
* here is about the tier the address is silent on.
|
|
90
|
+
*/
|
|
91
|
+
function MailLayout({ children }: { children: ReactNode }) {
|
|
92
|
+
const openPanels = useOpenPanels();
|
|
93
|
+
const setOpenPanels = useSetOpenPanels();
|
|
94
|
+
const intelligenceOpen = openPanels.includes("intelligence");
|
|
95
|
+
const onToggleIntelligence = useCallback(() => {
|
|
96
|
+
setOpenPanels(intelligenceOpen ? [] : ["intelligence"]);
|
|
97
|
+
}, [intelligenceOpen, setOpenPanels]);
|
|
98
|
+
|
|
99
|
+
const value: MailContextValue = {
|
|
100
|
+
accounts: [],
|
|
101
|
+
mailboxNameIndex: new Map(),
|
|
102
|
+
accountNameIndex: new Map(),
|
|
103
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
104
|
+
searchQuery: "",
|
|
105
|
+
searchInput: "",
|
|
106
|
+
searchViewKey: "",
|
|
107
|
+
onSearchChange: () => {},
|
|
108
|
+
onSearchClear: () => {},
|
|
109
|
+
onSearchClearQuery: () => {},
|
|
110
|
+
intelligenceOpen,
|
|
111
|
+
onToggleIntelligence,
|
|
112
|
+
};
|
|
113
|
+
return createElement(MailContext.Provider, { value }, children);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The shell as `MailShell` mounts it: one pane carrying the phone view below
|
|
118
|
+
* the reading boundary, the slots above it, and the rail's visibility taken
|
|
119
|
+
* from the address the same way the layout takes it.
|
|
120
|
+
*/
|
|
121
|
+
function Shell({ width }: { width: number }) {
|
|
122
|
+
const intelligenceOpen = useOpenPanels().includes("intelligence");
|
|
123
|
+
if (width < 1024) {
|
|
124
|
+
return createElement(AppShellSlotted, {
|
|
125
|
+
initialWidth: width,
|
|
126
|
+
nav: null,
|
|
127
|
+
list: createElement(MailboxPane.Phone),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return createElement(AppShellSlotted, {
|
|
131
|
+
initialWidth: width,
|
|
132
|
+
nav: null,
|
|
133
|
+
list: null,
|
|
134
|
+
reading: createElement(MailboxPane.Reading),
|
|
135
|
+
intelligence: createElement(MailboxPane.Intelligence),
|
|
136
|
+
intelligenceOpen,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const testRouter = (width: number, href: string): AnyRouter => {
|
|
141
|
+
const rootRoute = createRootRoute({
|
|
142
|
+
component: () =>
|
|
143
|
+
createElement(
|
|
144
|
+
ComposeProvider,
|
|
145
|
+
null,
|
|
146
|
+
createElement(MailLayout, {
|
|
147
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
148
|
+
children: createElement(Outlet),
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
});
|
|
152
|
+
const mailRoute = createRoute({
|
|
153
|
+
getParentRoute: () => rootRoute,
|
|
154
|
+
path: "/mail",
|
|
155
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
156
|
+
component: Outlet,
|
|
157
|
+
});
|
|
158
|
+
const mailboxRoute = createRoute({
|
|
159
|
+
getParentRoute: () => mailRoute,
|
|
160
|
+
path: "/$mailboxId",
|
|
161
|
+
component: () =>
|
|
162
|
+
createElement(MailboxPane, {
|
|
163
|
+
mailboxId: MAILBOX_ID,
|
|
164
|
+
thread: useOpenThreadPath(),
|
|
165
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
166
|
+
children: createElement(Outlet),
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
const threadRoute = createRoute({
|
|
170
|
+
getParentRoute: () => mailboxRoute,
|
|
171
|
+
path: "$threadId",
|
|
172
|
+
component: Outlet,
|
|
173
|
+
});
|
|
174
|
+
const messageRoute = createRoute({
|
|
175
|
+
getParentRoute: () => threadRoute,
|
|
176
|
+
path: "$messageId",
|
|
177
|
+
component: () => createElement(Shell, { width }),
|
|
178
|
+
});
|
|
179
|
+
const routeTree = rootRoute.addChildren([
|
|
180
|
+
mailRoute.addChildren([
|
|
181
|
+
mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
182
|
+
]),
|
|
183
|
+
]);
|
|
184
|
+
return createRouter({
|
|
185
|
+
routeTree,
|
|
186
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
187
|
+
}) as unknown as AnyRouter;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const mountAt = async (
|
|
191
|
+
width: number,
|
|
192
|
+
options: DomOptions = {},
|
|
193
|
+
href: string = MESSAGE_PATH,
|
|
194
|
+
): Promise<[DomHarness, AnyRouter]> => {
|
|
195
|
+
http = mockFetch((call) => {
|
|
196
|
+
if (call.path.endsWith("/config")) return { accounts: [] };
|
|
197
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
198
|
+
return { items: [row] };
|
|
199
|
+
}
|
|
200
|
+
if (call.path.includes("/messages/")) return describedMessage;
|
|
201
|
+
if (call.path.includes("/threads")) return { items: [row] };
|
|
202
|
+
return { items: [] };
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const router = testRouter(width, href);
|
|
206
|
+
await router.load();
|
|
207
|
+
const mounted = createDomHarness({ viewportWidth: width, ...options });
|
|
208
|
+
harness = mounted;
|
|
209
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
210
|
+
await settle(mounted);
|
|
211
|
+
return [mounted, router];
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const press = async (mounted: DomHarness, key: string): Promise<void> => {
|
|
215
|
+
mounted.dispatch(
|
|
216
|
+
mounted.window,
|
|
217
|
+
new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
218
|
+
);
|
|
219
|
+
await settle(mounted);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The rail: the intelligence panel mounted in the shell's own row rather than
|
|
224
|
+
* inside the drawer, which puts the same panel behind a scrim.
|
|
225
|
+
*/
|
|
226
|
+
const rail = (mounted: DomHarness): HTMLElement | null => {
|
|
227
|
+
const pane = mounted.query("aside");
|
|
228
|
+
if (!pane) return null;
|
|
229
|
+
return pane.closest('[role="dialog"]') ? null : pane;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
describe("the intelligence keys reach the surface the width has (#840)", () => {
|
|
233
|
+
for (const [tier, width, options] of [
|
|
234
|
+
["between the reading pane and the rail", TWO_PANE_WIDTH, {}],
|
|
235
|
+
[
|
|
236
|
+
"on the phone",
|
|
237
|
+
PHONE_WIDTH,
|
|
238
|
+
{ pointer: "coarse", orientation: "portrait" } as DomOptions,
|
|
239
|
+
],
|
|
240
|
+
] as const) {
|
|
241
|
+
it(`${tier}, i opens the drawer and leaves the address alone`, async () => {
|
|
242
|
+
const [mounted, router] = await mountAt(width, options);
|
|
243
|
+
|
|
244
|
+
await press(mounted, "i");
|
|
245
|
+
|
|
246
|
+
assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
|
|
247
|
+
assert.equal(
|
|
248
|
+
router.state.location.hash,
|
|
249
|
+
"",
|
|
250
|
+
"the address named a panel this width cannot render",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it(`${tier}, a second i puts the drawer away`, async () => {
|
|
255
|
+
const [mounted] = await mountAt(width, options);
|
|
256
|
+
|
|
257
|
+
await press(mounted, "i");
|
|
258
|
+
assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
|
|
259
|
+
|
|
260
|
+
await press(mounted, "i");
|
|
261
|
+
assert.equal(
|
|
262
|
+
intelligenceDrawer(mounted),
|
|
263
|
+
null,
|
|
264
|
+
"the second press left it up",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it(`${tier}, b reaches block sender through the same drawer`, async () => {
|
|
269
|
+
const [mounted, router] = await mountAt(width, options);
|
|
270
|
+
|
|
271
|
+
await press(mounted, "b");
|
|
272
|
+
|
|
273
|
+
assert.ok(intelligenceDrawer(mounted), "pressing b opened nothing");
|
|
274
|
+
assert.equal(
|
|
275
|
+
router.state.location.hash,
|
|
276
|
+
"",
|
|
277
|
+
"the address named a panel this width cannot render",
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
it("still writes the fragment where the rail is the surface", async () => {
|
|
283
|
+
const [mounted, router] = await mountAt(RAIL_WIDTH);
|
|
284
|
+
|
|
285
|
+
await press(mounted, "i");
|
|
286
|
+
|
|
287
|
+
assert.equal(
|
|
288
|
+
router.state.location.hash,
|
|
289
|
+
"intelligence",
|
|
290
|
+
"the rail's own tier stopped writing the fragment",
|
|
291
|
+
);
|
|
292
|
+
assert.equal(
|
|
293
|
+
intelligenceDrawer(mounted),
|
|
294
|
+
null,
|
|
295
|
+
"the drawer came up where the rail is the surface",
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("writes the fragment for block sender where the rail is the surface", async () => {
|
|
300
|
+
const [mounted, router] = await mountAt(RAIL_WIDTH);
|
|
301
|
+
|
|
302
|
+
await press(mounted, "b");
|
|
303
|
+
|
|
304
|
+
assert.equal(router.state.location.hash, "intelligence");
|
|
305
|
+
assert.equal(
|
|
306
|
+
intelligenceDrawer(mounted),
|
|
307
|
+
null,
|
|
308
|
+
"the drawer came up where the rail is the surface",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What the fragment is worth at each width, on a cold load. The rail is the one
|
|
315
|
+
* renderer the name has, which is why the keys above must not write the name
|
|
316
|
+
* anywhere else.
|
|
317
|
+
*/
|
|
318
|
+
describe("#intelligence names a renderer only where the rail fits", () => {
|
|
319
|
+
it("mounts the rail where the address carries it and the width has room", async () => {
|
|
320
|
+
const [mounted] = await mountAt(
|
|
321
|
+
RAIL_WIDTH,
|
|
322
|
+
{},
|
|
323
|
+
`${MESSAGE_PATH}#intelligence`,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
assert.ok(rail(mounted), "the fragment raised no rail");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("mounts nothing for the same address below the rail's width", async () => {
|
|
330
|
+
const [mounted] = await mountAt(
|
|
331
|
+
TWO_PANE_WIDTH,
|
|
332
|
+
{},
|
|
333
|
+
`${MESSAGE_PATH}#intelligence`,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
assert.equal(rail(mounted), null, "a rail rendered where none fits");
|
|
337
|
+
assert.equal(
|
|
338
|
+
intelligenceDrawer(mounted),
|
|
339
|
+
null,
|
|
340
|
+
"the fragment raised the drawer, which belongs to the thread",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -32,15 +32,19 @@ import { type OpenThreadPath, useOpenThreadPath } from "@/routing";
|
|
|
32
32
|
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
33
33
|
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
34
34
|
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
35
|
+
import {
|
|
36
|
+
describedMessage,
|
|
37
|
+
intelligenceDrawer,
|
|
38
|
+
MESSAGE_ID,
|
|
39
|
+
settle,
|
|
40
|
+
THREAD_ID,
|
|
41
|
+
} from "@/test-support/intelligence-surface";
|
|
35
42
|
import { BriefPane } from "./BriefPane";
|
|
36
43
|
import { FlaggedPane } from "./FlaggedPane";
|
|
37
44
|
|
|
38
45
|
/** Below the rail's 1280px gate, above the reading pane's 1024px one. */
|
|
39
46
|
const TWO_PANE_WIDTH = 1100;
|
|
40
47
|
|
|
41
|
-
const THREAD_ID = "thread-1";
|
|
42
|
-
const MESSAGE_ID = "msg-1";
|
|
43
|
-
|
|
44
48
|
const SHOW_INTELLIGENCE = "Show intelligence sidebar";
|
|
45
49
|
const HIDE_INTELLIGENCE = "Hide intelligence sidebar";
|
|
46
50
|
|
|
@@ -60,24 +64,6 @@ const row = makeThreadMessage({
|
|
|
60
64
|
},
|
|
61
65
|
});
|
|
62
66
|
|
|
63
|
-
/** What the reading pane reads each message's own headers and body from. */
|
|
64
|
-
const describedMessage = {
|
|
65
|
-
messageId: MESSAGE_ID,
|
|
66
|
-
envelope: {
|
|
67
|
-
from: [
|
|
68
|
-
{
|
|
69
|
-
addressId: "addr-1",
|
|
70
|
-
name: "Mondial Relay",
|
|
71
|
-
email: "delivery.notice@gmail.example",
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
to: [],
|
|
75
|
-
cc: [],
|
|
76
|
-
bcc: [],
|
|
77
|
-
},
|
|
78
|
-
bodyParts: [],
|
|
79
|
-
};
|
|
80
|
-
|
|
81
67
|
let harness: DomHarness | undefined;
|
|
82
68
|
let http: HttpMock | undefined;
|
|
83
69
|
|
|
@@ -197,15 +183,6 @@ const mount = async (pane: PaneUnderTest): Promise<DomHarness> => {
|
|
|
197
183
|
return mounted;
|
|
198
184
|
};
|
|
199
185
|
|
|
200
|
-
const settle = async (mounted: DomHarness): Promise<void> => {
|
|
201
|
-
await mounted.flush();
|
|
202
|
-
await mounted.wait(20);
|
|
203
|
-
await mounted.flush();
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const drawer = (mounted: DomHarness): HTMLElement | null =>
|
|
207
|
-
mounted.query('[role="dialog"][aria-label="Message details"]');
|
|
208
|
-
|
|
209
186
|
describe("intelligence is reachable wherever the reading pane mounts (#817)", () => {
|
|
210
187
|
for (const pane of panes) {
|
|
211
188
|
it(`${pane.name} offers a live toolbar control below the rail's width`, async () => {
|
|
@@ -221,7 +198,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
|
|
|
221
198
|
mounted.click(toggle);
|
|
222
199
|
await settle(mounted);
|
|
223
200
|
|
|
224
|
-
assert.ok(
|
|
201
|
+
assert.ok(intelligenceDrawer(mounted), "pressing it opened nothing");
|
|
225
202
|
assert.ok(
|
|
226
203
|
mounted.query(`[aria-label="${HIDE_INTELLIGENCE}"]`),
|
|
227
204
|
"the toolbar still reports the surface as closed",
|
|
@@ -235,7 +212,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
|
|
|
235
212
|
await settle(mounted);
|
|
236
213
|
|
|
237
214
|
assert.ok(
|
|
238
|
-
|
|
215
|
+
intelligenceDrawer(mounted),
|
|
239
216
|
"the banner's Why? reached no intelligence surface",
|
|
240
217
|
);
|
|
241
218
|
});
|
|
@@ -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
|
},
|