@remit/web-client 0.0.190 → 0.0.191

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.190",
3
+ "version": "0.0.191",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -9,9 +9,12 @@
9
9
  */
10
10
  import assert from "node:assert/strict";
11
11
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
12
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13
+ import i18next from "i18next";
12
14
  import type { JSDOM } from "jsdom";
13
- import { act, createElement, Fragment } from "react";
15
+ import React, { act, createElement, Fragment } from "react";
14
16
  import { createRoot, type Root } from "react-dom/client";
17
+ import { I18nextProvider, initReactI18next } from "react-i18next";
15
18
  import {
16
19
  type AuthProvider,
17
20
  AuthProviderProvider,
@@ -19,10 +22,25 @@ import {
19
22
  } from "@/auth/provider";
20
23
  import type { DeleteOutcome } from "@/lib/format";
21
24
  import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
25
+ import { RoleAppointmentPromptProvider } from "./RoleAppointmentPromptProvider";
26
+
27
+ // remit-ui's `.tsx` is transpiled with the classic JSX runtime, which
28
+ // references a global `React`; the app uses the automatic runtime, so this
29
+ // shim only exists for the test harness.
30
+ (globalThis as { React?: typeof React }).React = React;
31
+
32
+ const i18n = i18next.createInstance();
33
+ i18n.use(initReactI18next).init({
34
+ lng: "en",
35
+ ns: ["mail"],
36
+ defaultNS: "mail",
37
+ resources: { en: { mail: {} } },
38
+ });
22
39
 
23
40
  let dom: JSDOM;
24
41
  let container: HTMLElement;
25
42
  let root: Root;
43
+ const originalFetch = globalThis.fetch;
26
44
 
27
45
  before(async () => {
28
46
  const { JSDOM: JSDOMCtor } = await import("jsdom");
@@ -51,10 +69,61 @@ beforeEach(() => {
51
69
  ) as unknown as HTMLElement;
52
70
  container.innerHTML = "";
53
71
  root = createRoot(container);
72
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
73
+ const path = new URL(
74
+ input instanceof Request ? input.url : String(input),
75
+ "http://localhost",
76
+ ).pathname;
77
+ return new Response(JSON.stringify(answerFor(path)), {
78
+ status: 200,
79
+ headers: { "Content-Type": "application/json" },
80
+ });
81
+ }) as typeof fetch;
54
82
  });
55
83
 
84
+ const ACCOUNT = "acc-1";
85
+
86
+ /** Enough of the account for the appointment prompt to have folders to offer. */
87
+ const answerFor = (path: string): unknown => {
88
+ if (path.endsWith("/config")) {
89
+ return {
90
+ accounts: [
91
+ {
92
+ accountId: ACCOUNT,
93
+ email: `${ACCOUNT}@example.com`,
94
+ folderAppointments: [{ role: "Trash", source: "None" }],
95
+ },
96
+ ],
97
+ };
98
+ }
99
+ if (path.endsWith("/mailboxes")) {
100
+ return {
101
+ items: [
102
+ {
103
+ mailboxId: "mbx-trash",
104
+ accountId: ACCOUNT,
105
+ fullPath: "Prullenbak",
106
+ hierarchyDelimiter: "/",
107
+ messageCount: 3,
108
+ },
109
+ ],
110
+ };
111
+ }
112
+ return {};
113
+ };
114
+
115
+ /** Let the queries and the two writes behind the confirm run to completion. */
116
+ const settle = async (): Promise<void> => {
117
+ for (let round = 0; round < 8; round += 1) {
118
+ await act(async () => {
119
+ await Promise.resolve();
120
+ });
121
+ }
122
+ };
123
+
56
124
  afterEach(() => {
57
125
  act(() => root.unmount());
126
+ globalThis.fetch = originalFetch;
58
127
  });
59
128
 
60
129
  /** A deployment with an identity system and a live session to sign back into. */
