@remit/web-client 0.0.271 → 0.0.273
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/BlockedImagesNotice.render.test.ts +54 -5
- package/src/components/mail/BlockedImagesNotice.tsx +40 -18
- package/src/components/mail/BulkRunProvider.tsx +331 -0
- package/src/components/mail/MessageBody.blocked-sender.test.ts +193 -0
- package/src/components/mail/MessageBody.tsx +21 -2
- package/src/components/mail/MessageCard.blocked-sender.test.ts +239 -0
- package/src/components/mail/MessageCard.tsx +8 -1
- package/src/components/mail/MessageList.tsx +38 -23
- package/src/components/mail/SelectionWizardHost.tsx +57 -24
- package/src/components/mail/blocked-images-notice.stories.tsx +90 -0
- package/src/hooks/cross-account-bulk.render.test.ts +3 -3
- package/src/hooks/escalated-run-lifetime.render.test.ts +5 -4
- package/src/hooks/escalated-run-refusal.render.test.ts +219 -5
- package/src/hooks/escalated-run-survives-navigation.render.test.ts +524 -0
- package/src/hooks/useEscalatedActions.test.ts +1 -1
- package/src/hooks/useEscalatedActions.ts +93 -187
- package/src/lib/bulk-action-copy.ts +21 -0
- package/src/lib/bulk-actions.ts +32 -0
- package/src/routes/__root.tsx +16 -11
- package/src/test-support/bulk-run.ts +13 -0
- package/src/test-support/dom.ts +6 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
1
|
import {
|
|
3
2
|
messageBulkOperationsDeleteMessages,
|
|
4
3
|
messageBulkOperationsMoveMessages,
|
|
@@ -6,30 +5,22 @@ import {
|
|
|
6
5
|
threadOperationsSearchThreads,
|
|
7
6
|
} from "@remit/api-http-client/sdk.gen.ts";
|
|
8
7
|
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
9
|
-
import { useQueryClient } from "@tanstack/react-query";
|
|
10
8
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type BulkRunRequest,
|
|
11
|
+
type BulkRunSource,
|
|
12
|
+
useBulkRun,
|
|
13
|
+
} from "@/components/mail/BulkRunProvider";
|
|
12
14
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
15
|
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
type BulkActionProgress,
|
|
22
|
-
type BulkActionTarget,
|
|
23
|
-
type BulkRunOutcome,
|
|
24
|
-
type FetchIdsPage,
|
|
25
|
-
honestProgress,
|
|
26
|
-
runChunkedAction,
|
|
27
|
-
runPredicateAction,
|
|
16
|
+
import type {
|
|
17
|
+
ApplyBatch,
|
|
18
|
+
BulkActionProgress,
|
|
19
|
+
BulkActionTarget,
|
|
20
|
+
BulkRunStart,
|
|
21
|
+
EscalatedAction,
|
|
22
|
+
FetchIdsPage,
|
|
28
23
|
} from "@/lib/bulk-actions";
|
|
29
|
-
import {
|
|
30
|
-
invalidateThreadListQueries,
|
|
31
|
-
threadListCacheKeys,
|
|
32
|
-
} from "@/lib/thread-list-cache";
|
|
33
24
|
|
|
34
25
|
/** The predicate a search-scoped run re-issues on every page — the same
|
|
35
26
|
* filters the visible list is searching with, minus pagination/count knobs. */
|
|
@@ -45,16 +36,6 @@ export type EscalationSearchQuery = Pick<
|
|
|
45
36
|
| "category"
|
|
46
37
|
>;
|
|
47
38
|
|
|
48
|
-
/**
|
|
49
|
-
* What a bulk run applies to every batch it reaches (#114). Delete, move and
|
|
50
|
-
* mark-read differ only in the bulk call they issue and the caches that call
|
|
51
|
-
* invalidates; the paging, chunking, progress and cancellation are the same.
|
|
52
|
-
*/
|
|
53
|
-
export type EscalatedAction =
|
|
54
|
-
| { kind: "delete" }
|
|
55
|
-
| { kind: "move"; destinationMailboxId: string }
|
|
56
|
-
| { kind: "markRead" };
|
|
57
|
-
|
|
58
39
|
/** Page size for the execution loop. Set to the write side's own 100-id cap so
|
|
59
40
|
* an execution page IS a write chunk — no in-memory accumulation step between
|
|
60
41
|
* reading ids and sending them. */
|
|
@@ -67,6 +48,8 @@ export type EscalationPhase =
|
|
|
67
48
|
|
|
68
49
|
interface UseEscalatedActionsOptions {
|
|
69
50
|
mailboxId: string;
|
|
51
|
+
/** The mailbox in the user's words, for a commit refused while this runs. */
|
|
52
|
+
mailboxLabel?: string;
|
|
70
53
|
/** Owning account, forwarded to the unseen-count invalidation on completion. */
|
|
71
54
|
accountId?: string;
|
|
72
55
|
/** Disables escalation entirely (e.g. not searching). Resets any in-flight
|
|
@@ -76,6 +59,12 @@ interface UseEscalatedActionsOptions {
|
|
|
76
59
|
* changes (a different search is a different question). */
|
|
77
60
|
predicateKey: string;
|
|
78
61
|
searchQuery: EscalationSearchQuery;
|
|
62
|
+
/**
|
|
63
|
+
* States how a run ended, for a user who is no longer looking at any screen
|
|
64
|
+
* that could show it. Handed to the run's owner rather than called here, so
|
|
65
|
+
* an ending that lands after this hook unmounted is still said (#112).
|
|
66
|
+
*/
|
|
67
|
+
reportEnding?: BulkRunRequest["reportEnding"];
|
|
79
68
|
}
|
|
80
69
|
|
|
81
70
|
export interface UseEscalatedActionsResult {
|
|
@@ -84,11 +73,14 @@ export interface UseEscalatedActionsResult {
|
|
|
84
73
|
* selection to that predicate once it answers. */
|
|
85
74
|
escalate: () => void;
|
|
86
75
|
/**
|
|
87
|
-
* Stop whatever
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* A no-op when nothing is running.
|
|
91
|
-
*
|
|
76
|
+
* Stop whatever this mailbox has running — its count, and the run when the
|
|
77
|
+
* run is its own. The request in flight is aborted and nothing further leaves
|
|
78
|
+
* (#113); a delete the server has already accepted still applies, so the
|
|
79
|
+
* batch on the wire is what a stop is worth. A no-op when nothing is running.
|
|
80
|
+
* Cancelling a count here never reaches a run in another mailbox: that run is
|
|
81
|
+
* not what this screen is offering to stop, and ending it would be silent.
|
|
82
|
+
* The only thing that ends a run in flight: leaving the selection, the
|
|
83
|
+
* wizard, the search or the mailbox does not.
|
|
92
84
|
*/
|
|
93
85
|
stop: () => void;
|
|
94
86
|
/**
|
|
@@ -99,7 +91,7 @@ export interface UseEscalatedActionsResult {
|
|
|
99
91
|
*/
|
|
100
92
|
clear: () => void;
|
|
101
93
|
/** True while a chunked run (bounded->100 ids, or the escalated predicate)
|
|
102
|
-
* is in flight. */
|
|
94
|
+
* over this mailbox is in flight, whichever screen started it. */
|
|
103
95
|
isRunning: boolean;
|
|
104
96
|
/** The action currently in flight, for status and progress wording. */
|
|
105
97
|
runningAction: EscalatedAction | undefined;
|
|
@@ -112,61 +104,44 @@ export interface UseEscalatedActionsResult {
|
|
|
112
104
|
* as one batch the endpoint refuses whole (#872). Resolves once the run ends
|
|
113
105
|
* for any reason — cancelled, errored, or complete — with a
|
|
114
106
|
* `done`/`failedIds` outcome the caller reads to decide what is still
|
|
115
|
-
* outstanding
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
107
|
+
* outstanding, or with the refusal of a run that never started because
|
|
108
|
+
* another one is still going.
|
|
109
|
+
*
|
|
110
|
+
* The run itself belongs to `BulkRunProvider`, which outlives every screen
|
|
111
|
+
* that can show it: the caches, the refusal replay, the failure banner and
|
|
112
|
+
* the ending are its, so none of them are lost when the surface that started
|
|
113
|
+
* the run goes (#112).
|
|
119
114
|
*/
|
|
120
115
|
runAction: (
|
|
121
116
|
action: EscalatedAction,
|
|
122
117
|
targets?: readonly BulkActionTarget[],
|
|
123
|
-
|
|
118
|
+
claimEnding?: BulkRunRequest["claimEnding"],
|
|
119
|
+
) => Promise<BulkRunStart>;
|
|
124
120
|
}
|
|
125
121
|
|
|
126
|
-
/**
|
|
127
|
-
* The mailboxes whose cached listings a bulk run affects: the mailbox it ran
|
|
128
|
-
* over, plus a move's destination, which gains the messages the source loses.
|
|
129
|
-
*/
|
|
130
|
-
export const mailboxesTouchedBy = (
|
|
131
|
-
action: EscalatedAction,
|
|
132
|
-
mailboxId: string,
|
|
133
|
-
): string[] =>
|
|
134
|
-
action.kind === "move"
|
|
135
|
-
? [mailboxId, action.destinationMailboxId]
|
|
136
|
-
: [mailboxId];
|
|
137
|
-
|
|
138
122
|
export const useEscalatedActions = ({
|
|
139
123
|
mailboxId,
|
|
124
|
+
mailboxLabel,
|
|
140
125
|
accountId,
|
|
141
126
|
enabled,
|
|
142
127
|
predicateKey,
|
|
143
128
|
searchQuery,
|
|
129
|
+
reportEnding,
|
|
144
130
|
}: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
|
|
145
131
|
const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
>(undefined);
|
|
149
|
-
const
|
|
150
|
-
undefined,
|
|
151
|
-
);
|
|
152
|
-
// Cancellation is the signal every call the run makes is issued under, so a
|
|
153
|
-
// stop ends the request on the wire instead of waiting for it to come back
|
|
154
|
-
// (#113). Replaced, never reused, at the start of each count and each run.
|
|
155
|
-
const abortRef = useRef<AbortController | undefined>(undefined);
|
|
156
|
-
// True from the moment a run starts until its outcome is in hand. A run is
|
|
157
|
-
// mail already leaving the mailbox, so nothing that merely changes what the
|
|
158
|
-
// list is showing gets to end it — only `stop`.
|
|
159
|
-
const runningRef = useRef(false);
|
|
160
|
-
const queryClient = useQueryClient();
|
|
132
|
+
// The count's own signal, which is not the run's: leaving the search ends a
|
|
133
|
+
// count, and never a run (#112).
|
|
134
|
+
const countAbortRef = useRef<AbortController | undefined>(undefined);
|
|
135
|
+
const { run, start, stop: stopRun } = useBulkRun();
|
|
161
136
|
const { pushError } = useErrorBanners();
|
|
162
|
-
|
|
163
|
-
// The run
|
|
164
|
-
//
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
137
|
+
|
|
138
|
+
// The live run, when it is this mailbox's. A run is mail leaving one mailbox,
|
|
139
|
+
// so it reports on that mailbox's list — not on whichever list is on screen.
|
|
140
|
+
const activeRun = run?.mailboxId === mailboxId ? run : undefined;
|
|
141
|
+
const isRunning = activeRun !== undefined;
|
|
142
|
+
// Read by callbacks that must not close over a stale render's answer.
|
|
143
|
+
const isRunningRef = useRef(false);
|
|
144
|
+
isRunningRef.current = isRunning;
|
|
170
145
|
|
|
171
146
|
// A different search (or leaving search/desktop) makes any in-flight
|
|
172
147
|
// escalation meaningless — it would otherwise keep counting or offering to
|
|
@@ -175,7 +150,7 @@ export const useEscalatedActions = ({
|
|
|
175
150
|
// against and reports what it reached, wherever the list moved on to.
|
|
176
151
|
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
|
|
177
152
|
useEffect(() => {
|
|
178
|
-
|
|
153
|
+
countAbortRef.current?.abort();
|
|
179
154
|
setPhase({ kind: "idle" });
|
|
180
155
|
}, [enabled, predicateKey]);
|
|
181
156
|
|
|
@@ -252,36 +227,9 @@ export const useEscalatedActions = ({
|
|
|
252
227
|
[],
|
|
253
228
|
);
|
|
254
229
|
|
|
255
|
-
/**
|
|
256
|
-
* The unseen counts a run moved, per account. A cross-account selection has
|
|
257
|
-
* no single owning account — the surface leaves the option undefined exactly
|
|
258
|
-
* then — so the run's own targets are what name the accounts to refresh.
|
|
259
|
-
*/
|
|
260
|
-
const invalidateAfterRun = useCallback(
|
|
261
|
-
(action: EscalatedAction, targets: readonly BulkActionTarget[]) => {
|
|
262
|
-
invalidateThreadListQueries(
|
|
263
|
-
queryClient,
|
|
264
|
-
threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
|
|
265
|
-
);
|
|
266
|
-
const touched = new Set<string>();
|
|
267
|
-
if (accountId) touched.add(accountId);
|
|
268
|
-
for (const target of targets) {
|
|
269
|
-
if (target.accountId) touched.add(target.accountId);
|
|
270
|
-
}
|
|
271
|
-
for (const touchedAccountId of touched) {
|
|
272
|
-
queryClient.invalidateQueries({
|
|
273
|
-
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
274
|
-
path: { accountId: touchedAccountId },
|
|
275
|
-
}),
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
},
|
|
279
|
-
[queryClient, mailboxId, accountId],
|
|
280
|
-
);
|
|
281
|
-
|
|
282
230
|
const escalate = useCallback(() => {
|
|
283
231
|
const controller = new AbortController();
|
|
284
|
-
|
|
232
|
+
countAbortRef.current = controller;
|
|
285
233
|
setPhase({ kind: "counting" });
|
|
286
234
|
fetchMatchCount(controller.signal).then(
|
|
287
235
|
(total) => {
|
|
@@ -311,12 +259,13 @@ export const useEscalatedActions = ({
|
|
|
311
259
|
}, [fetchMatchCount, pushError]);
|
|
312
260
|
|
|
313
261
|
const stop = useCallback(() => {
|
|
314
|
-
|
|
315
|
-
|
|
262
|
+
countAbortRef.current?.abort();
|
|
263
|
+
if (isRunningRef.current) stopRun(mailboxId);
|
|
264
|
+
}, [stopRun, mailboxId]);
|
|
316
265
|
|
|
317
266
|
const clear = useCallback(() => {
|
|
318
|
-
if (
|
|
319
|
-
|
|
267
|
+
if (isRunningRef.current) return;
|
|
268
|
+
countAbortRef.current?.abort();
|
|
320
269
|
setPhase({ kind: "idle" });
|
|
321
270
|
}, []);
|
|
322
271
|
|
|
@@ -324,105 +273,62 @@ export const useEscalatedActions = ({
|
|
|
324
273
|
async (
|
|
325
274
|
action: EscalatedAction,
|
|
326
275
|
targets?: readonly BulkActionTarget[],
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
abortRef.current = controller;
|
|
330
|
-
runningRef.current = true;
|
|
331
|
-
setRunningAction(action);
|
|
276
|
+
claimEnding?: BulkRunRequest["claimEnding"],
|
|
277
|
+
): Promise<BulkRunStart> => {
|
|
332
278
|
// Read before the run clears the phase below, so a refusal can say how
|
|
333
279
|
// many messages the appointment's replay is about.
|
|
334
280
|
const matched =
|
|
335
281
|
targets?.length ?? (phase.kind === "escalated" ? phase.total : 0);
|
|
336
|
-
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
337
|
-
// predicate can match more by the time the run pages it than the count
|
|
338
|
-
// saw, and the bar must never show more done than out of.
|
|
339
|
-
const onProgress = (next: BulkActionProgress) =>
|
|
340
|
-
setProgress(honestProgress(next));
|
|
341
|
-
const applyBatch = applyBatchFor(action);
|
|
342
282
|
// The predicate as it read when the run was confirmed. The run outlives
|
|
343
283
|
// the screen that started it, so reading the live query on every page
|
|
344
284
|
// would let a search typed afterwards redirect what is being deleted.
|
|
345
|
-
const
|
|
285
|
+
const source: BulkRunSource =
|
|
286
|
+
targets !== undefined
|
|
287
|
+
? { kind: "targets", targets }
|
|
288
|
+
: {
|
|
289
|
+
kind: "predicate",
|
|
290
|
+
fetchPage: fetchPagesOf(searchQueryRef.current),
|
|
291
|
+
};
|
|
346
292
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
controller.signal,
|
|
359
|
-
)
|
|
360
|
-
: await runPredicateAction(
|
|
361
|
-
runPages,
|
|
362
|
-
phase.kind === "escalated" ? phase.total : 0,
|
|
363
|
-
applyBatch,
|
|
364
|
-
onProgress,
|
|
365
|
-
controller.signal,
|
|
366
|
-
);
|
|
367
|
-
} finally {
|
|
368
|
-
runningRef.current = false;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
setRunningAction(undefined);
|
|
372
|
-
setProgress(undefined);
|
|
373
|
-
setPhase({ kind: "idle" });
|
|
293
|
+
const started = await start({
|
|
294
|
+
action,
|
|
295
|
+
mailboxId,
|
|
296
|
+
mailboxLabel,
|
|
297
|
+
accountId,
|
|
298
|
+
matched,
|
|
299
|
+
source,
|
|
300
|
+
applyBatch: applyBatchFor(action),
|
|
301
|
+
reportEnding,
|
|
302
|
+
claimEnding,
|
|
303
|
+
});
|
|
374
304
|
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
? isFolderRoleRefusal(outcome.error)
|
|
382
|
-
: undefined;
|
|
383
|
-
if (refusal) {
|
|
384
|
-
requestAppointment({
|
|
385
|
-
accountId: refusal.accountId,
|
|
386
|
-
role: refusal.role,
|
|
387
|
-
reason: refusal.reason,
|
|
388
|
-
action: { kind: "delete", count: matched },
|
|
389
|
-
onAppointed: async () => {
|
|
390
|
-
await runRef.current(action, targets);
|
|
391
|
-
},
|
|
392
|
-
});
|
|
393
|
-
} else if (outcome.error) {
|
|
394
|
-
pushError(
|
|
395
|
-
buildMutationErrorBanner(
|
|
396
|
-
bulkActionFailureTitle(action.kind, outcome.done),
|
|
397
|
-
bulkActionFailureDetail(action.kind),
|
|
398
|
-
outcome.error,
|
|
399
|
-
),
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
if (outcome.done > 0) {
|
|
403
|
-
invalidateAfterRun(action, targets ?? []);
|
|
404
|
-
}
|
|
405
|
-
return outcome;
|
|
305
|
+
// The escalated selection was what the run was confirmed from, and the
|
|
306
|
+
// run has now happened to it. A refused commit leaves it standing: the
|
|
307
|
+
// selection is still the question, and stopping the other run is what
|
|
308
|
+
// makes it answerable.
|
|
309
|
+
if (started.kind === "ran") setPhase({ kind: "idle" });
|
|
310
|
+
return started;
|
|
406
311
|
},
|
|
407
312
|
[
|
|
408
313
|
applyBatchFor,
|
|
409
314
|
fetchPagesOf,
|
|
410
315
|
phase,
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
316
|
+
mailboxId,
|
|
317
|
+
mailboxLabel,
|
|
318
|
+
accountId,
|
|
319
|
+
reportEnding,
|
|
320
|
+
start,
|
|
414
321
|
],
|
|
415
322
|
);
|
|
416
|
-
runRef.current = runAction;
|
|
417
323
|
|
|
418
324
|
return {
|
|
419
325
|
phase,
|
|
420
326
|
escalate,
|
|
421
327
|
stop,
|
|
422
328
|
clear,
|
|
423
|
-
isRunning
|
|
424
|
-
runningAction,
|
|
425
|
-
progress,
|
|
329
|
+
isRunning,
|
|
330
|
+
runningAction: activeRun?.action,
|
|
331
|
+
progress: activeRun?.progress,
|
|
426
332
|
runAction,
|
|
427
333
|
};
|
|
428
334
|
};
|
|
@@ -115,6 +115,27 @@ export const bulkActionFailureTitle = (
|
|
|
115
115
|
export const bulkActionFailureDetail = (kind: BulkActionKind): string =>
|
|
116
116
|
failureDetail[kind];
|
|
117
117
|
|
|
118
|
+
const busyPhrase: Record<BulkActionKind, (count: string) => string> = {
|
|
119
|
+
delete: (count) => `A delete of ${count} messages`,
|
|
120
|
+
move: (count) => `A move of ${count} messages`,
|
|
121
|
+
markRead: (count) => `Marking ${count} messages as read`,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The commit that could not start, because the one still going owns the bar and
|
|
126
|
+
* the Stop it would need. It names the run in flight and where it is, so the
|
|
127
|
+
* user knows which screen to end it from rather than pressing a commit that
|
|
128
|
+
* silently does nothing.
|
|
129
|
+
*/
|
|
130
|
+
export const bulkRunBusyRefusal = (
|
|
131
|
+
kind: BulkActionKind,
|
|
132
|
+
matched: number,
|
|
133
|
+
mailboxLabel: string | undefined,
|
|
134
|
+
): string =>
|
|
135
|
+
`${busyPhrase[kind](formatNumber(matched))}${
|
|
136
|
+
mailboxLabel ? ` in ${mailboxLabel}` : ""
|
|
137
|
+
} is still running — stop it first.`;
|
|
138
|
+
|
|
118
139
|
/** Progress-bar tone: only delete is destructive. */
|
|
119
140
|
export const bulkActionProgressTone = (
|
|
120
141
|
kind: BulkActionKind,
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -34,6 +34,28 @@
|
|
|
34
34
|
|
|
35
35
|
export const BULK_ACTION_CHUNK_SIZE = 100;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* What a bulk run applies to every batch it reaches (#114). Delete, move and
|
|
39
|
+
* mark-read differ only in the bulk call they issue and the caches that call
|
|
40
|
+
* invalidates; the paging, chunking, progress and cancellation are the same.
|
|
41
|
+
*/
|
|
42
|
+
export type EscalatedAction =
|
|
43
|
+
| { kind: "delete" }
|
|
44
|
+
| { kind: "move"; destinationMailboxId: string }
|
|
45
|
+
| { kind: "markRead" };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The mailboxes whose cached listings a bulk run affects: the mailbox it ran
|
|
49
|
+
* over, plus a move's destination, which gains the messages the source loses.
|
|
50
|
+
*/
|
|
51
|
+
export const mailboxesTouchedBy = (
|
|
52
|
+
action: EscalatedAction,
|
|
53
|
+
mailboxId: string,
|
|
54
|
+
): string[] =>
|
|
55
|
+
action.kind === "move"
|
|
56
|
+
? [mailboxId, action.destinationMailboxId]
|
|
57
|
+
: [mailboxId];
|
|
58
|
+
|
|
37
59
|
/** For a run with no Stop control to wire a signal to. */
|
|
38
60
|
const NEVER_ABORTED = new AbortController().signal;
|
|
39
61
|
|
|
@@ -323,3 +345,13 @@ export interface BulkRunOutcome {
|
|
|
323
345
|
cancelled: boolean;
|
|
324
346
|
error?: unknown;
|
|
325
347
|
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* What asking for a run got: the run itself, or the reason there is none. One
|
|
351
|
+
* run at a time is the design — a second one would page a second predicate
|
|
352
|
+
* behind the same bar and the same Stop — so a commit pressed while one is
|
|
353
|
+
* going is answered with what is going rather than with a run that replaces it.
|
|
354
|
+
*/
|
|
355
|
+
export type BulkRunStart =
|
|
356
|
+
| { kind: "ran"; outcome: BulkRunOutcome }
|
|
357
|
+
| { kind: "refused"; reason: string };
|
package/src/routes/__root.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Suspense } from "react";
|
|
|
7
7
|
import { useTranslation } from "react-i18next";
|
|
8
8
|
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
9
9
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
10
|
+
import { BulkRunProvider } from "@/components/mail/BulkRunProvider";
|
|
10
11
|
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
11
12
|
import { SelfUpdateOverlay } from "@/components/self-update/SelfUpdateOverlay";
|
|
12
13
|
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
@@ -59,17 +60,21 @@ function RootLayout() {
|
|
|
59
60
|
return (
|
|
60
61
|
<ErrorBannerProvider>
|
|
61
62
|
<RoleAppointmentPromptProvider>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
{/* A bulk run outlives the route that started it, so its owner sits
|
|
64
|
+
above the router rather than inside the list (#112). */}
|
|
65
|
+
<BulkRunProvider>
|
|
66
|
+
<SelfUpdateProvider>
|
|
67
|
+
<ComposeProvider>
|
|
68
|
+
<SkipLink />
|
|
69
|
+
<main id="main-content" className="h-dvh overflow-hidden">
|
|
70
|
+
<Suspense fallback={<AppShellSkeleton />}>
|
|
71
|
+
<Outlet />
|
|
72
|
+
</Suspense>
|
|
73
|
+
</main>
|
|
74
|
+
</ComposeProvider>
|
|
75
|
+
<SelfUpdateOverlay />
|
|
76
|
+
</SelfUpdateProvider>
|
|
77
|
+
</BulkRunProvider>
|
|
73
78
|
</RoleAppointmentPromptProvider>
|
|
74
79
|
<FatalErrorOverlay />
|
|
75
80
|
</ErrorBannerProvider>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BulkRunOutcome, BulkRunStart } from "../lib/bulk-actions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The outcome of a run a test meant to start. A refusal here is the test's own
|
|
5
|
+
* setup — another run of its making is still going — so it says so rather than
|
|
6
|
+
* failing on a missing property three assertions later.
|
|
7
|
+
*/
|
|
8
|
+
export const ranOutcome = (started: BulkRunStart): BulkRunOutcome => {
|
|
9
|
+
if (started.kind === "refused") {
|
|
10
|
+
throw new Error(`the run was refused: ${started.reason}`);
|
|
11
|
+
}
|
|
12
|
+
return started.outcome;
|
|
13
|
+
};
|
package/src/test-support/dom.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
10
10
|
import React, { act, createElement } from "react";
|
|
11
11
|
import { createRoot, type Root } from "react-dom/client";
|
|
12
12
|
import { DEFAULT_VIEWPORT, setViewport } from "../../test-support/dom-env.mjs";
|
|
13
|
+
import { BulkRunProvider } from "../components/mail/BulkRunProvider";
|
|
13
14
|
import { RoleAppointmentPromptProvider } from "../components/mail/RoleAppointmentPromptProvider";
|
|
14
15
|
import { ErrorBannerProvider } from "../components/ui/ErrorBannerProvider";
|
|
15
16
|
|
|
@@ -113,7 +114,11 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
|
|
|
113
114
|
createElement(
|
|
114
115
|
ErrorBannerProvider,
|
|
115
116
|
null,
|
|
116
|
-
createElement(
|
|
117
|
+
createElement(
|
|
118
|
+
RoleAppointmentPromptProvider,
|
|
119
|
+
null,
|
|
120
|
+
createElement(BulkRunProvider, null, element),
|
|
121
|
+
),
|
|
117
122
|
),
|
|
118
123
|
),
|
|
119
124
|
);
|