@remit/web-client 0.0.268 → 0.0.270
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/MessageList.run-reporting.test.ts +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +15 -50
- package/src/components/mail/bulk-run-state.test.ts +100 -0
- package/src/components/mail/bulk-run-state.ts +99 -0
- package/src/components/ui/placement-refusal.test.ts +33 -2
- package/src/components/ui/placement-refusal.ts +14 -6
- package/src/hooks/escalated-run-lifetime.render.test.ts +87 -6
- package/src/hooks/refusal-escalation.render.test.ts +56 -0
- package/src/hooks/useDeleteMessages.ts +1 -1
- package/src/hooks/useEscalatedActions.ts +45 -24
- package/src/hooks/useMoveMessages.ts +16 -0
- package/src/lib/bulk-action-copy.test.ts +14 -4
- package/src/lib/bulk-action-copy.ts +5 -3
- package/src/lib/bulk-actions.test.ts +115 -26
- package/src/lib/bulk-actions.ts +40 -10
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
* failure (auth, the write, or the enqueue) that takes out the whole batch and
|
|
16
16
|
* stops the run.
|
|
17
17
|
*
|
|
18
|
+
* Cancellation is an `AbortSignal` the caller owns, handed to every call the run
|
|
19
|
+
* makes (#113). Aborting ends the request in flight rather than waiting for it,
|
|
20
|
+
* so at most the batch already on the wire is at stake instead of a whole page;
|
|
21
|
+
* the run then stops before the next request leaves. A rejection raised while
|
|
22
|
+
* the signal is aborted is that abort, so it reports `cancelled` rather than an
|
|
23
|
+
* error — the user asked for it.
|
|
24
|
+
*
|
|
25
|
+
* That classification reads `signal.aborted`, deliberately, and not the shape of
|
|
26
|
+
* the rejection (`isAbortError`): a genuine failure that raced the user's Stop
|
|
27
|
+
* should read as a stop. The user pressed it and the run is over either way, and
|
|
28
|
+
* an error banner for a run they ended themselves is the wrong answer.
|
|
29
|
+
*
|
|
18
30
|
* Pure, framework-agnostic, and independently testable — no React, no fetch.
|
|
19
31
|
* `useEscalatedActions.ts` supplies the real `ApplyBatch`/`FetchIdsPage`
|
|
20
32
|
* implementations (the generated SDK client) and owns the React state.
|
|
@@ -22,6 +34,9 @@
|
|
|
22
34
|
|
|
23
35
|
export const BULK_ACTION_CHUNK_SIZE = 100;
|
|
24
36
|
|
|
37
|
+
/** For a run with no Stop control to wire a signal to. */
|
|
38
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Resolves a promise to a discriminated result instead of throwing, so a
|
|
27
42
|
* caller can branch on infrastructure failure as a value — the app's
|
|
@@ -104,7 +119,10 @@ export interface BatchResult {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
/** Applies the action to one batch of ids (≤100) with a single bulk call. */
|
|
107
|
-
export type ApplyBatch = (
|
|
122
|
+
export type ApplyBatch = (
|
|
123
|
+
ids: string[],
|
|
124
|
+
signal: AbortSignal,
|
|
125
|
+
) => Promise<BatchResult>;
|
|
108
126
|
|
|
109
127
|
export interface BulkActionProgress {
|
|
110
128
|
/** Ids the action has been applied to so far. */
|
|
@@ -122,6 +140,9 @@ export interface BulkActionOutcome {
|
|
|
122
140
|
* cancellation or a thrown error, the chunks the bounded run never attempted
|
|
123
141
|
* (see `runChunkedAction`). Empty in the predicate case, which re-resolves on
|
|
124
142
|
* every run rather than handing back a remainder (see `runPredicateAction`).
|
|
143
|
+
* An aborted batch is counted here too: aborting the request does not
|
|
144
|
+
* un-send what the server already accepted, so its ids are "may not have
|
|
145
|
+
* landed" rather than "did not", and every action here is idempotent.
|
|
125
146
|
* There is no per-id failure source: a returned batch call counts every id in
|
|
126
147
|
* it as accepted (see the module header).
|
|
127
148
|
*/
|
|
@@ -150,7 +171,7 @@ export const runChunkedAction = async (
|
|
|
150
171
|
targets: readonly BulkActionTarget[],
|
|
151
172
|
applyBatch: ApplyBatch,
|
|
152
173
|
onProgress: (progress: BulkActionProgress) => void,
|
|
153
|
-
|
|
174
|
+
signal: AbortSignal,
|
|
154
175
|
): Promise<BulkActionOutcome> => {
|
|
155
176
|
const chunks = chunkTargets(targets);
|
|
156
177
|
const total = targets.length;
|
|
@@ -158,14 +179,15 @@ export const runChunkedAction = async (
|
|
|
158
179
|
const failedIds: string[] = [];
|
|
159
180
|
|
|
160
181
|
for (let i = 0; i < chunks.length; i++) {
|
|
161
|
-
if (
|
|
182
|
+
if (signal.aborted) {
|
|
162
183
|
failedIds.push(...chunks.slice(i).flat());
|
|
163
184
|
return { done, failedIds, cancelled: true };
|
|
164
185
|
}
|
|
165
186
|
const chunk = chunks[i];
|
|
166
|
-
const attempted = await attempt(applyBatch(chunk));
|
|
187
|
+
const attempted = await attempt(applyBatch(chunk, signal));
|
|
167
188
|
if (!attempted.ok) {
|
|
168
189
|
failedIds.push(...chunks.slice(i).flat());
|
|
190
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
169
191
|
onProgress({ done, total });
|
|
170
192
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
171
193
|
}
|
|
@@ -194,6 +216,10 @@ export const runChunkedAction = async (
|
|
|
194
216
|
* focused row, or a selection the surface already scoped — so nothing here
|
|
195
217
|
* carries an account to split by. A caller with a cross-account selection
|
|
196
218
|
* belongs on `runChunkedAction`, whose targets name one (#872).
|
|
219
|
+
*
|
|
220
|
+
* These callers have no Stop control to wire a signal to — a mutation over a
|
|
221
|
+
* handful of ids has no progress surface to stop from — so the run is handed a
|
|
222
|
+
* signal that never aborts.
|
|
197
223
|
*/
|
|
198
224
|
export const runChunkedMutation = async (
|
|
199
225
|
ids: readonly string[],
|
|
@@ -206,7 +232,7 @@ export const runChunkedMutation = async (
|
|
|
206
232
|
return { successCount: chunk.length, failureCount: 0 };
|
|
207
233
|
},
|
|
208
234
|
() => {},
|
|
209
|
-
|
|
235
|
+
NEVER_ABORTED,
|
|
210
236
|
);
|
|
211
237
|
};
|
|
212
238
|
|
|
@@ -218,6 +244,7 @@ export interface FetchIdsPageResult {
|
|
|
218
244
|
/** Fetches one page of matching message ids for the active predicate. */
|
|
219
245
|
export type FetchIdsPage = (
|
|
220
246
|
continuationToken: string | undefined,
|
|
247
|
+
signal: AbortSignal,
|
|
221
248
|
) => Promise<FetchIdsPageResult>;
|
|
222
249
|
|
|
223
250
|
/**
|
|
@@ -230,33 +257,36 @@ export type FetchIdsPage = (
|
|
|
230
257
|
* `failedIds` for the unreached remainder, because those ids were never
|
|
231
258
|
* fetched — there is nothing to hand back. A predicate resolves fresh on
|
|
232
259
|
* every run (D2), so resuming is re-invoking this same function with the same
|
|
233
|
-
* predicate.
|
|
260
|
+
* predicate. Aborting also ends the page or batch on the wire, so the run gives
|
|
261
|
+
* up at most the one request rather than the whole page it was mid-way through.
|
|
234
262
|
*/
|
|
235
263
|
export const runPredicateAction = async (
|
|
236
264
|
fetchIdsPage: FetchIdsPage,
|
|
237
265
|
total: number,
|
|
238
266
|
applyBatch: ApplyBatch,
|
|
239
267
|
onProgress: (progress: BulkActionProgress) => void,
|
|
240
|
-
|
|
268
|
+
signal: AbortSignal,
|
|
241
269
|
): Promise<BulkActionOutcome> => {
|
|
242
270
|
let done = 0;
|
|
243
271
|
const failedIds: string[] = [];
|
|
244
272
|
let token: string | undefined;
|
|
245
273
|
|
|
246
274
|
do {
|
|
247
|
-
if (
|
|
275
|
+
if (signal.aborted) {
|
|
248
276
|
return { done, failedIds, cancelled: true };
|
|
249
277
|
}
|
|
250
278
|
|
|
251
|
-
const fetched = await attempt(fetchIdsPage(token));
|
|
279
|
+
const fetched = await attempt(fetchIdsPage(token, signal));
|
|
252
280
|
if (!fetched.ok) {
|
|
281
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
253
282
|
return { done, failedIds, cancelled: false, error: fetched.error };
|
|
254
283
|
}
|
|
255
284
|
const page = fetched.value;
|
|
256
285
|
|
|
257
286
|
if (page.ids.length > 0) {
|
|
258
|
-
const attempted = await attempt(applyBatch(page.ids));
|
|
287
|
+
const attempted = await attempt(applyBatch(page.ids, signal));
|
|
259
288
|
if (!attempted.ok) {
|
|
289
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
260
290
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
261
291
|
}
|
|
262
292
|
done += page.ids.length;
|