@@ -72,23 +141,47 @@ const mount = (options: {
72
141
  outcome: DeleteOutcome;
73
142
  count?: number;
74
143
  isDeleting?: boolean;
144
+ accountId?: string;
145
+ trashFolderLabel?: string;
146
+ staleFolderLabel?: string;
147
+ trashIsUnconfirmed?: boolean;
75
148
  authProvider?: AuthProvider;
76
- onConfirm?: () => void;
149
+ onConfirm?: (messageIds: string[]) => void;
77
150
  }) => {
78
151
  const onConfirm = options.onConfirm ?? (() => undefined);
152
+ const messageIds = Array.from(
153
+ { length: options.count ?? 1 },
154
+ (_, index) => `msg-${index}`,
155
+ );
79
156
  act(() =>
80
157
  root.render(
81
158
  createElement(
82
- AuthProviderProvider,
83
- { value: options.authProvider ?? noneAuthProvider },
84
- createElement(DeleteConfirmDialog, {
85
- isOpen: true,
86
- count: options.count ?? 1,
87
- outcome: options.outcome,
88
- isDeleting: options.isDeleting,
89
- onConfirm,
90
- onCancel: () => undefined,
91
- }),
159
+ I18nextProvider,
160
+ { i18n },
161
+ createElement(
162
+ QueryClientProvider,
163
+ { client: new QueryClient() },
164
+ createElement(
165
+ RoleAppointmentPromptProvider,
166
+ null,
167
+ createElement(
168
+ AuthProviderProvider,
169
+ { value: options.authProvider ?? noneAuthProvider },
170
+ createElement(DeleteConfirmDialog, {
171
+ isOpen: true,
172
+ messageIds,
173
+ outcome: options.outcome,
174
+ accountId: options.accountId,
175
+ trashFolderLabel: options.trashFolderLabel,
176
+ staleFolderLabel: options.staleFolderLabel,
177
+ trashIsUnconfirmed: options.trashIsUnconfirmed,
178
+ isDeleting: options.isDeleting,
179
+ onConfirm,
180
+ onCancel: () => undefined,
181
+ }),
182
+ ),
183
+ ),
184
+ ),
92
185
  ),
93
186
  ),
94
187
  );
@@ -98,6 +191,8 @@ const mount = (options: {
98
191
  Array.from(
99
192
  dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
100
193
  ).find((b) => b.textContent === label),
194
+ byLabel: (label: string) =>
195
+ dom.window.document.querySelector<HTMLElement>(`[aria-label="${label}"]`),
101
196
  };
102
197
  };
103
198
 
@@ -182,33 +277,120 @@ describe("DeleteConfirmDialog — a read that failed refuses the delete", () =>
182
277
  });
183
278
 
184
279
  /**
185
- * An account that appoints no Trash is a resolved answer of "none", not a
186
- * missing one: the server refuses that delete outright (#846) rather than
187
- * moving anything, so the dialog may not offer a move — and the remedy is the
188
- * appointment, not the session.
280
+ * An account that appoints no Trash, or one whose appointed folder is gone, is
281
+ * a resolved answer: the server refuses that delete outright (#846) rather than
282
+ * moving anything. The remedy is the appointment, made where the refusal
283
+ * happened never a link to Settings the user has to come back from (#887).
189
284
  */
