@remit/web-client 0.0.42 → 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.
@@ -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
- type BulkDeleteProgress,
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
- runChunkedDelete,
22
- runPredicateDelete,
23
- } from "@/lib/bulk-delete";
28
+ runChunkedAction,
29
+ runPredicateAction,
30
+ } from "@/lib/bulk-actions";
24
31
 
25
- /** The predicate a search-scoped delete re-issues on every page — the same
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 delete chunk — no
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 UseEscalatedDeleteOptions {
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 UseEscalatedDeleteResult {
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 a delete — at the next page
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 delete (bounded->100 ids, or the escalated
67
- * predicate) is running. */
68
- isDeleting: boolean;
69
- deleteProgress: BulkDeleteProgress | undefined;
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 a chunked delete. Pass `ids` for a materialized (bounded) selection;
72
- * omit it to delete the escalated predicate (`phase` must be "escalated").
73
- * Resolves once the run ends for any reason — cancelled, errored, or
74
- * complete — with a `done`/`failedIds` outcome the caller feeds to
75
- * `resolveSelectionAfterDelete` to decide what selection looks like next.
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
- runDelete: (ids?: string[]) => Promise<DeleteRunOutcome>;
99
+ runAction: (
100
+ action: EscalatedAction,
101
+ ids?: string[],
102
+ ) => Promise<BulkRunOutcome>;
81
103
  }
82
104
 
83
- export const useEscalatedDelete = ({
105
+ export const useEscalatedActions = ({
84
106
  mailboxId,
85
107
  accountId,
86
108
  enabled,
87
109
  predicateKey,
88
110
  searchQuery,
89
- }: UseEscalatedDeleteOptions): UseEscalatedDeleteResult => {
111
+ }: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
90
112
  const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
91
- const [isDeleting, setIsDeleting] = useState(false);
92
- const [deleteProgress, setDeleteProgress] = useState<
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
- // delete a predicate the visible list no longer reflects.
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 deleteBatch = useCallback(async (ids: string[]) => {
131
- const { data } = await messageBulkOperationsDeleteMessages({
132
- body: { messageIds: ids },
133
- throwOnError: true,
134
- });
135
- return data;
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 invalidateAfterDelete = useCallback(() => {
139
- queryClient.invalidateQueries({
140
- queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
141
- });
142
- queryClient.invalidateQueries({
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: mailboxOperationsListMailboxesQueryKey({
148
- path: { accountId },
189
+ queryKey: threadOperationsSearchThreadsQueryKey({
190
+ path: { mailboxId },
149
191
  }),
150
192
  });
151
- }
152
- }, [queryClient, mailboxId, accountId]);
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 runDelete = useCallback(
191
- async (ids?: string[]): Promise<DeleteRunOutcome> => {
247
+ const runAction = useCallback(
248
+ async (
249
+ action: EscalatedAction,
250
+ ids?: string[],
251
+ ): Promise<BulkRunOutcome> => {
192
252
  cancelRef.current = false;
193
- setIsDeleting(true);
253
+ setRunningAction(action);
194
254
  // `honestProgress` widens `total` if `done` overtakes it (#109) — the
195
- // predicate can match more by the time the delete re-pages it than
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 = (progress: BulkDeleteProgress) =>
198
- setDeleteProgress(honestProgress(progress));
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 runChunkedDelete(
263
+ ? await runChunkedAction(
203
264
  ids,
204
- deleteBatch,
265
+ applyBatch,
205
266
  onProgress,
206
267
  () => cancelRef.current,
207
268
  )
208
- : await runPredicateDelete(
269
+ : await runPredicateAction(
209
270
  fetchIdsPage,
210
271
  phase.kind === "escalated" ? phase.total : 0,
211
- deleteBatch,
272
+ applyBatch,
212
273
  onProgress,
213
274
  () => cancelRef.current,
214
275
  );
215
276
 
216
- setIsDeleting(false);
217
- setDeleteProgress(undefined);
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 > 0
224
- ? `Stopped after ${outcome.done} — some messages weren't deleted`
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
- invalidateAfterDelete();
291
+ invalidateAfterRun(action);
233
292
  }
234
293
  return outcome;
235
294
  },
236
- [deleteBatch, fetchIdsPage, phase, pushError, invalidateAfterDelete],
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
- isDeleting,
245
- deleteProgress,
246
- runDelete,
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 processDeleteOutcome materializing the failed ids as the new
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");