@remit/web-client 0.0.43 → 0.0.45
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/MessageActionMenu.tsx +24 -63
- package/src/components/mail/MessageList.selection.test.ts +41 -0
- package/src/components/mail/MessageList.tsx +122 -63
- package/src/components/mail/MobileMessageBar.tsx +8 -10
- package/src/hooks/useEscalatedActions.test.ts +36 -0
- package/src/hooks/{useEscalatedDelete.ts → useEscalatedActions.ts} +135 -74
- package/src/hooks/useMoveMessages.ts +8 -4
- 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
- package/src/lib/thread-list-cache.test.ts +104 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
4
|
+
import { threadListCacheKeys } from "@/lib/thread-list-cache";
|
|
5
|
+
import { mailboxesTouchedBy } from "./useEscalatedActions.js";
|
|
6
|
+
|
|
7
|
+
describe("mailboxesTouchedBy", () => {
|
|
8
|
+
test("a delete or mark-read run touches only the mailbox it ran over", () => {
|
|
9
|
+
assert.deepStrictEqual(mailboxesTouchedBy({ kind: "delete" }, "mb1"), [
|
|
10
|
+
"mb1",
|
|
11
|
+
]);
|
|
12
|
+
assert.deepStrictEqual(mailboxesTouchedBy({ kind: "markRead" }, "mb1"), [
|
|
13
|
+
"mb1",
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a move run also touches the destination", () => {
|
|
18
|
+
assert.deepStrictEqual(
|
|
19
|
+
mailboxesTouchedBy({ kind: "move", destinationMailboxId: "mb2" }, "mb1"),
|
|
20
|
+
["mb1", "mb2"],
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("its cache keys reach the unified listing the daily brief reads", () => {
|
|
25
|
+
const keys = threadListCacheKeys(
|
|
26
|
+
mailboxesTouchedBy({ kind: "delete" }, "mb1"),
|
|
27
|
+
);
|
|
28
|
+
assert.ok(
|
|
29
|
+
keys.some(
|
|
30
|
+
(key) =>
|
|
31
|
+
JSON.stringify(key) ===
|
|
32
|
+
JSON.stringify(unifiedThreadOperationsListAllThreadsQueryKey()),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
mailboxOperationsListMailboxesQueryKey,
|
|
3
|
-
threadOperationsListThreadsQueryKey,
|
|
4
|
-
threadOperationsSearchThreadsQueryKey,
|
|
5
|
-
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
1
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
2
|
import {
|
|
7
3
|
messageBulkOperationsDeleteMessages,
|
|
4
|
+
messageBulkOperationsMoveMessages,
|
|
5
|
+
messageBulkOperationsUpdateFlags,
|
|
8
6
|
threadOperationsSearchThreads,
|
|
9
7
|
} from "@remit/api-http-client/sdk.gen.ts";
|
|
10
8
|
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
@@ -13,24 +11,43 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
13
11
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
14
12
|
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
15
13
|
import {
|
|
16
|
-
|
|
14
|
+
bulkActionFailureDetail,
|
|
15
|
+
bulkActionFailureTitle,
|
|
16
|
+
} from "@/lib/bulk-action-copy";
|
|
17
|
+
import {
|
|
18
|
+
type ApplyBatch,
|
|
19
|
+
type BulkActionProgress,
|
|
20
|
+
type BulkRunOutcome,
|
|
17
21
|
countMatches,
|
|
18
|
-
type DeleteRunOutcome,
|
|
19
22
|
type FetchIdsPage,
|
|
20
23
|
honestProgress,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "@/lib/bulk-
|
|
24
|
+
runChunkedAction,
|
|
25
|
+
runPredicateAction,
|
|
26
|
+
} from "@/lib/bulk-actions";
|
|
27
|
+
import {
|
|
28
|
+
invalidateThreadListQueries,
|
|
29
|
+
threadListCacheKeys,
|
|
30
|
+
} from "@/lib/thread-list-cache";
|
|
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,70 @@ 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
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The mailboxes whose cached listings a bulk run affects: the mailbox it ran
|
|
107
|
+
* over, plus a move's destination, which gains the messages the source loses.
|
|
108
|
+
*/
|
|
109
|
+
export const mailboxesTouchedBy = (
|
|
110
|
+
action: EscalatedAction,
|
|
111
|
+
mailboxId: string,
|
|
112
|
+
): string[] =>
|
|
113
|
+
action.kind === "move"
|
|
114
|
+
? [mailboxId, action.destinationMailboxId]
|
|
115
|
+
: [mailboxId];
|
|
116
|
+
|
|
117
|
+
export const useEscalatedActions = ({
|
|
84
118
|
mailboxId,
|
|
85
119
|
accountId,
|
|
86
120
|
enabled,
|
|
87
121
|
predicateKey,
|
|
88
122
|
searchQuery,
|
|
89
|
-
}:
|
|
123
|
+
}: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
|
|
90
124
|
const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
|
|
91
|
-
const [
|
|
92
|
-
|
|
93
|
-
BulkDeleteProgress | undefined
|
|
125
|
+
const [runningAction, setRunningAction] = useState<
|
|
126
|
+
EscalatedAction | undefined
|
|
94
127
|
>(undefined);
|
|
128
|
+
const [progress, setProgress] = useState<BulkActionProgress | undefined>(
|
|
129
|
+
undefined,
|
|
130
|
+
);
|
|
95
131
|
const cancelRef = useRef(false);
|
|
96
132
|
const queryClient = useQueryClient();
|
|
97
133
|
const { pushError } = useErrorBanners();
|
|
98
134
|
|
|
99
135
|
// A different search (or leaving search/desktop) makes any in-flight
|
|
100
136
|
// escalation meaningless — it would otherwise keep counting or offering to
|
|
101
|
-
//
|
|
137
|
+
// act on a predicate the visible list no longer reflects.
|
|
102
138
|
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself is unconditional, not a value read from either.
|
|
103
139
|
useEffect(() => {
|
|
104
140
|
cancelRef.current = true;
|
|
@@ -127,29 +163,51 @@ export const useEscalatedDelete = ({
|
|
|
127
163
|
[mailboxId],
|
|
128
164
|
);
|
|
129
165
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
const applyBatchFor = useCallback(
|
|
167
|
+
(action: EscalatedAction): ApplyBatch =>
|
|
168
|
+
async (ids: string[]) => {
|
|
169
|
+
if (action.kind === "move") {
|
|
170
|
+
const { data } = await messageBulkOperationsMoveMessages({
|
|
171
|
+
body: {
|
|
172
|
+
messageIds: ids,
|
|
173
|
+
destinationMailboxId: action.destinationMailboxId,
|
|
174
|
+
},
|
|
175
|
+
throwOnError: true,
|
|
176
|
+
});
|
|
177
|
+
return data;
|
|
178
|
+
}
|
|
179
|
+
if (action.kind === "markRead") {
|
|
180
|
+
const { data } = await messageBulkOperationsUpdateFlags({
|
|
181
|
+
body: { messageIds: ids, isRead: true },
|
|
182
|
+
throwOnError: true,
|
|
183
|
+
});
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
const { data } = await messageBulkOperationsDeleteMessages({
|
|
187
|
+
body: { messageIds: ids },
|
|
188
|
+
throwOnError: true,
|
|
189
|
+
});
|
|
190
|
+
return data;
|
|
191
|
+
},
|
|
192
|
+
[],
|
|
193
|
+
);
|
|
137
194
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
195
|
+
const invalidateAfterRun = useCallback(
|
|
196
|
+
(action: EscalatedAction) => {
|
|
197
|
+
invalidateThreadListQueries(
|
|
198
|
+
queryClient,
|
|
199
|
+
threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
|
|
200
|
+
);
|
|
201
|
+
if (accountId) {
|
|
202
|
+
queryClient.invalidateQueries({
|
|
203
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
204
|
+
path: { accountId },
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
[queryClient, mailboxId, accountId],
|
|
210
|
+
);
|
|
153
211
|
|
|
154
212
|
const escalate = useCallback(() => {
|
|
155
213
|
cancelRef.current = false;
|
|
@@ -187,53 +245,55 @@ export const useEscalatedDelete = ({
|
|
|
187
245
|
setPhase({ kind: "idle" });
|
|
188
246
|
}, []);
|
|
189
247
|
|
|
190
|
-
const
|
|
191
|
-
async (
|
|
248
|
+
const runAction = useCallback(
|
|
249
|
+
async (
|
|
250
|
+
action: EscalatedAction,
|
|
251
|
+
ids?: string[],
|
|
252
|
+
): Promise<BulkRunOutcome> => {
|
|
192
253
|
cancelRef.current = false;
|
|
193
|
-
|
|
254
|
+
setRunningAction(action);
|
|
194
255
|
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
195
|
-
// predicate can match more by the time the
|
|
256
|
+
// predicate can match more by the time the run re-pages it than
|
|
196
257
|
// `countMatches` saw, and the bar must never show more done than out of.
|
|
197
|
-
const onProgress = (
|
|
198
|
-
|
|
258
|
+
const onProgress = (next: BulkActionProgress) =>
|
|
259
|
+
setProgress(honestProgress(next));
|
|
260
|
+
const applyBatch = applyBatchFor(action);
|
|
199
261
|
|
|
200
262
|
const outcome =
|
|
201
263
|
ids !== undefined
|
|
202
|
-
? await
|
|
264
|
+
? await runChunkedAction(
|
|
203
265
|
ids,
|
|
204
|
-
|
|
266
|
+
applyBatch,
|
|
205
267
|
onProgress,
|
|
206
268
|
() => cancelRef.current,
|
|
207
269
|
)
|
|
208
|
-
: await
|
|
270
|
+
: await runPredicateAction(
|
|
209
271
|
fetchIdsPage,
|
|
210
272
|
phase.kind === "escalated" ? phase.total : 0,
|
|
211
|
-
|
|
273
|
+
applyBatch,
|
|
212
274
|
onProgress,
|
|
213
275
|
() => cancelRef.current,
|
|
214
276
|
);
|
|
215
277
|
|
|
216
|
-
|
|
217
|
-
|
|
278
|
+
setRunningAction(undefined);
|
|
279
|
+
setProgress(undefined);
|
|
218
280
|
setPhase({ kind: "idle" });
|
|
219
281
|
|
|
220
282
|
if (outcome.error) {
|
|
221
283
|
pushError(
|
|
222
284
|
buildMutationErrorBanner(
|
|
223
|
-
outcome.done
|
|
224
|
-
|
|
225
|
-
: "Couldn't delete these messages",
|
|
226
|
-
"The delete didn't finish.",
|
|
285
|
+
bulkActionFailureTitle(action.kind, outcome.done),
|
|
286
|
+
bulkActionFailureDetail(action.kind),
|
|
227
287
|
outcome.error,
|
|
228
288
|
),
|
|
229
289
|
);
|
|
230
290
|
}
|
|
231
291
|
if (outcome.done > 0) {
|
|
232
|
-
|
|
292
|
+
invalidateAfterRun(action);
|
|
233
293
|
}
|
|
234
294
|
return outcome;
|
|
235
295
|
},
|
|
236
|
-
[
|
|
296
|
+
[applyBatchFor, fetchIdsPage, phase, pushError, invalidateAfterRun],
|
|
237
297
|
);
|
|
238
298
|
|
|
239
299
|
return {
|
|
@@ -241,8 +301,9 @@ export const useEscalatedDelete = ({
|
|
|
241
301
|
escalate,
|
|
242
302
|
stop,
|
|
243
303
|
clear,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
304
|
+
isRunning: runningAction !== undefined,
|
|
305
|
+
runningAction,
|
|
306
|
+
progress,
|
|
307
|
+
runAction,
|
|
247
308
|
};
|
|
248
309
|
};
|
|
@@ -80,10 +80,14 @@ export const useMoveMessages = ({
|
|
|
80
80
|
path: { threadId },
|
|
81
81
|
})
|
|
82
82
|
: [];
|
|
83
|
-
// The source
|
|
84
|
-
// backs the daily brief — moving from the
|
|
85
|
-
// there too, not only from the per-mailbox
|
|
86
|
-
|
|
83
|
+
// The source and destination mailboxes' lists plus the unified
|
|
84
|
+
// cross-account listing that backs the daily brief — moving from the
|
|
85
|
+
// brief has to remove the row there too, not only from the per-mailbox
|
|
86
|
+
// lists (#140, part of #149).
|
|
87
|
+
const listPrefixes = threadListCacheKeys([
|
|
88
|
+
mailboxId,
|
|
89
|
+
variables.body.destinationMailboxId,
|
|
90
|
+
]);
|
|
87
91
|
|
|
88
92
|
// Only cancel the thread-messages query when we actually have a
|
|
89
93
|
// threadId — passing an empty queryKey here would match every
|
|
@@ -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");
|