190
- describe("DeleteConfirmDialog — no Trash appointed is not a move", () => {
285
+ describe("DeleteConfirmDialog — a refusal answers itself", () => {
191
286
  it("refuses rather than promising a restore", () => {
192
- const view = mount({ outcome: "noTrash", count: 2 });
287
+ const view = mount({ outcome: "noTrash", count: 2, accountId: "acc-1" });
193
288
  const text = view.text();
194
- assert.match(text, /Can.t delete 2 messages/);
195
- assert.match(text, /appointed as Trash/);
289
+ assert.match(text, /Can.t delete 2 messages yet/);
290
+ assert.match(text, /No folder on this account is set as Trash/);
196
291
  assert.doesNotMatch(text, /Move 2 messages to Trash?/);
197
292
  assert.doesNotMatch(text, /restore them from Trash later/);
198
293
  });
199
294
 
200
- it("names the screen that fixes it, and cannot reach the delete", () => {
295
+ it("opens the appointment prompt instead of leaving for Settings", () => {
201
296
  let confirmed = 0;
202
297
  const view = mount({
203
298
  outcome: "noTrash",
299
+ accountId: "acc-1",
204
300
  onConfirm: () => {
205
301
  confirmed += 1;
206
302
  },
207
303
  });
208
- const settings = view.button("Open folder settings");
209
- assert.ok(settings, "the remedy the copy names is on screen");
210
- assert.equal(settings?.disabled, false, "and it can be pressed");
211
- assert.equal(view.button("Move to Trash"), undefined);
212
- assert.equal(confirmed, 0);
304
+ assert.equal(view.button("Open folder settings"), undefined);
305
+ const pick = view.button("Pick a Trash folder");
306
+ assert.ok(pick, "the remedy the copy names is on screen");
307
+ assert.equal(pick?.disabled, false);
308
+
309
+ act(() => pick?.click());
310
+ assert.match(view.text(), /No folder is set as Trash/);
311
+ assert.equal(confirmed, 0, "the refusal never reaches the delete");
312
+ });
313
+
314
+ it("names the folder that vanished, and repairs it in place", () => {
315
+ const view = mount({
316
+ outcome: "staleTrash",
317
+ count: 2,
318
+ accountId: "acc-1",
319
+ staleFolderLabel: "INBOX/Prullenbak",
320
+ });
321
+ assert.match(view.text(), /INBOX\/Prullenbak/);
322
+ const pick = view.button("Pick another folder");
323
+ assert.ok(pick);
324
+ act(() => pick?.click());
325
+ assert.match(view.text(), /The Trash folder you chose is gone/);
326
+ });
327
+
328
+ it("still acts when no single account owns the rows", () => {
329
+ const confirmed: string[][] = [];
330
+ const view = mount({
331
+ outcome: "noTrash",
332
+ onConfirm: (ids) => confirmed.push(ids),
333
+ });
334
+ const pick = view.button("Pick a Trash folder");
335
+ assert.equal(pick?.disabled, false, "never a control that does nothing");
336
+ act(() => pick?.click());
337
+ assert.deepEqual(
338
+ confirmed,
339
+ [["msg-0"]],
340
+ "the server's own 409 names the account",
341
+ );
342
+ });
343
+
344
+ // The dialog is gone by the time the appointment lands, so the replay cannot
345
+ // read the caller's pending state — it carries the rows it was about.
346
+ it("hands the replay the rows the delete was about", async () => {
347
+ const confirmed: string[][] = [];
348
+ const view = mount({
349
+ outcome: "noTrash",
350
+ count: 3,
351
+ accountId: ACCOUNT,
352
+ onConfirm: (ids) => confirmed.push(ids),
353
+ });
354
+
355
+ act(() => view.button("Pick a Trash folder")?.click());
356
+ await settle();
357
+ assert.deepEqual(
358
+ confirmed,
359
+ [],
360
+ "nothing is deleted before a folder is set",
361
+ );
362
+
363
+ act(() => view.byLabel("Set Prullenbak, 3 messages, as Trash")?.click());
364
+ await settle();
365
+ act(() => view.button("Set as Trash and delete 3 messages")?.click());
366
+ await settle();
367
+
368
+ assert.deepEqual(confirmed, [["msg-0", "msg-1", "msg-2"]]);
369
+ });
370
+ });
371
+
372
+ /**
373
+ * D4a: an expunge inside a Trash reader only matched by name still goes
374
+ * through — the user asked for these specific rows — but they are told which
375
+ * folder that is, and that nobody chose it, before it happens.
376
+ */
377
+ describe("DeleteConfirmDialog — an expunge inside a Trash nobody confirmed", () => {
378
+ it("keeps today's words for a confirmed Trash", () => {
379
+ const view = mount({ outcome: "permanent", count: 3 });
380
+ assert.doesNotMatch(view.text(), /nobody confirmed it/);
381
+ });
382
+
383
+ it("names the folder and says nobody chose it", () => {
384
+ const view = mount({
385
+ outcome: "permanent",
386
+ count: 3,
387
+ trashFolderLabel: "Deleted Messages",
388
+ trashIsUnconfirmed: true,
389
+ });
390
+ const text = view.text();
391
+ assert.match(text, /Permanently delete 3 messages\?/);
392
+ assert.match(text, /They are in Deleted Messages/);
393
+ assert.match(text, /nobody confirmed it/);
394
+ assert.equal(view.button("Delete permanently")?.disabled, false);
213
395
  });
214
396
  });
@@ -5,49 +5,85 @@
5
5
  * and Flagged — so the wording and the refusal cannot drift apart again the way
6
6
  * they did between #845 and #855.
7
7
  *
8
- * Two of the outcomes are not confirmations at all. When the account appoints
9
- * no Trash the server refuses the delete outright, and when its folder settings
10
- * could not be read reader cannot say whether a delete moves the mail or erases
11
- * it. Both refuse: the affirmative control carries the remedy the copy names
12
- * folder settings, or signing back in — and the caller's `onConfirm` is never
13
- * reached. Neither may render as "this folder is not Trash".
8
+ * Three of the outcomes are not confirmations at all. When the account has no
9
+ * Trash, or the folder it appointed is gone, the server refuses the delete
10
+ * outright; when its folder settings could not be read reader cannot say
11
+ * whether a delete moves the mail or erases it. All three refuse and the
12
+ * caller's `onConfirm` is never reached directly. The first two are answered
13
+ * in place the affirmative control opens the appointment prompt, whose own
14
+ * confirm appoints the folder and then runs this delete. None of them may
15
+ * render as "this folder is not Trash".
14
16
  */
15
17
  import { ConfirmDialog } from "@remit/ui";
16
18
  import { useAuthProvider } from "@/auth/provider";
17
19
  import { type DeleteOutcome, deleteConfirmationCopy } from "@/lib/format";
18
-
19
- /** Where a missing Trash appointment is made — the remedy the copy names. */
20
- const FOLDER_SETTINGS_PATH = "/settings/folders";
20
+ import { useRoleAppointmentPrompt } from "./RoleAppointmentPromptProvider";
21
21
 
22
22
  interface DeleteConfirmDialogProps {
23
23
  isOpen: boolean;
24
- /** How many messages the pending delete covers. */
25
- count: number;
24
+ /**
25
+ * The rows the pending delete covers. Carried rather than counted, because
26
+ * an appointment replays this delete after the dialog has closed — reading
27
+ * the caller's pending state by then would replay nothing.
28
+ */
29
+ messageIds: readonly string[];
26
30
  outcome: DeleteOutcome;
31
+ /** The account the appointment would be made on, when the rows share one. */
32
+ accountId?: string;
33
+ /** The folder reader files this account's deletes in. */
34
+ trashFolderLabel?: string;
35
+ /** The folder the user appointed, now gone from the mail server. */
36
+ staleFolderLabel?: string;
37
+ /** That folder is a name match nobody ever confirmed. */
38
+ trashIsUnconfirmed?: boolean;
27
39
  /** A delete is already in flight, so the confirm cannot be pressed again. */
28
40
  isDeleting?: boolean;
29
- onConfirm: () => void;
41
+ onConfirm: (messageIds: string[]) => void;
30
42
  onCancel: () => void;
31
43
  }
32
44
 
33
45
  export const DeleteConfirmDialog = ({
34
46
  isOpen,
35
- count,
47
+ messageIds,
36
48
  outcome,
49
+ accountId,
50
+ trashFolderLabel,
51
+ staleFolderLabel,
52
+ trashIsUnconfirmed = false,
37
53
  isDeleting = false,
38
54
  onConfirm,
39
55
  onCancel,
40
56
  }: DeleteConfirmDialogProps) => {
41
57
  const { Account } = useAuthProvider();
42
- const copy = deleteConfirmationCopy(count, outcome);
58
+ const { requestAppointment } = useRoleAppointmentPrompt();
59
+ const count = messageIds.length;
60
+ const confirm = () => onConfirm([...messageIds]);
61
+ const copy = deleteConfirmationCopy(count, outcome, {
62
+ trashFolderLabel,
63
+ staleFolderLabel,
64
+ trashIsUnconfirmed,
65
+ });
43
66
 
44
- if (outcome === "noTrash") {
67
+ if (outcome === "noTrash" || outcome === "staleTrash") {
45
68
  return (
46
69
  <ConfirmDialog
47
70
  isOpen={isOpen}
48
71
  {...copy}
49
72
  onConfirm={() => {
50
- window.location.assign(FOLDER_SETTINGS_PATH);
73
+ // With no account to appoint on, the delete is issued anyway and
74
+ // the server's own 409 opens the prompt naming the account it
75
+ // refused for. Never a control that does nothing.
76
+ if (!accountId) return confirm();
77
+ const replay = [...messageIds];
78
+ onCancel();
79
+ requestAppointment({
80
+ accountId,
81
+ role: "Trash",
82
+ reason: outcome === "noTrash" ? "none" : "stale",
83
+ action: { kind: "delete", count },
84
+ staleFolderLabel,
85
+ onAppointed: async () => onConfirm(replay),
86
+ });
51
87
  }}
52
88
  onCancel={onCancel}
53
89
  />
@@ -88,7 +124,7 @@ export const DeleteConfirmDialog = ({
88
124
  // The confirm holds while the appointment is still arriving: the answer
89
125
  // is seconds away and it decides which of two dialogs this is.
90
126
  isBusy={isDeleting || outcome === "unknown"}
91
- onConfirm={onConfirm}
127
+ onConfirm={confirm}
92
128
  onCancel={onCancel}
93
129
  />
94
130
  );
@@ -304,7 +304,11 @@ export const MessageList = ({
304
304
  () => [{ accountId, mailboxId }],
305
305
  [accountId, mailboxId],
306
306
  );
307
- const deleteOutcome = useDeleteOutcome(deleteScope);
307
+ const {
308
+ outcome: deleteOutcome,
309
+ trashIsUnconfirmed,
310
+ staleFolderLabel,
311
+ } = useDeleteOutcome(deleteScope);
308
312
 
309
313
  // Selection state
310
314
  const {
@@ -688,76 +692,75 @@ export const MessageList = ({
688
692
 
689
693
  // Confirm handler: run the actual delete, then clear selection and move
690
694
  // focus to a sensible neighbor (the row after the first deleted one).
691
- const handleConfirmDelete = useCallback(() => {
692
- if (!pendingDelete) return;
693
-
694
- const ids = pendingDelete;
695
- if (ids.length === 0) {
696
- setPendingDelete(null);
697
- return;
698
- }
699
-
700
- const deletedSet = new Set(ids);
701
- const firstDeletedIndex = threads.findIndex((t) =>
702
- deletedSet.has(t.messageId),
703
- );
704
- // Next surviving row at or after the first deleted row, else the one
705
- // before it. Computed against the pre-delete order.
706
- let nextFocus: string | undefined;
707
- for (let i = firstDeletedIndex + 1; i < threads.length; i++) {
708
- if (!deletedSet.has(threads[i].messageId)) {
709
- nextFocus = threads[i].messageId;
710
- break;
695
+ const handleConfirmDelete = useCallback(
696
+ (ids: string[]) => {
697
+ if (ids.length === 0) {
698
+ setPendingDelete(null);
699
+ return;
711
700
  }
712
- }
713
- if (nextFocus === undefined) {
714
- for (let i = firstDeletedIndex - 1; i >= 0; i--) {
701
+
702
+ const deletedSet = new Set(ids);
703
+ const firstDeletedIndex = threads.findIndex((t) =>
704
+ deletedSet.has(t.messageId),
705
+ );
706
+ // Next surviving row at or after the first deleted row, else the one
707
+ // before it. Computed against the pre-delete order.
708
+ let nextFocus: string | undefined;
709
+ for (let i = firstDeletedIndex + 1; i < threads.length; i++) {
715
710
  if (!deletedSet.has(threads[i].messageId)) {
716
711
  nextFocus = threads[i].messageId;
717
712
  break;
718
713
  }
719
714
  }
720
- }
715
+ if (nextFocus === undefined) {
716
+ for (let i = firstDeletedIndex - 1; i >= 0; i--) {
717
+ if (!deletedSet.has(threads[i].messageId)) {
718
+ nextFocus = threads[i].messageId;
719
+ break;
720
+ }
721
+ }
722
+ }
721
723
 
722
- onDeleteMessages?.(ids);
723
- exitSelection();
724
- focusBeforeConfirmRef.current = null;
725
- setPendingDelete(null);
724
+ onDeleteMessages?.(ids);
725
+ exitSelection();
726
+ focusBeforeConfirmRef.current = null;
727
+ setPendingDelete(null);
726
728
 
727
- if (nextFocus !== undefined) {
728
- // Same hand-back as cancelling, aimed at the surviving neighbour
729
- // instead: confirming also closes a dialog that held DOM focus.
730
- pendingDomFocusRef.current = nextFocus;
731
- cursorMovedByPointerRef.current = false;
732
- setFocusedMessageId(nextFocus);
733
- // Desktop is two-pane: opening the neighbour fills the reading pane
734
- // beside the list. On a single-pane mobile layout the same navigation
735
- // replaces the list with a full-screen message, so a bulk delete looks
736
- // like it opened a random neighbour instead of removing the rows (#202).
737
- // Mobile keeps the cursor move but stays on the list.
738
- if (isDesktop) {
739
- openRow(nextFocus, { replace: true });
729
+ if (nextFocus !== undefined) {
730
+ // Same hand-back as cancelling, aimed at the surviving neighbour
731
+ // instead: confirming also closes a dialog that held DOM focus.
732
+ pendingDomFocusRef.current = nextFocus;
733
+ cursorMovedByPointerRef.current = false;
734
+ setFocusedMessageId(nextFocus);
735
+ // Desktop is two-pane: opening the neighbour fills the reading pane
736
+ // beside the list. On a single-pane mobile layout the same navigation
737
+ // replaces the list with a full-screen message, so a bulk delete looks
738
+ // like it opened a random neighbour instead of removing the rows (#202).
739
+ // Mobile keeps the cursor move but stays on the list.
740
+ if (isDesktop) {
741
+ openRow(nextFocus, { replace: true });
742
+ }
740
743
  }
741
- }
742
744
 
743
- // Mobile keeps the list up, so it needs its own signal the delete landed
744
- // (#202). On desktop the rows leaving the list beside the reading pane is
745
- // signal enough.
746
- if (!isDesktop) {
747
- setCompletionBanner(
748
- bulkActionCompletionText("delete", ids.length, deleteOutcome),
749
- );
750
- }
751
- }, [
752
- pendingDelete,
753
- threads,
754
- onDeleteMessages,
755
- exitSelection,
756
- openRow,
757
- isDesktop,
758
- setFocusedMessageId,
759
- deleteOutcome,
760
- ]);
745
+ // Mobile keeps the list up, so it needs its own signal the delete landed
746
+ // (#202). On desktop the rows leaving the list beside the reading pane is
747
+ // signal enough.
748
+ if (!isDesktop) {
749
+ setCompletionBanner(
750
+ bulkActionCompletionText("delete", ids.length, deleteOutcome),
751
+ );
752
+ }
753
+ },
754
+ [
755
+ threads,
756
+ onDeleteMessages,
757
+ exitSelection,
758
+ openRow,
759
+ isDesktop,
760
+ setFocusedMessageId,
761
+ deleteOutcome,
762
+ ],
763
+ );
761
764
 
762
765
  // Every way out of the confirmation that isn't the delete — Escape, Cancel,
763
766
  // the backdrop — arrives here, so this is the one place the keyboard has to
@@ -1343,8 +1346,14 @@ export const MessageList = ({
1343
1346
  />
1344
1347
  <DeleteConfirmDialog
1345
1348
  isOpen={pendingDelete !== null}
1346
- count={pendingDelete?.length ?? 0}
1349
+ messageIds={pendingDelete ?? []}
1347
1350
  outcome={deleteOutcome}
1351
+ accountId={accountId}
1352
+ // Every row here is filed in the open mailbox, so on an expunge that
1353
+ // mailbox is the Trash the copy has to name.
1354
+ trashFolderLabel={listTitle}
1355
+ staleFolderLabel={staleFolderLabel}
1356
+ trashIsUnconfirmed={trashIsUnconfirmed}
1348
1357
  isDeleting={isDeleting}
1349
1358
  onConfirm={handleConfirmDelete}
1350
1359
  onCancel={handleCancelDelete}