@remit/web-client 0.0.215 → 0.0.216
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/DeleteConfirmDialog.render.test.ts +37 -21
- package/src/components/mail/DeleteConfirmDialog.tsx +26 -14
- package/src/components/mail/MessageList.run-reporting.test.ts +21 -12
- package/src/components/mail/MessageList.tsx +2 -2
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +1 -0
- package/src/components/mail/ThreadListInteraction.tsx +2 -2
- package/src/hooks/escalated-run-refusal.render.test.ts +156 -0
- package/src/hooks/useDeleteOutcome.ts +18 -13
- package/src/hooks/useEscalatedActions.ts +42 -2
- package/src/lib/bulk-action-copy.ts +2 -2
- package/src/lib/format.test.ts +50 -41
- package/src/lib/format.ts +43 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.216",
|
|
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": {
|
|
@@ -144,7 +144,6 @@ const mount = (options: {
|
|
|
144
144
|
accountId?: string;
|
|
145
145
|
trashFolderLabel?: string;
|
|
146
146
|
staleFolderLabel?: string;
|
|
147
|
-
trashIsUnconfirmed?: boolean;
|
|
148
147
|
authProvider?: AuthProvider;
|
|
149
148
|
onConfirm?: (messageIds: string[]) => void;
|
|
150
149
|
}) => {
|
|
@@ -174,7 +173,6 @@ const mount = (options: {
|
|
|
174
173
|
accountId: options.accountId,
|
|
175
174
|
trashFolderLabel: options.trashFolderLabel,
|
|
176
175
|
staleFolderLabel: options.staleFolderLabel,
|
|
177
|
-
trashIsUnconfirmed: options.trashIsUnconfirmed,
|
|
178
176
|
isDeleting: options.isDeleting,
|
|
179
177
|
onConfirm,
|
|
180
178
|
onCancel: () => undefined,
|
|
@@ -325,6 +323,40 @@ describe("DeleteConfirmDialog — a refusal answers itself", () => {
|
|
|
325
323
|
assert.match(view.text(), /The Trash folder you chose is gone/);
|
|
326
324
|
});
|
|
327
325
|
|
|
326
|
+
// #876: a row already inside the folder reader only matched by name refuses
|
|
327
|
+
// like Empty Trash's own "unconfirmed" — not a plain "Delete permanently"
|
|
328
|
+
// the server would 409 on.
|
|
329
|
+
it("asks to confirm the guessed folder before an expunge nobody confirmed", async () => {
|
|
330
|
+
const view = mount({
|
|
331
|
+
outcome: "unconfirmed",
|
|
332
|
+
count: 3,
|
|
333
|
+
accountId: ACCOUNT,
|
|
334
|
+
trashFolderLabel: "Deleted Messages",
|
|
335
|
+
});
|
|
336
|
+
const text = view.text();
|
|
337
|
+
assert.match(text, /Confirm this account's Trash folder/);
|
|
338
|
+
assert.match(text, /Deleted Messages/);
|
|
339
|
+
assert.doesNotMatch(text, /Permanently delete 3 messages\?/);
|
|
340
|
+
const confirm = view.button("Confirm the folder");
|
|
341
|
+
assert.ok(confirm, "the remedy the copy names is on screen");
|
|
342
|
+
act(() => confirm?.click());
|
|
343
|
+
// DeleteConfirmDialog's own "unconfirmed" copy shares its title with the
|
|
344
|
+
// appointment prompt's, so the title alone cannot tell the two dialogs
|
|
345
|
+
// apart — matching only that string would pass whether or not the
|
|
346
|
+
// prompt actually replaced the dialog. The picker prompt is the
|
|
347
|
+
// prompt's own wording and never appears in DeleteConfirmDialog's copy.
|
|
348
|
+
assert.match(
|
|
349
|
+
view.text(),
|
|
350
|
+
/Confirm Deleted Messages, or pick the folder this account really uses\./,
|
|
351
|
+
);
|
|
352
|
+
await settle();
|
|
353
|
+
act(() => view.byLabel("Set Prullenbak, 3 messages, as Trash")?.click());
|
|
354
|
+
assert.ok(
|
|
355
|
+
view.button("Set as Trash and delete 3 messages"),
|
|
356
|
+
"the folder picker and the delete-specific confirm label are the prompt's own, not the dialog's",
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
328
360
|
it("still acts when no single account owns the rows", () => {
|
|
329
361
|
const confirmed: string[][] = [];
|
|
330
362
|
const view = mount({
|
|
@@ -369,28 +401,12 @@ describe("DeleteConfirmDialog — a refusal answers itself", () => {
|
|
|
369
401
|
});
|
|
370
402
|
});
|
|
371
403
|
|
|
372
|
-
|
|
373
|
-
|
|
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", () => {
|
|
404
|
+
describe("DeleteConfirmDialog — an expunge inside a confirmed Trash", () => {
|
|
405
|
+
it("asks for the expunge itself, with no folder to confirm first", () => {
|
|
379
406
|
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
407
|
const text = view.text();
|
|
391
408
|
assert.match(text, /Permanently delete 3 messages\?/);
|
|
392
|
-
assert.
|
|
393
|
-
assert.match(text, /nobody confirmed it/);
|
|
409
|
+
assert.doesNotMatch(text, /nobody confirmed it/);
|
|
394
410
|
assert.equal(view.button("Delete permanently")?.disabled, false);
|
|
395
411
|
});
|
|
396
412
|
});
|
|
@@ -5,14 +5,15 @@
|
|
|
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
|
-
*
|
|
9
|
-
* Trash,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
8
|
+
* Four of the outcomes are not confirmations at all. When the account has no
|
|
9
|
+
* Trash, the folder it appointed is gone, or the row would expunge inside a
|
|
10
|
+
* Trash nobody ever confirmed, the server refuses the delete outright; when
|
|
11
|
+
* its folder settings could not be read reader cannot say whether a delete
|
|
12
|
+
* moves the mail or erases it. All four refuse and the caller's `onConfirm` is
|
|
13
|
+
* never reached directly. The first three are answered in place — the
|
|
14
|
+
* affirmative control opens the appointment prompt, whose own confirm appoints
|
|
15
|
+
* the folder and then runs this delete. None of them may render as "this
|
|
16
|
+
* folder is not Trash".
|
|
16
17
|
*/
|
|
17
18
|
import { ConfirmDialog } from "@remit/ui";
|
|
18
19
|
import { useAuthProvider } from "@/auth/provider";
|
|
@@ -34,8 +35,8 @@ interface DeleteConfirmDialogProps {
|
|
|
34
35
|
trashFolderLabel?: string;
|
|
35
36
|
/** The folder the user appointed, now gone from the mail server. */
|
|
36
37
|
staleFolderLabel?: string;
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/** `unconfirmed`: that guessed folder by id, so the prompt opens on it. */
|
|
39
|
+
guessedMailboxId?: string;
|
|
39
40
|
/** A delete is already in flight, so the confirm cannot be pressed again. */
|
|
40
41
|
isDeleting?: boolean;
|
|
41
42
|
onConfirm: (messageIds: string[]) => void;
|
|
@@ -49,7 +50,7 @@ export const DeleteConfirmDialog = ({
|
|
|
49
50
|
accountId,
|
|
50
51
|
trashFolderLabel,
|
|
51
52
|
staleFolderLabel,
|
|
52
|
-
|
|
53
|
+
guessedMailboxId,
|
|
53
54
|
isDeleting = false,
|
|
54
55
|
onConfirm,
|
|
55
56
|
onCancel,
|
|
@@ -61,10 +62,19 @@ export const DeleteConfirmDialog = ({
|
|
|
61
62
|
const copy = deleteConfirmationCopy(count, outcome, {
|
|
62
63
|
trashFolderLabel,
|
|
63
64
|
staleFolderLabel,
|
|
64
|
-
trashIsUnconfirmed,
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
if (
|
|
67
|
+
if (
|
|
68
|
+
outcome === "noTrash" ||
|
|
69
|
+
outcome === "staleTrash" ||
|
|
70
|
+
outcome === "unconfirmed"
|
|
71
|
+
) {
|
|
72
|
+
const reason =
|
|
73
|
+
outcome === "noTrash"
|
|
74
|
+
? "none"
|
|
75
|
+
: outcome === "staleTrash"
|
|
76
|
+
? "stale"
|
|
77
|
+
: "unconfirmed";
|
|
68
78
|
return (
|
|
69
79
|
<ConfirmDialog
|
|
70
80
|
isOpen={isOpen}
|
|
@@ -79,9 +89,11 @@ export const DeleteConfirmDialog = ({
|
|
|
79
89
|
requestAppointment({
|
|
80
90
|
accountId,
|
|
81
91
|
role: "Trash",
|
|
82
|
-
reason
|
|
92
|
+
reason,
|
|
83
93
|
action: { kind: "delete", count },
|
|
84
94
|
staleFolderLabel,
|
|
95
|
+
trashFolderLabel,
|
|
96
|
+
guessedMailboxId,
|
|
85
97
|
onAppointed: async () => onConfirm(replay),
|
|
86
98
|
});
|
|
87
99
|
}}
|
|
@@ -5,6 +5,7 @@ import { describe, it } from "node:test";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { runEndingBanner } from "@/lib/bulk-action-copy";
|
|
7
7
|
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
8
|
+
import { type DeleteOutcome, deleteExpunges } from "@/lib/format";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A run states how it ended, to whoever is still there to read it (#521). The
|
|
@@ -28,6 +29,8 @@ import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
|
28
29
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
|
|
30
31
|
|
|
32
|
+
const expunging: DeleteOutcome[] = ["permanent", "unconfirmed"];
|
|
33
|
+
|
|
31
34
|
const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
|
|
32
35
|
done: 100,
|
|
33
36
|
failedIds: [],
|
|
@@ -92,18 +95,24 @@ describe("reporting how a run ended", () => {
|
|
|
92
95
|
);
|
|
93
96
|
});
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
?.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
// Both outcomes a row already inside Trash produces, because both erase it.
|
|
99
|
+
// `unconfirmed` is only ever reached that way (#876), and a ternary that knew
|
|
100
|
+
// only `permanent` reported an expunge as "moved to Trash".
|
|
101
|
+
for (const outcome of expunging) {
|
|
102
|
+
it(`names a ${outcome} delete as an expunge, however the run ended`, () => {
|
|
103
|
+
assert.ok(deleteExpunges(outcome), "the copy reads this predicate");
|
|
104
|
+
assert.match(
|
|
105
|
+
runEndingBanner("delete", 100, ended(), outcome)?.title ?? "",
|
|
106
|
+
/^100 permanently deleted\./,
|
|
107
|
+
);
|
|
108
|
+
assert.match(
|
|
109
|
+
runEndingBanner("delete", 3000, ended({ cancelled: true }), outcome)
|
|
110
|
+
?.detail ?? "",
|
|
111
|
+
/100 of 3,000 permanently deleted\./,
|
|
112
|
+
"the half that ran is erased whether or not the rest did",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
107
116
|
});
|
|
108
117
|
|
|
109
118
|
describe("the run the wizard leaves behind", () => {
|
|
@@ -326,8 +326,8 @@ export const MessageList = ({
|
|
|
326
326
|
);
|
|
327
327
|
const {
|
|
328
328
|
outcome: deleteOutcome,
|
|
329
|
-
trashIsUnconfirmed,
|
|
330
329
|
staleFolderLabel,
|
|
330
|
+
guessedMailboxId,
|
|
331
331
|
} = useDeleteOutcome(deleteScope);
|
|
332
332
|
|
|
333
333
|
// Selection state
|
|
@@ -1352,7 +1352,7 @@ export const MessageList = ({
|
|
|
1352
1352
|
// mailbox is the Trash the copy has to name.
|
|
1353
1353
|
trashFolderLabel={listTitle}
|
|
1354
1354
|
staleFolderLabel={staleFolderLabel}
|
|
1355
|
-
|
|
1355
|
+
guessedMailboxId={guessedMailboxId}
|
|
1356
1356
|
isDeleting={isDeleting}
|
|
1357
1357
|
onConfirm={handleConfirmDelete}
|
|
1358
1358
|
onCancel={handleCancelDelete}
|
|
@@ -212,6 +212,7 @@ export const RoleAppointmentPromptProvider = ({
|
|
|
212
212
|
folders={folders}
|
|
213
213
|
delimiter={folderDelimiter(mailboxes)}
|
|
214
214
|
trashFolderLabel={trashFolderLabel}
|
|
215
|
+
guessedMailboxId={guessedMailboxId}
|
|
215
216
|
staleFolderLabel={
|
|
216
217
|
request.staleFolderLabel ?? trash?.staleAppointmentPath
|
|
217
218
|
}
|
|
@@ -287,8 +287,8 @@ export function ThreadListInteraction({
|
|
|
287
287
|
);
|
|
288
288
|
const {
|
|
289
289
|
outcome: deleteOutcome,
|
|
290
|
-
trashIsUnconfirmed,
|
|
291
290
|
staleFolderLabel,
|
|
291
|
+
guessedMailboxId,
|
|
292
292
|
} = useDeleteOutcome(pendingDelete?.targets ?? NO_TARGETS);
|
|
293
293
|
|
|
294
294
|
// A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
|
|
@@ -482,7 +482,7 @@ export function ThreadListInteraction({
|
|
|
482
482
|
outcome={deleteOutcome}
|
|
483
483
|
accountId={pendingDelete?.targets[0]?.accountId}
|
|
484
484
|
staleFolderLabel={staleFolderLabel}
|
|
485
|
-
|
|
485
|
+
guessedMailboxId={guessedMailboxId}
|
|
486
486
|
isDeleting={isDeleting}
|
|
487
487
|
onConfirm={confirmDelete}
|
|
488
488
|
onCancel={cancelDelete}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A select-all delete refused for its folder role is answered by the
|
|
3
|
+
* appointment prompt, not by a banner (#876).
|
|
4
|
+
*
|
|
5
|
+
* The escalated path had no branch for the coded 409, so the refusal arrived as
|
|
6
|
+
* the API's own sentence — "Appoint one under Settings › Folder roles" — under a
|
|
7
|
+
* "Couldn't delete these messages" banner. That asks a user who has just
|
|
8
|
+
* selected three thousand messages to go to another screen and rebuild the
|
|
9
|
+
* selection, which is the outcome the prompt exists to remove; the single-row
|
|
10
|
+
* path has answered it in place since #887.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { afterEach, describe, it } from "node:test";
|
|
15
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
16
|
+
import { act, createElement } from "react";
|
|
17
|
+
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
18
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
19
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
20
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
21
|
+
import {
|
|
22
|
+
type UseEscalatedActionsResult,
|
|
23
|
+
useEscalatedActions,
|
|
24
|
+
} from "./useEscalatedActions";
|
|
25
|
+
|
|
26
|
+
const INBOX = "mbx-inbox";
|
|
27
|
+
const ACCOUNT = "acc-1";
|
|
28
|
+
const TRASH = "mbx-trash";
|
|
29
|
+
|
|
30
|
+
let harness: DomHarness | undefined;
|
|
31
|
+
let http: HttpMock | undefined;
|
|
32
|
+
let hook: UseEscalatedActionsResult | undefined;
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
harness?.close();
|
|
36
|
+
harness = undefined;
|
|
37
|
+
http?.restore();
|
|
38
|
+
http = undefined;
|
|
39
|
+
hook = undefined;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const CONFIG = {
|
|
43
|
+
accounts: [
|
|
44
|
+
{
|
|
45
|
+
accountId: ACCOUNT,
|
|
46
|
+
email: "you@example.com",
|
|
47
|
+
folderAppointments: [
|
|
48
|
+
{ role: "Trash", mailboxId: TRASH, source: "Proposed" },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Fresh every call — a `Response` body can only be read once. */
|
|
55
|
+
const refusal = (): Response =>
|
|
56
|
+
new Response(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
status: 409,
|
|
59
|
+
message: "Nobody has confirmed which folder is this account's Trash",
|
|
60
|
+
code: "folder_role_unresolved",
|
|
61
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT },
|
|
62
|
+
}),
|
|
63
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const Probe = () => {
|
|
67
|
+
hook = useEscalatedActions({
|
|
68
|
+
mailboxId: INBOX,
|
|
69
|
+
accountId: ACCOUNT,
|
|
70
|
+
enabled: true,
|
|
71
|
+
predicateKey: "npm",
|
|
72
|
+
searchQuery: { query: "npm" },
|
|
73
|
+
});
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const settle = async (): Promise<void> => {
|
|
78
|
+
if (!harness) throw new Error("nothing mounted");
|
|
79
|
+
for (let round = 0; round < 40; round += 1) {
|
|
80
|
+
await harness.flush();
|
|
81
|
+
await harness.wait(0);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const mount = async (respond: (path: string) => unknown): Promise<void> => {
|
|
86
|
+
http = mockFetch((call) => respond(call.path));
|
|
87
|
+
harness = createDomHarness();
|
|
88
|
+
await harness.renderAsync(
|
|
89
|
+
createElement(
|
|
90
|
+
QueryClientProvider,
|
|
91
|
+
{ client: harness.queryClient as never },
|
|
92
|
+
createElement(
|
|
93
|
+
ErrorBannerProvider,
|
|
94
|
+
null,
|
|
95
|
+
createElement(
|
|
96
|
+
RoleAppointmentPromptProvider,
|
|
97
|
+
null,
|
|
98
|
+
createElement(Probe),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
await settle();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const runDelete = async (): Promise<void> => {
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await hook?.runAction({ kind: "delete" }, [
|
|
109
|
+
{ id: "msg-1", accountId: ACCOUNT },
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
await settle();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
describe("a bulk delete refused for its folder role", () => {
|
|
116
|
+
it("opens the appointment prompt instead of bannering the API's sentence", async () => {
|
|
117
|
+
await mount((path) => {
|
|
118
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
119
|
+
if (path.endsWith("/messages/delete")) return refusal();
|
|
120
|
+
return {};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await runDelete();
|
|
124
|
+
|
|
125
|
+
const text = harness?.text() ?? "";
|
|
126
|
+
assert.match(text, /Confirm this account's Trash folder/);
|
|
127
|
+
assert.doesNotMatch(text, /Couldn't delete these messages/);
|
|
128
|
+
assert.doesNotMatch(
|
|
129
|
+
text,
|
|
130
|
+
/Settings › Folder roles/,
|
|
131
|
+
"the raw API sentence is what the prompt replaces",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps today's banner for a 409 that is somebody else's", async () => {
|
|
136
|
+
await mount((path) => {
|
|
137
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
138
|
+
if (path.endsWith("/messages/delete"))
|
|
139
|
+
return new Response(
|
|
140
|
+
JSON.stringify({
|
|
141
|
+
status: 409,
|
|
142
|
+
message: "boom",
|
|
143
|
+
code: "mailbox_not_settled",
|
|
144
|
+
}),
|
|
145
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
146
|
+
);
|
|
147
|
+
return {};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await runDelete();
|
|
151
|
+
|
|
152
|
+
const text = harness?.text() ?? "";
|
|
153
|
+
assert.match(text, /Couldn't delete these messages/);
|
|
154
|
+
assert.doesNotMatch(text, /Confirm this account's Trash folder/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* from several mailboxes and several accounts at once.
|
|
12
12
|
*
|
|
13
13
|
* The decision itself is `deleteOutcomeFor`, kept pure in `lib/format`; this is
|
|
14
|
-
* only the read that feeds it. The two facts beside it are
|
|
15
|
-
* to name
|
|
16
|
-
*
|
|
14
|
+
* only the read that feeds it. The two facts beside it are the folders the
|
|
15
|
+
* refusal has to name: the one a stale appointment lost, and the one reader
|
|
16
|
+
* guessed and is asking the user to confirm.
|
|
17
17
|
*/
|
|
18
18
|
import { useMemo } from "react";
|
|
19
19
|
import {
|
|
@@ -25,10 +25,14 @@ import { useTrashByAccount } from "./useArchiveMailbox";
|
|
|
25
25
|
|
|
26
26
|
export interface DeleteOutcomeResult {
|
|
27
27
|
outcome: DeleteOutcome;
|
|
28
|
-
/** The Trash these rows resolve to was matched by name, never confirmed. */
|
|
29
|
-
trashIsUnconfirmed: boolean;
|
|
30
28
|
/** The folder the user appointed, when it is gone from the mail server. */
|
|
31
29
|
staleFolderLabel?: string;
|
|
30
|
+
/**
|
|
31
|
+
* `unconfirmed`: the folder reader matched by name, which the rows are
|
|
32
|
+
* already inside. The prompt opens with it chosen, so confirming the guess is
|
|
33
|
+
* one tap.
|
|
34
|
+
*/
|
|
35
|
+
guessedMailboxId?: string;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** The outcome of deleting `targets`. */
|
|
@@ -48,18 +52,19 @@ export const useDeleteOutcome = (
|
|
|
48
52
|
target.accountId ? trashByAccount.get(target.accountId) : undefined;
|
|
49
53
|
return {
|
|
50
54
|
outcome,
|
|
51
|
-
// Only the rows the delete would actually expunge — a row filed
|
|
52
|
-
// somewhere else says nothing about the folder it is moving into.
|
|
53
|
-
trashIsUnconfirmed: targets.some((target) => {
|
|
54
|
-
const trash = trashFor(target);
|
|
55
|
-
return (
|
|
56
|
-
trash?.source === "Proposed" && trash.mailboxId === target.mailboxId
|
|
57
|
-
);
|
|
58
|
-
}),
|
|
59
55
|
// The account `deleteOutcomeFor` refused on, found the way it found it.
|
|
60
56
|
staleFolderLabel: targets
|
|
61
57
|
.map(trashFor)
|
|
62
58
|
.find((trash) => trash?.source === "Stale")?.staleFolderPath,
|
|
59
|
+
// The folder that produced `unconfirmed`: a row's own mailbox, which is
|
|
60
|
+
// also its account's guessed Trash. Only that row says which folder is
|
|
61
|
+
// being asked about — a row filed elsewhere is moving, not expunging.
|
|
62
|
+
guessedMailboxId:
|
|
63
|
+
outcome === "unconfirmed"
|
|
64
|
+
? targets.find(
|
|
65
|
+
(target) => trashFor(target)?.mailboxId === target.mailboxId,
|
|
66
|
+
)?.mailboxId
|
|
67
|
+
: undefined,
|
|
63
68
|
};
|
|
64
69
|
}, [targets, trashByAccount, hasAppointments, isError]);
|
|
65
70
|
};
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
9
9
|
import { useQueryClient } from "@tanstack/react-query";
|
|
10
10
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
+
import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
11
12
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
12
13
|
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
14
|
+
import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
|
|
13
15
|
import {
|
|
14
16
|
bulkActionFailureDetail,
|
|
15
17
|
bulkActionFailureTitle,
|
|
@@ -152,6 +154,14 @@ export const useEscalatedActions = ({
|
|
|
152
154
|
const runningRef = useRef(false);
|
|
153
155
|
const queryClient = useQueryClient();
|
|
154
156
|
const { pushError } = useErrorBanners();
|
|
157
|
+
const { requestAppointment } = useRoleAppointmentPrompt();
|
|
158
|
+
// The run replays itself once a folder is appointed, so it needs a handle on
|
|
159
|
+
// itself that does not make `runAction` its own dependency.
|
|
160
|
+
const runRef = useRef<UseEscalatedActionsResult["runAction"]>(async () => ({
|
|
161
|
+
done: 0,
|
|
162
|
+
failedIds: [],
|
|
163
|
+
cancelled: false,
|
|
164
|
+
}));
|
|
155
165
|
|
|
156
166
|
// A different search (or leaving search/desktop) makes any in-flight
|
|
157
167
|
// escalation meaningless — it would otherwise keep counting or offering to
|
|
@@ -298,6 +308,10 @@ export const useEscalatedActions = ({
|
|
|
298
308
|
cancelRef.current = false;
|
|
299
309
|
runningRef.current = true;
|
|
300
310
|
setRunningAction(action);
|
|
311
|
+
// Read before the run clears the phase below, so a refusal can say how
|
|
312
|
+
// many messages the appointment's replay is about.
|
|
313
|
+
const matched =
|
|
314
|
+
targets?.length ?? (phase.kind === "escalated" ? phase.total : 0);
|
|
301
315
|
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
302
316
|
// predicate can match more by the time the run pages it than the count
|
|
303
317
|
// saw, and the bar must never show more done than out of.
|
|
@@ -337,7 +351,25 @@ export const useEscalatedActions = ({
|
|
|
337
351
|
setProgress(undefined);
|
|
338
352
|
setPhase({ kind: "idle" });
|
|
339
353
|
|
|
340
|
-
|
|
354
|
+
// A provenance refusal is answered by the appointment prompt here exactly
|
|
355
|
+
// as it is on the single-row path (#887, #876). Left to the banner it
|
|
356
|
+
// arrives as the API's own sentence — "Appoint one under Settings ›
|
|
357
|
+
// Folder roles" — which asks the user to reassemble a select-all they
|
|
358
|
+
// have already made.
|
|
359
|
+
const refusal = outcome.error
|
|
360
|
+
? isFolderRoleRefusal(outcome.error)
|
|
361
|
+
: undefined;
|
|
362
|
+
if (refusal) {
|
|
363
|
+
requestAppointment({
|
|
364
|
+
accountId: refusal.accountId,
|
|
365
|
+
role: refusal.role,
|
|
366
|
+
reason: refusal.reason,
|
|
367
|
+
action: { kind: "delete", count: matched },
|
|
368
|
+
onAppointed: async () => {
|
|
369
|
+
await runRef.current(action, targets);
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
} else if (outcome.error) {
|
|
341
373
|
pushError(
|
|
342
374
|
buildMutationErrorBanner(
|
|
343
375
|
bulkActionFailureTitle(action.kind, outcome.done),
|
|
@@ -351,8 +383,16 @@ export const useEscalatedActions = ({
|
|
|
351
383
|
}
|
|
352
384
|
return outcome;
|
|
353
385
|
},
|
|
354
|
-
[
|
|
386
|
+
[
|
|
387
|
+
applyBatchFor,
|
|
388
|
+
fetchPagesOf,
|
|
389
|
+
phase,
|
|
390
|
+
pushError,
|
|
391
|
+
invalidateAfterRun,
|
|
392
|
+
requestAppointment,
|
|
393
|
+
],
|
|
355
394
|
);
|
|
395
|
+
runRef.current = runAction;
|
|
356
396
|
|
|
357
397
|
return {
|
|
358
398
|
phase,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ErrorBannerSeverity } from "@/components/ui/error-banners";
|
|
2
2
|
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
3
|
-
import { type DeleteOutcome, formatNumber } from "@/lib/format";
|
|
3
|
+
import { type DeleteOutcome, deleteExpunges, formatNumber } from "@/lib/format";
|
|
4
4
|
|
|
5
5
|
/** A run ending, as the list banners it. */
|
|
6
6
|
export interface RunEndingBanner {
|
|
@@ -38,7 +38,7 @@ const pastTense: Record<BulkActionKind, string> = {
|
|
|
38
38
|
* it does for one that finished — the half that ran is still erased.
|
|
39
39
|
*/
|
|
40
40
|
const pastTenseFor = (kind: BulkActionKind, outcome: DeleteOutcome): string =>
|
|
41
|
-
kind === "delete" && outcome
|
|
41
|
+
kind === "delete" && deleteExpunges(outcome)
|
|
42
42
|
? "permanently deleted"
|
|
43
43
|
: pastTense[kind];
|
|
44
44
|
|
package/src/lib/format.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
+
import { trashSourceMeetsAssurance } from "@remit/data-ports/folder-role";
|
|
4
|
+
import { FolderAppointmentSource } from "@remit/domain-enums";
|
|
3
5
|
import type { DeleteTarget, TrashResolution } from "./format.js";
|
|
4
6
|
import {
|
|
5
7
|
deleteConfirmationCopy,
|
|
@@ -338,33 +340,55 @@ describe("deleteOutcomeFor", () => {
|
|
|
338
340
|
targets: [target("mbx-inbox", "acct-1")],
|
|
339
341
|
}),
|
|
340
342
|
"trash",
|
|
341
|
-
"
|
|
343
|
+
"filing mail into a name guess is reversible; only an expunge needs more",
|
|
342
344
|
);
|
|
343
345
|
});
|
|
344
346
|
|
|
345
|
-
test("
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
"
|
|
365
|
-
|
|
366
|
-
)
|
|
367
|
-
}
|
|
347
|
+
test("a row already inside a Trash resolved only by name refuses as unconfirmed (#876)", () => {
|
|
348
|
+
assert.strictEqual(
|
|
349
|
+
deleteOutcomeFor({
|
|
350
|
+
...settled,
|
|
351
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
352
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Proposed" }],
|
|
353
|
+
]),
|
|
354
|
+
targets: [target("mbx-trash", "acct-1")],
|
|
355
|
+
}),
|
|
356
|
+
"unconfirmed",
|
|
357
|
+
"deleting a row already in the name-guessed folder expunges it — the same act Empty Trash refuses on this evidence",
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("one row expunging inside an unconfirmed Trash outranks a plain expunge", () => {
|
|
362
|
+
assert.strictEqual(
|
|
363
|
+
deleteOutcomeFor({
|
|
364
|
+
...settled,
|
|
365
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
366
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Proposed" }],
|
|
367
|
+
]),
|
|
368
|
+
targets: [target("mbx-inbox", "acct-1"), target("mbx-trash", "acct-1")],
|
|
369
|
+
}),
|
|
370
|
+
"unconfirmed",
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("words an expunge from the same evidence the service gates it on", () => {
|
|
375
|
+
// Derived from the shared gate rather than listed here: a hand-written
|
|
376
|
+
// list is how the dialog came to offer a confirm the server 409s on.
|
|
377
|
+
// `Stale` is excluded because it refuses under its own outcome first.
|
|
378
|
+
for (const source of Object.values(FolderAppointmentSource)) {
|
|
379
|
+
if (source === FolderAppointmentSource.Stale) continue;
|
|
380
|
+
const outcome = deleteOutcomeFor({
|
|
381
|
+
...settled,
|
|
382
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
383
|
+
["acct-1", { mailboxId: "mbx-trash", source }],
|
|
384
|
+
]),
|
|
385
|
+
targets: [target("mbx-trash", "acct-1")],
|
|
386
|
+
});
|
|
387
|
+
assert.strictEqual(
|
|
388
|
+
outcome === "unconfirmed",
|
|
389
|
+
!trashSourceMeetsAssurance(source, "confirmed"),
|
|
390
|
+
`${source} must ask for what the service demands before an expunge`,
|
|
391
|
+
);
|
|
368
392
|
}
|
|
369
393
|
});
|
|
370
394
|
});
|
|
@@ -438,24 +462,9 @@ describe("deleteConfirmationCopy — the refusal", () => {
|
|
|
438
462
|
});
|
|
439
463
|
});
|
|
440
464
|
|
|
441
|
-
test("names the folder before an expunge inside a Trash nobody confirmed", () => {
|
|
442
|
-
const copy = deleteConfirmationCopy(2, "permanent", {
|
|
443
|
-
trashFolderLabel: "Deleted Messages",
|
|
444
|
-
trashIsUnconfirmed: true,
|
|
445
|
-
});
|
|
446
|
-
assert.strictEqual(copy.title, "Permanently delete 2 messages?");
|
|
447
|
-
assert.strictEqual(
|
|
448
|
-
copy.description,
|
|
449
|
-
"They are in Deleted Messages, which reader treats as this account's Trash because of its name — nobody confirmed it. They are erased from the mail server and cannot be restored.",
|
|
450
|
-
);
|
|
451
|
-
assert.strictEqual(copy.confirmLabel, "Delete permanently");
|
|
452
|
-
});
|
|
453
|
-
|
|
454
465
|
test("drops the name clause when the caller holds no folder name", () => {
|
|
455
|
-
const copy = deleteConfirmationCopy(
|
|
456
|
-
|
|
457
|
-
});
|
|
458
|
-
assert.match(copy.description, /because of its name — nobody confirmed it/);
|
|
466
|
+
const copy = deleteConfirmationCopy(3, "unconfirmed");
|
|
467
|
+
assert.match(copy.description, /matched by name — nobody confirmed it/);
|
|
459
468
|
assert.ok(!copy.description.includes("undefined"));
|
|
460
469
|
});
|
|
461
470
|
});
|
package/src/lib/format.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FolderAppointmentSourceValue,
|
|
3
|
+
trashSourceMeetsAssurance,
|
|
4
|
+
} from "@remit/data-ports/folder-role";
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* Get the user's preferred locale from browser settings.
|
|
3
8
|
* Falls back to 'en-US' if unavailable.
|
|
@@ -178,8 +183,6 @@ export interface DeleteConfirmationContext {
|
|
|
178
183
|
trashFolderLabel?: string;
|
|
179
184
|
/** The folder the user appointed, now gone from the mail server. */
|
|
180
185
|
staleFolderLabel?: string;
|
|
181
|
-
/** That folder is a name match nobody ever confirmed. */
|
|
182
|
-
trashIsUnconfirmed?: boolean;
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
/**
|
|
@@ -207,10 +210,11 @@ export interface DeleteConfirmationContext {
|
|
|
207
210
|
* single `noTrash` member cannot — this function only ever sees the member.
|
|
208
211
|
*
|
|
209
212
|
* `unconfirmed` is a folder reader matched by name that nobody ever confirmed.
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* folder
|
|
213
|
-
*
|
|
213
|
+
* Empty Trash refuses on it for the whole folder (D4); `deleteOutcomeFor`
|
|
214
|
+
* produces it too, for the narrower case of a row that is already inside that
|
|
215
|
+
* folder — deleting it expunges on the same name guess, and the guess is never
|
|
216
|
+
* enough to destroy mail (#876). Both surfaces derive it from the server's own
|
|
217
|
+
* 409, and the branch lives here so they word it identically.
|
|
214
218
|
*/
|
|
215
219
|
export type DeleteOutcome =
|
|
216
220
|
| "trash"
|
|
@@ -222,13 +226,7 @@ export type DeleteOutcome =
|
|
|
222
226
|
| "unavailable";
|
|
223
227
|
|
|
224
228
|
/** Where an account's Trash answer came from (`FolderAppointmentSource`). */
|
|
225
|
-
export type TrashSource =
|
|
226
|
-
| "Appointed"
|
|
227
|
-
| "Flagged"
|
|
228
|
-
| "Reserved"
|
|
229
|
-
| "Proposed"
|
|
230
|
-
| "Stale"
|
|
231
|
-
| "None";
|
|
229
|
+
export type TrashSource = FolderAppointmentSourceValue;
|
|
232
230
|
|
|
233
231
|
/** One account's Trash, as `/config` resolved it. */
|
|
234
232
|
export interface TrashResolution {
|
|
@@ -268,8 +266,10 @@ export interface DeleteOutcomeInput {
|
|
|
268
266
|
* One row bound for an expunge makes the whole delete unrecoverable, so a mixed
|
|
269
267
|
* set is permanent: the wording may overstate what is destroyed, never what is
|
|
270
268
|
* kept. One row on an account with no Trash refuses the whole call, so that
|
|
271
|
-
* outranks both.
|
|
272
|
-
*
|
|
269
|
+
* outranks both. An expunge on a Trash nobody confirmed outranks a plain
|
|
270
|
+
* expunge in turn — the server refuses it (#876), so the dialog asks for the
|
|
271
|
+
* same confirmation up front rather than offering a confirm that 409s. Pure,
|
|
272
|
+
* so every branch — the failure ones above all — is testable without a DOM.
|
|
273
273
|
*/
|
|
274
274
|
export const deleteOutcomeFor = ({
|
|
275
275
|
targets,
|
|
@@ -282,17 +282,33 @@ export const deleteOutcomeFor = ({
|
|
|
282
282
|
if (targets.length === 0) return "unknown";
|
|
283
283
|
|
|
284
284
|
let expunges = false;
|
|
285
|
+
let expungesUnconfirmed = false;
|
|
285
286
|
for (const target of targets) {
|
|
286
287
|
if (target.accountId === undefined) return "unknown";
|
|
287
288
|
const trash = trashByAccount.get(target.accountId);
|
|
288
289
|
if (trash === undefined) return "unknown";
|
|
289
290
|
if (trash.source === "Stale") return "staleTrash";
|
|
290
291
|
if (trash.mailboxId === undefined) return "noTrash";
|
|
291
|
-
if (trash.mailboxId === target.mailboxId)
|
|
292
|
+
if (trash.mailboxId === target.mailboxId) {
|
|
293
|
+
expunges = true;
|
|
294
|
+
if (!trashSourceMeetsAssurance(trash.source, "confirmed"))
|
|
295
|
+
expungesUnconfirmed = true;
|
|
296
|
+
}
|
|
292
297
|
}
|
|
298
|
+
if (expungesUnconfirmed) return "unconfirmed";
|
|
293
299
|
return expunges ? "permanent" : "trash";
|
|
294
300
|
};
|
|
295
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The delete erases mail rather than filing it. Both outcomes a row already
|
|
304
|
+
* inside Trash produces say so — `unconfirmed` is only ever reached that way
|
|
305
|
+
* (#876) — and anything that reports what a delete did reads this rather than
|
|
306
|
+
* testing for `permanent`, which is how a run of expunged mail came to be
|
|
307
|
+
* announced as "moved to Trash".
|
|
308
|
+
*/
|
|
309
|
+
export const deleteExpunges = (outcome: DeleteOutcome): boolean =>
|
|
310
|
+
outcome === "permanent" || outcome === "unconfirmed";
|
|
311
|
+
|
|
296
312
|
/**
|
|
297
313
|
* The confirmation for a delete, worded for what the delete actually does.
|
|
298
314
|
* Deleting mail that already sits in Trash expunges it on the server and
|
|
@@ -315,7 +331,7 @@ export const deleteConfirmationCopy = (
|
|
|
315
331
|
): DeleteConfirmationCopy => {
|
|
316
332
|
const quantity = count === 1 ? "1" : formatNumber(count);
|
|
317
333
|
const noun = count === 1 ? "message" : "messages";
|
|
318
|
-
const { trashFolderLabel, staleFolderLabel
|
|
334
|
+
const { trashFolderLabel, staleFolderLabel } = context;
|
|
319
335
|
|
|
320
336
|
if (outcome === "noTrash") {
|
|
321
337
|
return {
|
|
@@ -335,11 +351,18 @@ export const deleteConfirmationCopy = (
|
|
|
335
351
|
};
|
|
336
352
|
}
|
|
337
353
|
if (outcome === "unconfirmed") {
|
|
354
|
+
const folderClause = trashFolderLabel
|
|
355
|
+
? `reader files this account's deleted mail in ${trashFolderLabel} because of its name — nobody confirmed it.`
|
|
356
|
+
: "reader files this account's deleted mail in a folder it matched by name — nobody confirmed it.";
|
|
357
|
+
// count === 0: Empty Trash, acting on everything the folder holds. A
|
|
358
|
+
// positive count: a delete whose rows already sit in that folder, so
|
|
359
|
+
// this expunges them specifically, not the folder's whole contents.
|
|
338
360
|
return {
|
|
339
361
|
title: "Confirm this account's Trash folder",
|
|
340
|
-
description:
|
|
341
|
-
|
|
342
|
-
|
|
362
|
+
description:
|
|
363
|
+
count > 0
|
|
364
|
+
? `${folderClause} Deleting ${quantity} ${noun} there erases them from the mail server, and that cannot be restored. Nothing has been deleted.`
|
|
365
|
+
: `${folderClause} Emptying a folder erases everything in it from the mail server, and that cannot be restored. Nothing has been emptied.`,
|
|
343
366
|
confirmLabel: "Confirm the folder",
|
|
344
367
|
};
|
|
345
368
|
}
|
|
@@ -359,18 +382,6 @@ export const deleteConfirmationCopy = (
|
|
|
359
382
|
};
|
|
360
383
|
}
|
|
361
384
|
if (outcome === "permanent") {
|
|
362
|
-
// D4a: the expunge still goes through on a Trash nobody confirmed — the
|
|
363
|
-
// user asked for these specific rows — but they are told which folder
|
|
364
|
-
// reader treats as Trash, and that nobody chose it, before it happens.
|
|
365
|
-
if (trashIsUnconfirmed) {
|
|
366
|
-
return {
|
|
367
|
-
title: `Permanently delete ${quantity} ${noun}?`,
|
|
368
|
-
description: trashFolderLabel
|
|
369
|
-
? `They are in ${trashFolderLabel}, which reader treats as this account's Trash because of its name — nobody confirmed it. They are erased from the mail server and cannot be restored.`
|
|
370
|
-
: "They are in a folder reader treats as this account's Trash because of its name — nobody confirmed it. They are erased from the mail server and cannot be restored.",
|
|
371
|
-
confirmLabel: "Delete permanently",
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
385
|
return {
|
|
375
386
|
title: `Permanently delete ${quantity} ${noun}?`,
|
|
376
387
|
description:
|