@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.
@@ -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
- type BulkDeleteProgress,
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
- runChunkedDelete,
22
- runPredicateDelete,
23
- } from "@/lib/bulk-delete";
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 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,70 @@ 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
+ /**
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
- }: UseEscalatedDeleteOptions): UseEscalatedDeleteResult => {
123
+ }: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
90
124
  const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
91
- const [isDeleting, setIsDeleting] = useState(false);
92
- const [deleteProgress, setDeleteProgress] = useState<
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
- // delete a predicate the visible list no longer reflects.
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 deleteBatch = useCallback(async (ids: string[]) => {
131
- const { data } = await messageBulkOperationsDeleteMessages({
132
- body: { messageIds: ids },
133
- throwOnError: true,
134
- });
135
- return data;
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 invalidateAfterDelete = useCallback(() => {
139
- queryClient.invalidateQueries({
140
- queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
141
- });
142
- queryClient.invalidateQueries({
143
- queryKey: threadOperationsSearchThreadsQueryKey({ path: { mailboxId } }),
144
- });
145
- if (accountId) {
146
- queryClient.invalidateQueries({
147
- queryKey: mailboxOperationsListMailboxesQueryKey({
148
- path: { accountId },
149
- }),
150
- });
151
- }
152
- }, [queryClient, mailboxId, accountId]);
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 runDelete = useCallback(
191
- async (ids?: string[]): Promise<DeleteRunOutcome> => {
248
+ const runAction = useCallback(
249
+ async (
250
+ action: EscalatedAction,
251
+ ids?: string[],
252
+ ): Promise<BulkRunOutcome> => {
192
253
  cancelRef.current = false;
193
- setIsDeleting(true);
254
+ setRunningAction(action);
194
255
  // `honestProgress` widens `total` if `done` overtakes it (#109) — the
195
- // predicate can match more by the time the delete re-pages it than
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 = (progress: BulkDeleteProgress) =>
198
- setDeleteProgress(honestProgress(progress));
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 runChunkedDelete(
264
+ ? await runChunkedAction(
203
265
  ids,
204
- deleteBatch,
266
+ applyBatch,
205
267
  onProgress,
206
268
  () => cancelRef.current,
207
269
  )
208
- : await runPredicateDelete(
270
+ : await runPredicateAction(
209
271
  fetchIdsPage,
210
272
  phase.kind === "escalated" ? phase.total : 0,
211
- deleteBatch,
273
+ applyBatch,
212
274
  onProgress,
213
275
  () => cancelRef.current,
214
276
  );
215
277
 
216
- setIsDeleting(false);
217
- setDeleteProgress(undefined);
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 > 0
224
- ? `Stopped after ${outcome.done} — some messages weren't deleted`
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
- invalidateAfterDelete();
292
+ invalidateAfterRun(action);
233
293
  }
234
294
  return outcome;
235
295
  },
236
- [deleteBatch, fetchIdsPage, phase, pushError, invalidateAfterDelete],
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
- isDeleting,
245
- deleteProgress,
246
- runDelete,
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 mailbox's lists plus the unified cross-account listing that
84
- // backs the daily brief — moving from the brief has to remove the row
85
- // there too, not only from the per-mailbox lists (#140, part of #149).
86
- const listPrefixes = threadListCacheKeys([mailboxId]);
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 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");