@remit/web-client 0.0.43 → 0.0.44
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/MailboxPane.tsx +1 -1
- package/src/components/mail/MessageList.selection.test.ts +41 -0
- package/src/components/mail/MessageList.tsx +122 -63
- package/src/hooks/{useEscalatedDelete.ts → useEscalatedActions.ts} +126 -66
- package/src/hooks/useSelection.test.ts +1 -1
- package/src/lib/bulk-action-copy.test.ts +108 -0
- package/src/lib/bulk-action-copy.ts +85 -0
- package/src/lib/{bulk-delete.test.ts → bulk-actions.test.ts} +43 -43
- package/src/lib/{bulk-delete.ts → bulk-actions.ts} +53 -52
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
6
|
import {
|
|
7
7
|
messageBulkOperationsDeleteMessages,
|
|
8
|
+
messageBulkOperationsMoveMessages,
|
|
9
|
+
messageBulkOperationsUpdateFlags,
|
|
8
10
|
threadOperationsSearchThreads,
|
|
9
11
|
} from "@remit/api-http-client/sdk.gen.ts";
|
|
10
12
|
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
@@ -13,24 +15,39 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
13
15
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
14
16
|
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
15
17
|
import {
|
|
16
|
-
|
|
18
|
+
bulkActionFailureDetail,
|
|
19
|
+
bulkActionFailureTitle,
|
|
20
|
+
} from "@/lib/bulk-action-copy";
|
|
21
|
+
import {
|
|
22
|
+
type ApplyBatch,
|
|
23
|
+
type BulkActionProgress,
|
|
24
|
+
type BulkRunOutcome,
|
|
17
25
|
countMatches,
|
|
18
|
-
type DeleteRunOutcome,
|
|
19
26
|
type FetchIdsPage,
|
|
20
27
|
honestProgress,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "@/lib/bulk-
|
|
28
|
+
runChunkedAction,
|
|
29
|
+
runPredicateAction,
|
|
30
|
+
} from "@/lib/bulk-actions";
|
|
24
31
|
|
|
25
|
-
/** The predicate a search-scoped
|
|
32
|
+
/** The predicate a search-scoped run re-issues on every page — the same
|
|
26
33
|
* filters the visible list is searching with, minus pagination/count knobs. */
|
|
27
34
|
export type EscalationSearchQuery = Pick<
|
|
28
35
|
NonNullable<ThreadOperationsSearchThreadsData["query"]>,
|
|
29
36
|
"order" | "query" | "subject" | "from" | "unread" | "starred" | "attachments"
|
|
30
37
|
>;
|
|
31
38
|
|
|
39
|
+
/**
|
|
40
|
+
* What a bulk run applies to every batch it reaches (#114). Delete, move and
|
|
41
|
+
* mark-read differ only in the bulk call they issue and the caches that call
|
|
42
|
+
* invalidates; the paging, chunking, progress and cancellation are the same.
|
|
43
|
+
*/
|
|
44
|
+
export type EscalatedAction =
|
|
45
|
+
| { kind: "delete" }
|
|
46
|
+
| { kind: "move"; destinationMailboxId: string }
|
|
47
|
+
| { kind: "markRead" };
|
|
48
|
+
|
|
32
49
|
/** Page size for both the counting and the execution loop. Set to the write
|
|
33
|
-
* side's own 100-id cap so an execution page IS a
|
|
50
|
+
* side's own 100-id cap so an execution page IS a write chunk — no
|
|
34
51
|
* in-memory accumulation step between reading ids and sending them. Counting
|
|
35
52
|
* doesn't have that constraint but reuses the same page size rather than
|
|
36
53
|
* adding a second one to reason about. */
|
|
@@ -41,7 +58,7 @@ export type EscalationPhase =
|
|
|
41
58
|
| { kind: "counting"; countSoFar: number }
|
|
42
59
|
| { kind: "escalated"; total: number };
|
|
43
60
|
|
|
44
|
-
interface
|
|
61
|
+
interface UseEscalatedActionsOptions {
|
|
45
62
|
mailboxId: string;
|
|
46
63
|
/** Owning account, forwarded to the unseen-count invalidation on completion. */
|
|
47
64
|
accountId?: string;
|
|
@@ -54,51 +71,58 @@ interface UseEscalatedDeleteOptions {
|
|
|
54
71
|
searchQuery: EscalationSearchQuery;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
export interface
|
|
74
|
+
export interface UseEscalatedActionsResult {
|
|
58
75
|
phase: EscalationPhase;
|
|
59
76
|
/** Begin paging the predicate's full match set to find its total. */
|
|
60
77
|
escalate: () => void;
|
|
61
|
-
/** Stop whatever's running — counting or
|
|
78
|
+
/** Stop whatever's running — counting or an action — at the next page
|
|
62
79
|
* boundary. A no-op when nothing is running. */
|
|
63
80
|
stop: () => void;
|
|
64
81
|
/** Drop an escalated selection back to bounded without confirming anything. */
|
|
65
82
|
clear: () => void;
|
|
66
|
-
/** True while a chunked
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
/** True while a chunked run (bounded->100 ids, or the escalated predicate)
|
|
84
|
+
* is in flight. */
|
|
85
|
+
isRunning: boolean;
|
|
86
|
+
/** The action currently in flight, for status and progress wording. */
|
|
87
|
+
runningAction: EscalatedAction | undefined;
|
|
88
|
+
progress: BulkActionProgress | undefined;
|
|
70
89
|
/**
|
|
71
|
-
* Runs
|
|
72
|
-
* omit it to
|
|
73
|
-
* Resolves once the run ends for any reason — cancelled,
|
|
74
|
-
* complete — with a `done`/`failedIds` outcome the caller feeds
|
|
75
|
-
* `
|
|
90
|
+
* Runs `action` in chunks. Pass `ids` for a materialized (bounded)
|
|
91
|
+
* selection; omit it to run against the escalated predicate (`phase` must
|
|
92
|
+
* be "escalated"). Resolves once the run ends for any reason — cancelled,
|
|
93
|
+
* errored, or complete — with a `done`/`failedIds` outcome the caller feeds
|
|
94
|
+
* to `resolveSelectionAfterRun` to decide what selection looks like next.
|
|
76
95
|
* Infrastructure failures are reported through the app's existing
|
|
77
96
|
* escalation seam (`pushError`, which itself escalates a 5xx/exception to
|
|
78
97
|
* the fatal overlay) — not swallowed here.
|
|
79
98
|
*/
|
|
80
|
-
|
|
99
|
+
runAction: (
|
|
100
|
+
action: EscalatedAction,
|
|
101
|
+
ids?: string[],
|
|
102
|
+
) => Promise<BulkRunOutcome>;
|
|
81
103
|
}
|
|
82
104
|
|
|
83
|
-
export const
|
|
105
|
+
export const useEscalatedActions = ({
|
|
84
106
|
mailboxId,
|
|
85
107
|
accountId,
|
|
86
108
|
enabled,
|
|
87
109
|
predicateKey,
|
|
88
110
|
searchQuery,
|
|
89
|
-
}:
|
|
111
|
+
}: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
|
|
90
112
|
const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
|
|
91
|
-
const [
|
|
92
|
-
|
|
93
|
-
BulkDeleteProgress | undefined
|
|
113
|
+
const [runningAction, setRunningAction] = useState<
|
|
114
|
+
EscalatedAction | undefined
|
|
94
115
|
>(undefined);
|
|
116
|
+
const [progress, setProgress] = useState<BulkActionProgress | undefined>(
|
|
117
|
+
undefined,
|
|
118
|
+
);
|
|
95
119
|
const cancelRef = useRef(false);
|
|
96
120
|
const queryClient = useQueryClient();
|
|
97
121
|
const { pushError } = useErrorBanners();
|
|
98
122
|
|
|
99
123
|
// A different search (or leaving search/desktop) makes any in-flight
|
|
100
124
|
// escalation meaningless — it would otherwise keep counting or offering to
|
|
101
|
-
//
|
|
125
|
+
// act on a predicate the visible list no longer reflects.
|
|
102
126
|
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself is unconditional, not a value read from either.
|
|
103
127
|
useEffect(() => {
|
|
104
128
|
cancelRef.current = true;
|
|
@@ -127,29 +151,62 @@ export const useEscalatedDelete = ({
|
|
|
127
151
|
[mailboxId],
|
|
128
152
|
);
|
|
129
153
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
const applyBatchFor = useCallback(
|
|
155
|
+
(action: EscalatedAction): ApplyBatch =>
|
|
156
|
+
async (ids: string[]) => {
|
|
157
|
+
if (action.kind === "move") {
|
|
158
|
+
const { data } = await messageBulkOperationsMoveMessages({
|
|
159
|
+
body: {
|
|
160
|
+
messageIds: ids,
|
|
161
|
+
destinationMailboxId: action.destinationMailboxId,
|
|
162
|
+
},
|
|
163
|
+
throwOnError: true,
|
|
164
|
+
});
|
|
165
|
+
return data;
|
|
166
|
+
}
|
|
167
|
+
if (action.kind === "markRead") {
|
|
168
|
+
const { data } = await messageBulkOperationsUpdateFlags({
|
|
169
|
+
body: { messageIds: ids, isRead: true },
|
|
170
|
+
throwOnError: true,
|
|
171
|
+
});
|
|
172
|
+
return data;
|
|
173
|
+
}
|
|
174
|
+
const { data } = await messageBulkOperationsDeleteMessages({
|
|
175
|
+
body: { messageIds: ids },
|
|
176
|
+
throwOnError: true,
|
|
177
|
+
});
|
|
178
|
+
return data;
|
|
179
|
+
},
|
|
180
|
+
[],
|
|
181
|
+
);
|
|
137
182
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
queryKey: threadOperationsSearchThreadsQueryKey({ path: { mailboxId } }),
|
|
144
|
-
});
|
|
145
|
-
if (accountId) {
|
|
183
|
+
const invalidateAfterRun = useCallback(
|
|
184
|
+
(action: EscalatedAction) => {
|
|
185
|
+
queryClient.invalidateQueries({
|
|
186
|
+
queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
|
|
187
|
+
});
|
|
146
188
|
queryClient.invalidateQueries({
|
|
147
|
-
queryKey:
|
|
148
|
-
path: {
|
|
189
|
+
queryKey: threadOperationsSearchThreadsQueryKey({
|
|
190
|
+
path: { mailboxId },
|
|
149
191
|
}),
|
|
150
192
|
});
|
|
151
|
-
|
|
152
|
-
|
|
193
|
+
if (action.kind === "move") {
|
|
194
|
+
queryClient.invalidateQueries({
|
|
195
|
+
queryKey: threadOperationsListThreadsQueryKey({
|
|
196
|
+
path: { mailboxId: action.destinationMailboxId },
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (accountId) {
|
|
201
|
+
queryClient.invalidateQueries({
|
|
202
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
203
|
+
path: { accountId },
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
[queryClient, mailboxId, accountId],
|
|
209
|
+
);
|
|
153
210
|
|
|
154
211
|
const escalate = useCallback(() => {
|
|
155
212
|
cancelRef.current = false;
|
|
@@ -187,53 +244,55 @@ export const useEscalatedDelete = ({
|
|
|
187
244
|
setPhase({ kind: "idle" });
|
|
188
245
|
}, []);
|
|
189
246
|
|
|
190
|
-
const
|
|
191
|
-
async (
|
|
247
|
+
const runAction = useCallback(
|
|
248
|
+
async (
|
|
249
|
+
action: EscalatedAction,
|
|
250
|
+
ids?: string[],
|
|
251
|
+
): Promise<BulkRunOutcome> => {
|
|
192
252
|
cancelRef.current = false;
|
|
193
|
-
|
|
253
|
+
setRunningAction(action);
|
|
194
254
|
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
195
|
-
// predicate can match more by the time the
|
|
255
|
+
// predicate can match more by the time the run re-pages it than
|
|
196
256
|
// `countMatches` saw, and the bar must never show more done than out of.
|
|
197
|
-
const onProgress = (
|
|
198
|
-
|
|
257
|
+
const onProgress = (next: BulkActionProgress) =>
|
|
258
|
+
setProgress(honestProgress(next));
|
|
259
|
+
const applyBatch = applyBatchFor(action);
|
|
199
260
|
|
|
200
261
|
const outcome =
|
|
201
262
|
ids !== undefined
|
|
202
|
-
? await
|
|
263
|
+
? await runChunkedAction(
|
|
203
264
|
ids,
|
|
204
|
-
|
|
265
|
+
applyBatch,
|
|
205
266
|
onProgress,
|
|
206
267
|
() => cancelRef.current,
|
|
207
268
|
)
|
|
208
|
-
: await
|
|
269
|
+
: await runPredicateAction(
|
|
209
270
|
fetchIdsPage,
|
|
210
271
|
phase.kind === "escalated" ? phase.total : 0,
|
|
211
|
-
|
|
272
|
+
applyBatch,
|
|
212
273
|
onProgress,
|
|
213
274
|
() => cancelRef.current,
|
|
214
275
|
);
|
|
215
276
|
|
|
216
|
-
|
|
217
|
-
|
|
277
|
+
setRunningAction(undefined);
|
|
278
|
+
setProgress(undefined);
|
|
218
279
|
setPhase({ kind: "idle" });
|
|
219
280
|
|
|
220
281
|
if (outcome.error) {
|
|
221
282
|
pushError(
|
|
222
283
|
buildMutationErrorBanner(
|
|
223
|
-
outcome.done
|
|
224
|
-
|
|
225
|
-
: "Couldn't delete these messages",
|
|
226
|
-
"The delete didn't finish.",
|
|
284
|
+
bulkActionFailureTitle(action.kind, outcome.done),
|
|
285
|
+
bulkActionFailureDetail(action.kind),
|
|
227
286
|
outcome.error,
|
|
228
287
|
),
|
|
229
288
|
);
|
|
230
289
|
}
|
|
231
290
|
if (outcome.done > 0) {
|
|
232
|
-
|
|
291
|
+
invalidateAfterRun(action);
|
|
233
292
|
}
|
|
234
293
|
return outcome;
|
|
235
294
|
},
|
|
236
|
-
[
|
|
295
|
+
[applyBatchFor, fetchIdsPage, phase, pushError, invalidateAfterRun],
|
|
237
296
|
);
|
|
238
297
|
|
|
239
298
|
return {
|
|
@@ -241,8 +300,9 @@ export const useEscalatedDelete = ({
|
|
|
241
300
|
escalate,
|
|
242
301
|
stop,
|
|
243
302
|
clear,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
303
|
+
isRunning: runningAction !== undefined,
|
|
304
|
+
runningAction,
|
|
305
|
+
progress,
|
|
306
|
+
runAction,
|
|
247
307
|
};
|
|
248
308
|
};
|
|
@@ -216,7 +216,7 @@ describe("intersectSelectedIds", () => {
|
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
test("a post-delete retry selection survives a refetch that still contains it, minus what actually left", () => {
|
|
219
|
-
// Mirrors
|
|
219
|
+
// Mirrors processRunOutcome materializing the failed ids as the new
|
|
220
220
|
// selection, then the cache-invalidation refetch running this same
|
|
221
221
|
// intersection against the freshly reloaded `threads`. One retry id
|
|
222
222
|
// ("fail-2") is momentarily missing from the refreshed page; the other
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
type BulkActionKind,
|
|
5
|
+
bulkActionCompletionText,
|
|
6
|
+
bulkActionFailureDetail,
|
|
7
|
+
bulkActionFailureTitle,
|
|
8
|
+
bulkActionPartialText,
|
|
9
|
+
bulkActionProgressLabel,
|
|
10
|
+
bulkActionProgressTone,
|
|
11
|
+
} from "./bulk-action-copy.js";
|
|
12
|
+
|
|
13
|
+
const kinds: BulkActionKind[] = ["delete", "move", "markRead"];
|
|
14
|
+
|
|
15
|
+
describe("bulkActionProgressLabel", () => {
|
|
16
|
+
test("names the action and both counts", () => {
|
|
17
|
+
assert.equal(
|
|
18
|
+
bulkActionProgressLabel("delete", 1200, 3412),
|
|
19
|
+
"Deleting 1,200 of 3,412…",
|
|
20
|
+
);
|
|
21
|
+
assert.equal(
|
|
22
|
+
bulkActionProgressLabel("move", 1200, 3412),
|
|
23
|
+
"Moving 1,200 of 3,412…",
|
|
24
|
+
);
|
|
25
|
+
assert.equal(
|
|
26
|
+
bulkActionProgressLabel("markRead", 1200, 3412),
|
|
27
|
+
"Marking 1,200 of 3,412 as read…",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("bulkActionCompletionText", () => {
|
|
33
|
+
test("says what happened and that the server is still applying it", () => {
|
|
34
|
+
assert.equal(
|
|
35
|
+
bulkActionCompletionText("delete", 3412),
|
|
36
|
+
"3,412 moved to Trash. Your mail server is still catching up.",
|
|
37
|
+
);
|
|
38
|
+
assert.equal(
|
|
39
|
+
bulkActionCompletionText("move", 3412),
|
|
40
|
+
"3,412 moved. Your mail server is still catching up.",
|
|
41
|
+
);
|
|
42
|
+
assert.equal(
|
|
43
|
+
bulkActionCompletionText("markRead", 3412),
|
|
44
|
+
"3,412 marked as read. Your mail server is still catching up.",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("bulkActionPartialText", () => {
|
|
50
|
+
test("splits what landed from what is still selected", () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
bulkActionPartialText("delete", 3072, 340),
|
|
53
|
+
"3,072 moved to Trash. 340 couldn't be deleted.",
|
|
54
|
+
);
|
|
55
|
+
assert.equal(
|
|
56
|
+
bulkActionPartialText("move", 3072, 340),
|
|
57
|
+
"3,072 moved. 340 couldn't be moved.",
|
|
58
|
+
);
|
|
59
|
+
assert.equal(
|
|
60
|
+
bulkActionPartialText("markRead", 3072, 340),
|
|
61
|
+
"3,072 marked as read. 340 couldn't be marked as read.",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("bulkActionFailureTitle", () => {
|
|
67
|
+
test("reports where a partly-done run stopped", () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
bulkActionFailureTitle("move", 3072),
|
|
70
|
+
"Stopped after 3,072 — some messages couldn't be moved",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("drops the count when nothing landed", () => {
|
|
75
|
+
assert.equal(
|
|
76
|
+
bulkActionFailureTitle("markRead", 0),
|
|
77
|
+
"Couldn't mark these messages as read",
|
|
78
|
+
);
|
|
79
|
+
assert.equal(
|
|
80
|
+
bulkActionFailureTitle("delete", 0),
|
|
81
|
+
"Couldn't delete these messages",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("every action carries its own wording", () => {
|
|
87
|
+
test("no two actions share a sentence", () => {
|
|
88
|
+
const sentences: Array<(kind: BulkActionKind) => string> = [
|
|
89
|
+
(kind) => bulkActionCompletionText(kind, 5),
|
|
90
|
+
(kind) => bulkActionPartialText(kind, 5, 2),
|
|
91
|
+
(kind) => bulkActionFailureTitle(kind, 0),
|
|
92
|
+
(kind) => bulkActionFailureTitle(kind, 5),
|
|
93
|
+
bulkActionFailureDetail,
|
|
94
|
+
(kind) => bulkActionProgressLabel(kind, 1, 2),
|
|
95
|
+
];
|
|
96
|
+
for (const render of sentences) {
|
|
97
|
+
assert.equal(new Set(kinds.map(render)).size, kinds.length);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("bulkActionProgressTone", () => {
|
|
103
|
+
test("only delete reads as destructive", () => {
|
|
104
|
+
assert.equal(bulkActionProgressTone("delete"), "danger");
|
|
105
|
+
assert.equal(bulkActionProgressTone("move"), "info");
|
|
106
|
+
assert.equal(bulkActionProgressTone("markRead"), "info");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { formatNumber } from "@/lib/format";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wording for the three bulk actions a selection can run (#114). One place
|
|
5
|
+
* per sentence the selection bar, the completion banner and the error banner
|
|
6
|
+
* can say, so a new action is a row in these tables rather than a branch in
|
|
7
|
+
* every caller.
|
|
8
|
+
*/
|
|
9
|
+
export type BulkActionKind = "delete" | "move" | "markRead";
|
|
10
|
+
|
|
11
|
+
const progressPhrase: Record<
|
|
12
|
+
BulkActionKind,
|
|
13
|
+
(done: string, total: string) => string
|
|
14
|
+
> = {
|
|
15
|
+
delete: (done, total) => `Deleting ${done} of ${total}…`,
|
|
16
|
+
move: (done, total) => `Moving ${done} of ${total}…`,
|
|
17
|
+
markRead: (done, total) => `Marking ${done} of ${total} as read…`,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const pastTense: Record<BulkActionKind, string> = {
|
|
21
|
+
delete: "moved to Trash",
|
|
22
|
+
move: "moved",
|
|
23
|
+
markRead: "marked as read",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const negated: Record<BulkActionKind, string> = {
|
|
27
|
+
delete: "couldn't be deleted",
|
|
28
|
+
move: "couldn't be moved",
|
|
29
|
+
markRead: "couldn't be marked as read",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const failureTitle: Record<BulkActionKind, string> = {
|
|
33
|
+
delete: "Couldn't delete these messages",
|
|
34
|
+
move: "Couldn't move these messages",
|
|
35
|
+
markRead: "Couldn't mark these messages as read",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const failureDetail: Record<BulkActionKind, string> = {
|
|
39
|
+
delete: "The delete didn't finish.",
|
|
40
|
+
move: "The move didn't finish.",
|
|
41
|
+
markRead: "The update didn't finish.",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Running status while a chunked or escalated run is in flight. */
|
|
45
|
+
export const bulkActionProgressLabel = (
|
|
46
|
+
kind: BulkActionKind,
|
|
47
|
+
done: number,
|
|
48
|
+
total: number,
|
|
49
|
+
): string => progressPhrase[kind](formatNumber(done), formatNumber(total));
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Shown once a run finishes with nothing left over. The second sentence is
|
|
53
|
+
* the honest part: the bulk endpoints enqueue the IMAP write, so the mail
|
|
54
|
+
* server is still applying it when this appears.
|
|
55
|
+
*/
|
|
56
|
+
export const bulkActionCompletionText = (
|
|
57
|
+
kind: BulkActionKind,
|
|
58
|
+
done: number,
|
|
59
|
+
): string =>
|
|
60
|
+
`${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
|
|
61
|
+
|
|
62
|
+
/** Shown when part of a run landed and the rest is still selected for Retry. */
|
|
63
|
+
export const bulkActionPartialText = (
|
|
64
|
+
kind: BulkActionKind,
|
|
65
|
+
succeeded: number,
|
|
66
|
+
remaining: number,
|
|
67
|
+
): string =>
|
|
68
|
+
`${formatNumber(succeeded)} ${pastTense[kind]}. ${formatNumber(remaining)} ${negated[kind]}.`;
|
|
69
|
+
|
|
70
|
+
/** Error-banner title for a run stopped by an infrastructure failure. */
|
|
71
|
+
export const bulkActionFailureTitle = (
|
|
72
|
+
kind: BulkActionKind,
|
|
73
|
+
done: number,
|
|
74
|
+
): string =>
|
|
75
|
+
done > 0
|
|
76
|
+
? `Stopped after ${formatNumber(done)} — some messages ${negated[kind]}`
|
|
77
|
+
: failureTitle[kind];
|
|
78
|
+
|
|
79
|
+
export const bulkActionFailureDetail = (kind: BulkActionKind): string =>
|
|
80
|
+
failureDetail[kind];
|
|
81
|
+
|
|
82
|
+
/** Progress-bar tone: only delete is destructive. */
|
|
83
|
+
export const bulkActionProgressTone = (
|
|
84
|
+
kind: BulkActionKind,
|
|
85
|
+
): "danger" | "info" => (kind === "delete" ? "danger" : "info");
|