@remit/web-client 0.0.270 → 0.0.272
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/BulkRunProvider.tsx +331 -0
- package/src/components/mail/MessageList.tsx +38 -23
- package/src/components/mail/SelectionWizardHost.tsx +57 -24
- 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/update-restart-escalation.render.test.ts +340 -0
- package/src/hooks/use-system-update.ts +49 -2
- 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/lib/error-classifier.test.ts +59 -0
- package/src/lib/error-classifier.ts +48 -2
- package/src/lib/self-update-state.test.ts +1 -0
- package/src/lib/self-update-state.ts +138 -30
- package/src/routes/__root.tsx +16 -11
- package/src/test-support/bulk-run.ts +13 -0
- package/src/test-support/dom.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.272",
|
|
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": {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
14
|
+
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
15
|
+
import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
|
|
16
|
+
import {
|
|
17
|
+
bulkActionFailureDetail,
|
|
18
|
+
bulkActionFailureTitle,
|
|
19
|
+
bulkRunBusyRefusal,
|
|
20
|
+
} from "@/lib/bulk-action-copy";
|
|
21
|
+
import {
|
|
22
|
+
type ApplyBatch,
|
|
23
|
+
type BulkActionProgress,
|
|
24
|
+
type BulkActionTarget,
|
|
25
|
+
type BulkRunOutcome,
|
|
26
|
+
type BulkRunStart,
|
|
27
|
+
type EscalatedAction,
|
|
28
|
+
type FetchIdsPage,
|
|
29
|
+
honestProgress,
|
|
30
|
+
mailboxesTouchedBy,
|
|
31
|
+
runChunkedAction,
|
|
32
|
+
runPredicateAction,
|
|
33
|
+
} from "@/lib/bulk-actions";
|
|
34
|
+
import {
|
|
35
|
+
invalidateThreadListQueries,
|
|
36
|
+
threadListCacheKeys,
|
|
37
|
+
} from "@/lib/thread-list-cache";
|
|
38
|
+
import { useRoleAppointmentPrompt } from "./RoleAppointmentPromptProvider";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What the run pages, which is the one thing a bounded and an escalated run do
|
|
42
|
+
* differently: a ticked selection is a list of ids the surface already holds,
|
|
43
|
+
* an escalated one is a predicate the server re-resolves page by page.
|
|
44
|
+
*/
|
|
45
|
+
export type BulkRunSource =
|
|
46
|
+
| { kind: "targets"; targets: readonly BulkActionTarget[] }
|
|
47
|
+
| { kind: "predicate"; fetchPage: FetchIdsPage };
|
|
48
|
+
|
|
49
|
+
export interface BulkRunRequest {
|
|
50
|
+
action: EscalatedAction;
|
|
51
|
+
/** The mailbox the run belongs to — where its progress shows on return. */
|
|
52
|
+
mailboxId: string;
|
|
53
|
+
/** That mailbox in the user's words, for a refusal that names this run. */
|
|
54
|
+
mailboxLabel?: string;
|
|
55
|
+
/** Owning account, for the unseen-count invalidation on completion. */
|
|
56
|
+
accountId: string | undefined;
|
|
57
|
+
/** The count the run was offered against, for the bar and for the ending. */
|
|
58
|
+
matched: number;
|
|
59
|
+
source: BulkRunSource;
|
|
60
|
+
/** Issues one batch of the action. Closed over the SDK, never over a screen. */
|
|
61
|
+
applyBatch: ApplyBatch;
|
|
62
|
+
/**
|
|
63
|
+
* States how the run ended, once no screen is left reporting on it in place.
|
|
64
|
+
* Called by the provider rather than by the caller, so an ending that lands
|
|
65
|
+
* after the surface unmounted is still said out loud (#112). Absent on a
|
|
66
|
+
* surface with nowhere to say it.
|
|
67
|
+
*/
|
|
68
|
+
reportEnding?: (
|
|
69
|
+
kind: EscalatedAction["kind"],
|
|
70
|
+
matched: number,
|
|
71
|
+
outcome: BulkRunOutcome,
|
|
72
|
+
) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Handed the release for this run's ending, for a screen that is showing the
|
|
75
|
+
* ending itself. The claim is made inside `start`, for the run it starts, so
|
|
76
|
+
* a commit that starts no run holds nothing over the ending of one that is
|
|
77
|
+
* already going (#112). Released when that screen goes.
|
|
78
|
+
*/
|
|
79
|
+
claimEnding?: (release: () => void) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A run in flight, as any screen reads it. */
|
|
83
|
+
export interface ActiveBulkRun {
|
|
84
|
+
action: EscalatedAction;
|
|
85
|
+
mailboxId: string;
|
|
86
|
+
/** Seeded from the count the run was offered against, so the bar has a
|
|
87
|
+
* denominator before the first batch comes back. */
|
|
88
|
+
progress: BulkActionProgress;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface BulkRunContextValue {
|
|
92
|
+
run: ActiveBulkRun | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Runs `request`, or refuses it because one is already going. There is one
|
|
95
|
+
* slot: a second run would page behind the first's bar and answer to the
|
|
96
|
+
* first's Stop, so it is refused with the run in flight named rather than
|
|
97
|
+
* queued or started alongside.
|
|
98
|
+
*/
|
|
99
|
+
start: (request: BulkRunRequest) => Promise<BulkRunStart>;
|
|
100
|
+
/**
|
|
101
|
+
* Ends the run `mailboxId` owns: the request on the wire is aborted and
|
|
102
|
+
* nothing more leaves. A stop aimed at any other mailbox is not this run's,
|
|
103
|
+
* and reaches nothing.
|
|
104
|
+
*/
|
|
105
|
+
stop: (mailboxId: string) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const BulkRunContext = createContext<BulkRunContextValue | undefined>(
|
|
109
|
+
undefined,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The owner of a chunked bulk run (#112).
|
|
114
|
+
*
|
|
115
|
+
* A run is mail leaving the mailbox, and it outlives every screen that can show
|
|
116
|
+
* it: the wizard invites the user to close it mid-run, and nothing stops them
|
|
117
|
+
* navigating to another mailbox on top of that. Held in the surface that started
|
|
118
|
+
* it, the progress, the count and the cancellation all unmounted with the route,
|
|
119
|
+
* so returning showed nothing, Stop reached nothing, and an ending that landed
|
|
120
|
+
* after the move was dropped in silence.
|
|
121
|
+
*
|
|
122
|
+
* Mounted at the root beside the other app-lifetime providers, this holds the
|
|
123
|
+
* run instead: its progress, its `AbortController`, the caches it invalidates,
|
|
124
|
+
* the refusal it replays and the ending it states. A screen reads the run and
|
|
125
|
+
* offers Stop; it never owns either.
|
|
126
|
+
*
|
|
127
|
+
* In memory and per tab. A run is a sequence of requests this tab is making, so
|
|
128
|
+
* a reload ends it — persisting the reading would show a bar for a run nothing
|
|
129
|
+
* is driving.
|
|
130
|
+
*/
|
|
131
|
+
export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
|
|
132
|
+
const [run, setRun] = useState<ActiveBulkRun | undefined>(undefined);
|
|
133
|
+
// The signal every call of the live run is issued under, so a stop ends the
|
|
134
|
+
// request on the wire instead of waiting for it to come back (#113).
|
|
135
|
+
const controllerRef = useRef<AbortController | undefined>(undefined);
|
|
136
|
+
// Identifies the live run across the awaits below, so a run that ended after
|
|
137
|
+
// the slot was taken from it — by a sign-out — writes nothing back.
|
|
138
|
+
const liveRunRef = useRef<symbol | undefined>(undefined);
|
|
139
|
+
// What is in the slot, read synchronously: two commits landing in one frame
|
|
140
|
+
// both have to see the first of them.
|
|
141
|
+
const liveRequestRef = useRef<BulkRunRequest | undefined>(undefined);
|
|
142
|
+
// The runs a screen is reporting on in place, by token — never a count of
|
|
143
|
+
// claims over runs in general, which any commit could hold against any run.
|
|
144
|
+
const claimsRef = useRef(new Set<symbol>());
|
|
145
|
+
const queryClient = useQueryClient();
|
|
146
|
+
const { pushError } = useErrorBanners();
|
|
147
|
+
const { requestAppointment } = useRoleAppointmentPrompt();
|
|
148
|
+
// The run replays itself once a folder is appointed, so it needs a handle on
|
|
149
|
+
// itself that does not make `start` its own dependency.
|
|
150
|
+
const startRef = useRef<BulkRunContextValue["start"]>(async () => ({
|
|
151
|
+
kind: "ran",
|
|
152
|
+
outcome: { done: 0, failedIds: [], cancelled: false },
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The listings a run changed, and the unseen counts it moved. A cross-account
|
|
157
|
+
* selection has no single owning account, so the run's own targets name the
|
|
158
|
+
* accounts to refresh alongside the one the surface named.
|
|
159
|
+
*/
|
|
160
|
+
const invalidateAfterRun = useCallback(
|
|
161
|
+
(request: BulkRunRequest) => {
|
|
162
|
+
invalidateThreadListQueries(
|
|
163
|
+
queryClient,
|
|
164
|
+
threadListCacheKeys(
|
|
165
|
+
mailboxesTouchedBy(request.action, request.mailboxId),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
const touched = new Set<string>();
|
|
169
|
+
if (request.accountId) touched.add(request.accountId);
|
|
170
|
+
if (request.source.kind === "targets") {
|
|
171
|
+
for (const target of request.source.targets) {
|
|
172
|
+
if (target.accountId) touched.add(target.accountId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const accountId of touched) {
|
|
176
|
+
queryClient.invalidateQueries({
|
|
177
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
178
|
+
path: { accountId },
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[queryClient],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const start = useCallback(
|
|
187
|
+
async (request: BulkRunRequest): Promise<BulkRunStart> => {
|
|
188
|
+
const inFlight = liveRequestRef.current;
|
|
189
|
+
if (inFlight) {
|
|
190
|
+
return {
|
|
191
|
+
kind: "refused",
|
|
192
|
+
reason: bulkRunBusyRefusal(
|
|
193
|
+
inFlight.action.kind,
|
|
194
|
+
inFlight.matched,
|
|
195
|
+
inFlight.mailboxLabel,
|
|
196
|
+
),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const token = Symbol("bulk-run");
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
controllerRef.current = controller;
|
|
202
|
+
liveRunRef.current = token;
|
|
203
|
+
liveRequestRef.current = request;
|
|
204
|
+
if (request.claimEnding) {
|
|
205
|
+
claimsRef.current.add(token);
|
|
206
|
+
request.claimEnding(() => claimsRef.current.delete(token));
|
|
207
|
+
}
|
|
208
|
+
setRun({
|
|
209
|
+
action: request.action,
|
|
210
|
+
mailboxId: request.mailboxId,
|
|
211
|
+
progress: { done: 0, total: request.matched },
|
|
212
|
+
});
|
|
213
|
+
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
214
|
+
// predicate can match more by the time the run pages it than the count
|
|
215
|
+
// saw, and the bar must never show more done than out of.
|
|
216
|
+
const onProgress = (next: BulkActionProgress) => {
|
|
217
|
+
if (liveRunRef.current !== token) return;
|
|
218
|
+
setRun((held) =>
|
|
219
|
+
held ? { ...held, progress: honestProgress(next) } : held,
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const outcome =
|
|
224
|
+
request.source.kind === "targets"
|
|
225
|
+
? await runChunkedAction(
|
|
226
|
+
request.source.targets,
|
|
227
|
+
request.applyBatch,
|
|
228
|
+
onProgress,
|
|
229
|
+
controller.signal,
|
|
230
|
+
)
|
|
231
|
+
: await runPredicateAction(
|
|
232
|
+
request.source.fetchPage,
|
|
233
|
+
request.matched,
|
|
234
|
+
request.applyBatch,
|
|
235
|
+
onProgress,
|
|
236
|
+
controller.signal,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const reportedInPlace = claimsRef.current.delete(token);
|
|
240
|
+
if (liveRunRef.current === token) {
|
|
241
|
+
liveRunRef.current = undefined;
|
|
242
|
+
liveRequestRef.current = undefined;
|
|
243
|
+
setRun(undefined);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A provenance refusal is answered by the appointment prompt exactly as
|
|
247
|
+
// it is on the single-row path (#887, #876). Left to the banner it
|
|
248
|
+
// arrives as the API's own sentence — "Appoint one under Settings ›
|
|
249
|
+
// Folder roles" — which asks the user to reassemble a select-all they
|
|
250
|
+
// have already made.
|
|
251
|
+
const refusal = outcome.error
|
|
252
|
+
? isFolderRoleRefusal(outcome.error)
|
|
253
|
+
: undefined;
|
|
254
|
+
if (refusal) {
|
|
255
|
+
requestAppointment({
|
|
256
|
+
accountId: refusal.accountId,
|
|
257
|
+
role: refusal.role,
|
|
258
|
+
reason: refusal.reason,
|
|
259
|
+
action: { kind: "delete", count: request.matched },
|
|
260
|
+
// The replay is a new run, and it claims nothing: the claim the
|
|
261
|
+
// original was started under belongs to a screen whose release has
|
|
262
|
+
// already fired or never will for this run, so re-claiming would
|
|
263
|
+
// leave the replay's ending held back by nobody and never said.
|
|
264
|
+
onAppointed: async () => {
|
|
265
|
+
const replayed = await startRef.current({
|
|
266
|
+
...request,
|
|
267
|
+
claimEnding: undefined,
|
|
268
|
+
});
|
|
269
|
+
if (replayed.kind === "refused") {
|
|
270
|
+
pushError({
|
|
271
|
+
title: bulkActionFailureTitle(request.action.kind, 0),
|
|
272
|
+
detail: replayed.reason,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
} else if (outcome.error) {
|
|
278
|
+
pushError(
|
|
279
|
+
buildMutationErrorBanner(
|
|
280
|
+
bulkActionFailureTitle(request.action.kind, outcome.done),
|
|
281
|
+
bulkActionFailureDetail(request.action.kind),
|
|
282
|
+
outcome.error,
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
// Nobody is left showing this run, so the ending is said where the user
|
|
287
|
+
// now is. A screen still reporting on it says it in place instead.
|
|
288
|
+
if (!reportedInPlace) {
|
|
289
|
+
request.reportEnding?.(request.action.kind, request.matched, outcome);
|
|
290
|
+
}
|
|
291
|
+
if (outcome.done > 0) invalidateAfterRun(request);
|
|
292
|
+
return { kind: "ran", outcome };
|
|
293
|
+
},
|
|
294
|
+
[invalidateAfterRun, pushError, requestAppointment],
|
|
295
|
+
);
|
|
296
|
+
startRef.current = start;
|
|
297
|
+
|
|
298
|
+
const stop = useCallback((mailboxId: string) => {
|
|
299
|
+
if (liveRequestRef.current?.mailboxId !== mailboxId) return;
|
|
300
|
+
controllerRef.current?.abort();
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
// A run belongs to the session that started it. Both auth shells gate the
|
|
304
|
+
// whole app on the session, so signing out or switching accounts takes this
|
|
305
|
+
// provider down with the router — and a run left paging past that would go on
|
|
306
|
+
// deleting mail for an account nobody is signed in to.
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const claims = claimsRef.current;
|
|
309
|
+
return () => {
|
|
310
|
+
controllerRef.current?.abort();
|
|
311
|
+
controllerRef.current = undefined;
|
|
312
|
+
liveRunRef.current = undefined;
|
|
313
|
+
liveRequestRef.current = undefined;
|
|
314
|
+
claims.clear();
|
|
315
|
+
};
|
|
316
|
+
}, []);
|
|
317
|
+
|
|
318
|
+
const value = useMemo(() => ({ run, start, stop }), [run, start, stop]);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<BulkRunContext.Provider value={value}>{children}</BulkRunContext.Provider>
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export const useBulkRun = (): BulkRunContextValue => {
|
|
326
|
+
const context = useContext(BulkRunContext);
|
|
327
|
+
if (!context) {
|
|
328
|
+
throw new Error("useBulkRun must be used within a BulkRunProvider");
|
|
329
|
+
}
|
|
330
|
+
return context;
|
|
331
|
+
};
|
|
@@ -24,7 +24,6 @@ import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
|
24
24
|
import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
25
25
|
import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
|
|
26
26
|
import {
|
|
27
|
-
type EscalatedAction,
|
|
28
27
|
type EscalationSearchQuery,
|
|
29
28
|
useEscalatedActions,
|
|
30
29
|
} from "@/hooks/useEscalatedActions";
|
|
@@ -355,21 +354,12 @@ export const MessageList = ({
|
|
|
355
354
|
// are loaded. `orderedIds` below feeds `allLoadedSelected`; declared after
|
|
356
355
|
// this hook so its callback deps stay simple — see the
|
|
357
356
|
// `orderedIds`/`handleRowSelect` block.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
predicateKey,
|
|
365
|
-
searchQuery: searchPredicate ?? {},
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
// How a run ended, for the user who is no longer looking at the run screen.
|
|
369
|
-
// Closing the wizard mid-run is a movement the screen invites, and the run
|
|
370
|
-
// keeps going past it — so the list is what states the ending, whether the
|
|
371
|
-
// run covered everything or stopped short of it. The wizard calls this only
|
|
372
|
-
// once the user has left it, so an ending is never said twice.
|
|
357
|
+
// How a run ended, for the user who is no longer looking at any screen that
|
|
358
|
+
// could show it. Closing the wizard mid-run is a movement the screen invites,
|
|
359
|
+
// and leaving the mailbox on top of that is one nothing prevents — the run
|
|
360
|
+
// keeps going past both, so this states the ending, whether the run covered
|
|
361
|
+
// everything or stopped short of it. The run's owner calls it only once no
|
|
362
|
+
// screen is left reporting in place, so an ending is never said twice.
|
|
373
363
|
const reportRunOutcome = useCallback(
|
|
374
364
|
(kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
|
|
375
365
|
const banner = runEndingBanner(kind, matched, outcome, deleteOutcome);
|
|
@@ -380,6 +370,18 @@ export const MessageList = ({
|
|
|
380
370
|
[pushError, deleteOutcome],
|
|
381
371
|
);
|
|
382
372
|
|
|
373
|
+
const escalationEnabled = isSearching && !!searchPredicate;
|
|
374
|
+
const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
|
|
375
|
+
const escalation = useEscalatedActions({
|
|
376
|
+
mailboxId,
|
|
377
|
+
mailboxLabel: listTitle,
|
|
378
|
+
accountId,
|
|
379
|
+
enabled: escalationEnabled,
|
|
380
|
+
predicateKey,
|
|
381
|
+
searchQuery: searchPredicate ?? {},
|
|
382
|
+
reportEnding: reportRunOutcome,
|
|
383
|
+
});
|
|
384
|
+
|
|
383
385
|
// The one way selection mode ends (#115): cancel, a completed delete or
|
|
384
386
|
// move, a plain click that collapses a range, switching mailboxes, the back
|
|
385
387
|
// gesture. Nothing calls the selection hook's `clearSelection` directly, so
|
|
@@ -690,7 +692,8 @@ export const MessageList = ({
|
|
|
690
692
|
scope: describeSearchScope(searchPredicate ?? {}),
|
|
691
693
|
total: escalation.phase.total,
|
|
692
694
|
searchQuery: searchPredicate ?? {},
|
|
693
|
-
run: (action
|
|
695
|
+
run: (action, claimEnding) =>
|
|
696
|
+
escalation.runAction(action, undefined, claimEnding),
|
|
694
697
|
stop: escalation.stop,
|
|
695
698
|
}
|
|
696
699
|
: undefined;
|
|
@@ -1086,10 +1089,16 @@ export const MessageList = ({
|
|
|
1086
1089
|
// The escalation-derived surface state — viewport-independent, fed to both
|
|
1087
1090
|
// the mobile sheet and the desktop toolbar so the two never diverge (#212).
|
|
1088
1091
|
const selectionIsBusy = isDeleting || isMoving || escalation.isRunning;
|
|
1092
|
+
// A run outlives the selection it came from, and leaving the mailbox and
|
|
1093
|
+
// coming back leaves no selection at all — so while one is in flight the run's
|
|
1094
|
+
// own count is what the bar is about, or the bar would be an empty header over
|
|
1095
|
+
// mail that is still being deleted (#112).
|
|
1089
1096
|
const selectionCount =
|
|
1090
1097
|
escalation.phase.kind === "escalated"
|
|
1091
1098
|
? escalation.phase.total
|
|
1092
|
-
:
|
|
1099
|
+
: escalation.progress
|
|
1100
|
+
? escalation.progress.total
|
|
1101
|
+
: selectedCount;
|
|
1093
1102
|
|
|
1094
1103
|
// A run reports on the bar from the moment it starts, not from its first
|
|
1095
1104
|
// finished batch: the wizard invites the user back here mid-run, and a run
|
|
@@ -1128,17 +1137,22 @@ export const MessageList = ({
|
|
|
1128
1137
|
: undefined;
|
|
1129
1138
|
|
|
1130
1139
|
// At most one escalation notice at a time, ranked by how actionable it is:
|
|
1131
|
-
//
|
|
1132
|
-
// otherwise a fresh escalation offer. The
|
|
1133
|
-
// layered on behind them below.
|
|
1140
|
+
// a run in flight and a count both carry Stop, and that always wins; then an
|
|
1141
|
+
// escalated selection's way out; otherwise a fresh escalation offer. The
|
|
1142
|
+
// (rare) cross-account move hint is layered on behind them below.
|
|
1143
|
+
//
|
|
1144
|
+
// Stop rides on the bar rather than only on the wizard's run screen because
|
|
1145
|
+
// the run outlives both the wizard and the route: a user who comes back to
|
|
1146
|
+
// the mailbox mid-run finds the progress there, and has to be able to end it
|
|
1147
|
+
// from the same place (#112).
|
|
1134
1148
|
const escalationNotice =
|
|
1135
|
-
escalation.phase.kind === "counting"
|
|
1149
|
+
escalation.isRunning || escalation.phase.kind === "counting"
|
|
1136
1150
|
? {
|
|
1137
1151
|
tone: "info" as const,
|
|
1138
1152
|
text: "",
|
|
1139
1153
|
action: { label: "Stop", onClick: escalation.stop },
|
|
1140
1154
|
}
|
|
1141
|
-
: escalation.phase.kind === "escalated"
|
|
1155
|
+
: escalation.phase.kind === "escalated"
|
|
1142
1156
|
? {
|
|
1143
1157
|
tone: "info" as const,
|
|
1144
1158
|
text: "",
|
|
@@ -1343,6 +1357,7 @@ export const MessageList = ({
|
|
|
1343
1357
|
verb={wizardVerb}
|
|
1344
1358
|
accountId={accountId}
|
|
1345
1359
|
mailboxId={mailboxId}
|
|
1360
|
+
mailboxLabel={listTitle}
|
|
1346
1361
|
selection={wizardSelection}
|
|
1347
1362
|
selectionRestriction={moveDisabledHint ? "spansAccounts" : undefined}
|
|
1348
1363
|
escalated={escalatedSelection}
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
40
40
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
41
41
|
import {
|
|
42
|
-
type EscalatedAction,
|
|
43
42
|
type EscalationSearchQuery,
|
|
44
43
|
useEscalatedActions,
|
|
45
44
|
} from "@/hooks/useEscalatedActions";
|
|
@@ -55,6 +54,8 @@ import type {
|
|
|
55
54
|
BulkActionProgress,
|
|
56
55
|
BulkActionTarget,
|
|
57
56
|
BulkRunOutcome,
|
|
57
|
+
BulkRunStart,
|
|
58
|
+
EscalatedAction,
|
|
58
59
|
} from "@/lib/bulk-actions";
|
|
59
60
|
import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
|
|
60
61
|
import { useMailContext } from "@/lib/mail-context";
|
|
@@ -122,7 +123,15 @@ export interface EscalatedSelection {
|
|
|
122
123
|
* screen is what now stands in front of it. The run belongs to the list, so
|
|
123
124
|
* it survives the wizard closing over it — as the run screen promises.
|
|
124
125
|
*/
|
|
125
|
-
run: (
|
|
126
|
+
run: (
|
|
127
|
+
action: EscalatedAction,
|
|
128
|
+
/**
|
|
129
|
+
* Handed the release for the ending of the run it starts, so the wizard's
|
|
130
|
+
* run screen states that ending in place and the list does not banner it a
|
|
131
|
+
* second time (#112).
|
|
132
|
+
*/
|
|
133
|
+
claimEnding: (release: () => void) => void,
|
|
134
|
+
) => Promise<BulkRunStart>;
|
|
126
135
|
/** Ends that run at the next page boundary, which leaving the wizard does not. */
|
|
127
136
|
stop: () => void;
|
|
128
137
|
}
|
|
@@ -134,6 +143,8 @@ export interface SelectionWizardHostProps {
|
|
|
134
143
|
accountId?: string;
|
|
135
144
|
/** Where the selection was made, so a run invalidates the listing it changed. */
|
|
136
145
|
mailboxId?: string;
|
|
146
|
+
/** That mailbox in the user's words, for a commit refused while it runs. */
|
|
147
|
+
mailboxLabel?: string;
|
|
137
148
|
selection: readonly WizardSelectionMessage[];
|
|
138
149
|
/**
|
|
139
150
|
* Which scope the ticked rows span more of than the folder and rule steps can
|
|
@@ -154,10 +165,11 @@ export interface SelectionWizardHostProps {
|
|
|
154
165
|
/** The wizard is done with the selection, and the list drops it. */
|
|
155
166
|
onFinished: () => void;
|
|
156
167
|
/**
|
|
157
|
-
* How a run ended, once
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
168
|
+
* How a run ended, once no screen reporting on it is left. Handed to the run's
|
|
169
|
+
* owner rather than called here: the run screen invites the user to close it
|
|
170
|
+
* and keep the run going, and leaving the mailbox on top of that takes this
|
|
171
|
+
* component with it — so the ending is stated by something that outlives both
|
|
172
|
+
* (#112). A run screen still up reports in place instead.
|
|
161
173
|
*/
|
|
162
174
|
onRunEnded?: (
|
|
163
175
|
kind: EscalatedAction["kind"],
|
|
@@ -298,6 +310,7 @@ function SelectionWizardSession({
|
|
|
298
310
|
verb,
|
|
299
311
|
accountId,
|
|
300
312
|
mailboxId,
|
|
313
|
+
mailboxLabel,
|
|
301
314
|
selection,
|
|
302
315
|
selectionRestriction,
|
|
303
316
|
escalated: escalatedSelection,
|
|
@@ -361,11 +374,6 @@ function SelectionWizardSession({
|
|
|
361
374
|
// cleared, so a failed start can be retried.
|
|
362
375
|
const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
|
|
363
376
|
const commitSent = useRef(false);
|
|
364
|
-
// The user left the run screen while it was still reporting. Held here rather
|
|
365
|
-
// than read back off the URL: the rewind the exit starts lands a frame or two
|
|
366
|
-
// later, and an outcome arriving in between would find a screen that is on
|
|
367
|
-
// its way out and say nothing.
|
|
368
|
-
const walkedAway = useRef(false);
|
|
369
377
|
|
|
370
378
|
const messageIds = useMemo(
|
|
371
379
|
() => selection.map((message) => message.id),
|
|
@@ -491,14 +499,36 @@ function SelectionWizardSession({
|
|
|
491
499
|
senders,
|
|
492
500
|
);
|
|
493
501
|
|
|
502
|
+
// While this screen is up it states the ending itself, so the run's owner
|
|
503
|
+
// holds its banner back. The claim is over the run this screen starts and no
|
|
504
|
+
// other: a commit that starts none — a filter, a back-apply job — has no
|
|
505
|
+
// ending of its own to hold, and holding one anyway swallowed the banner of
|
|
506
|
+
// whatever run was going elsewhere. Leaving — closing the wizard, or leaving
|
|
507
|
+
// the mailbox altogether — unmounts this and releases it, and the ending is
|
|
508
|
+
// bannered where the user now is (#112, #521).
|
|
509
|
+
const releaseReport = useRef<(() => void) | undefined>(undefined);
|
|
510
|
+
const claimEnding = useCallback((release: () => void) => {
|
|
511
|
+
releaseReport.current?.();
|
|
512
|
+
releaseReport.current = release;
|
|
513
|
+
}, []);
|
|
514
|
+
useEffect(
|
|
515
|
+
() => () => {
|
|
516
|
+
releaseReport.current?.();
|
|
517
|
+
releaseReport.current = undefined;
|
|
518
|
+
},
|
|
519
|
+
[],
|
|
520
|
+
);
|
|
521
|
+
|
|
494
522
|
const organizeJob = useOrganizeJob(accountId);
|
|
495
523
|
const createFilter = useCreateFilter(accountId);
|
|
496
524
|
const bulk = useEscalatedActions({
|
|
497
525
|
mailboxId: mailboxId ?? "",
|
|
526
|
+
mailboxLabel,
|
|
498
527
|
accountId,
|
|
499
528
|
enabled: false,
|
|
500
529
|
predicateKey: "selection-wizard",
|
|
501
530
|
searchQuery: {},
|
|
531
|
+
reportEnding: onRunEnded,
|
|
502
532
|
});
|
|
503
533
|
const { runAction } = bulk;
|
|
504
534
|
|
|
@@ -661,13 +691,15 @@ function SelectionWizardSession({
|
|
|
661
691
|
return;
|
|
662
692
|
}
|
|
663
693
|
setBulkRun({ matched: targets.length, sent: targets });
|
|
664
|
-
const
|
|
665
|
-
setBulkRun({
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
694
|
+
const started = await runAction(action, targets, claimEnding);
|
|
695
|
+
setBulkRun({
|
|
696
|
+
matched: targets.length,
|
|
697
|
+
sent: targets,
|
|
698
|
+
outcome: started.kind === "ran" ? started.outcome : undefined,
|
|
699
|
+
failureReason: started.kind === "refused" ? started.reason : undefined,
|
|
700
|
+
});
|
|
669
701
|
},
|
|
670
|
-
[verb, named.moveMailboxId, junkMailboxId, runAction,
|
|
702
|
+
[verb, named.moveMailboxId, junkMailboxId, runAction, claimEnding],
|
|
671
703
|
);
|
|
672
704
|
|
|
673
705
|
// The escalated predicate, run by the chunked runner the list already owns.
|
|
@@ -685,12 +717,14 @@ function SelectionWizardSession({
|
|
|
685
717
|
return;
|
|
686
718
|
}
|
|
687
719
|
setBulkRun({ matched: escalated.total, sent: [] });
|
|
688
|
-
const
|
|
689
|
-
setBulkRun({
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
720
|
+
const started = await escalated.run(action, claimEnding);
|
|
721
|
+
setBulkRun({
|
|
722
|
+
matched: escalated.total,
|
|
723
|
+
sent: [],
|
|
724
|
+
outcome: started.kind === "ran" ? started.outcome : undefined,
|
|
725
|
+
failureReason: started.kind === "refused" ? started.reason : undefined,
|
|
726
|
+
});
|
|
727
|
+
}, [escalated, verb, named.moveMailboxId, junkMailboxId, claimEnding]);
|
|
694
728
|
|
|
695
729
|
const { start: startJob } = organizeJob;
|
|
696
730
|
const { createFilterAsync } = createFilter;
|
|
@@ -909,7 +943,6 @@ function SelectionWizardSession({
|
|
|
909
943
|
// run alone: the screen says so in as many words, and stopping it is a
|
|
910
944
|
// control of its own.
|
|
911
945
|
const dismiss = useCallback(() => {
|
|
912
|
-
walkedAway.current = true;
|
|
913
946
|
closeWizard(steps, current);
|
|
914
947
|
onFinished();
|
|
915
948
|
}, [closeWizard, steps, current, onFinished]);
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
import assert from "node:assert/strict";
|
|
21
21
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
22
22
|
import { act, createElement } from "react";
|
|
23
|
-
import type { BulkActionTarget } from "../lib/bulk-actions";
|
|
23
|
+
import type { BulkActionTarget, EscalatedAction } from "../lib/bulk-actions";
|
|
24
|
+
import { ranOutcome } from "../test-support/bulk-run";
|
|
24
25
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
25
26
|
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
26
27
|
import {
|
|
27
|
-
type EscalatedAction,
|
|
28
28
|
type UseEscalatedActionsResult,
|
|
29
29
|
useEscalatedActions,
|
|
30
30
|
} from "./useEscalatedActions";
|
|
@@ -80,7 +80,7 @@ const run = async (
|
|
|
80
80
|
});
|
|
81
81
|
await settle();
|
|
82
82
|
if (!started) throw new Error("the run never started");
|
|
83
|
-
return started;
|
|
83
|
+
return ranOutcome(await started);
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
/** The message-id list of every bulk request that left, in order. */
|