@remit/web-client 0.0.185 → 0.0.186
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/DailyBrief.tsx +1 -0
- package/src/components/mail/DeleteConfirmDialog.render.test.ts +214 -0
- package/src/components/mail/DeleteConfirmDialog.tsx +95 -0
- package/src/components/mail/FlaggedList.tsx +1 -0
- package/src/components/mail/MessageList.run-reporting.test.ts +76 -14
- package/src/components/mail/MessageList.selection.test.ts +1 -1
- package/src/components/mail/MessageList.tsx +24 -37
- package/src/components/mail/ThreadListInteraction.test.ts +200 -20
- package/src/components/mail/ThreadListInteraction.tsx +67 -16
- package/src/hooks/useArchiveMailbox.ts +38 -8
- package/src/hooks/useDeleteOutcome.ts +35 -0
- package/src/lib/bulk-action-copy.ts +69 -3
- package/src/lib/format.test.ts +182 -0
- package/src/lib/format.ts +101 -5
- package/src/test-support/fixtures.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.186",
|
|
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": {
|
|
@@ -888,6 +888,7 @@ export function DailyBrief({
|
|
|
888
888
|
<FilterPanelProvider hasSheet={showsRows && !resultsPanelOwnsBody}>
|
|
889
889
|
<ThreadListInteraction
|
|
890
890
|
selectedMessageId={selectedMessageId}
|
|
891
|
+
rows={filteredRows}
|
|
891
892
|
onOpen={openRow}
|
|
892
893
|
onDeleteMessages={onDeleteMessages}
|
|
893
894
|
onSelectionVerb={wizard.start}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeleteConfirmDialog — the dialog says what the delete will do, and when it
|
|
3
|
+
* cannot say, it refuses (#845, #855).
|
|
4
|
+
*
|
|
5
|
+
* The outcome arrives as a prop, so every branch is reachable here without a
|
|
6
|
+
* query in the way — including the one that matters most, a `/config` read that
|
|
7
|
+
* failed. `deleteOutcomeFor` (see `lib/format.test.ts`) pins which outcome each
|
|
8
|
+
* read state produces; this pins what each outcome puts on screen.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
12
|
+
import type { JSDOM } from "jsdom";
|
|
13
|
+
import { act, createElement, Fragment } from "react";
|
|
14
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
15
|
+
import {
|
|
16
|
+
type AuthProvider,
|
|
17
|
+
AuthProviderProvider,
|
|
18
|
+
noneAuthProvider,
|
|
19
|
+
} from "@/auth/provider";
|
|
20
|
+
import type { DeleteOutcome } from "@/lib/format";
|
|
21
|
+
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
|
22
|
+
|
|
23
|
+
let dom: JSDOM;
|
|
24
|
+
let container: HTMLElement;
|
|
25
|
+
let root: Root;
|
|
26
|
+
|
|
27
|
+
before(async () => {
|
|
28
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
29
|
+
dom = new JSDOMCtor(
|
|
30
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
31
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
32
|
+
);
|
|
33
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
34
|
+
globalThis.document = dom.window.document;
|
|
35
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
36
|
+
globalThis.Element = dom.window.Element;
|
|
37
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
38
|
+
value: dom.window.navigator,
|
|
39
|
+
configurable: true,
|
|
40
|
+
});
|
|
41
|
+
(
|
|
42
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
43
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
after(() => dom.window.close());
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
container = dom.window.document.getElementById(
|
|
50
|
+
"root",
|
|
51
|
+
) as unknown as HTMLElement;
|
|
52
|
+
container.innerHTML = "";
|
|
53
|
+
root = createRoot(container);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
act(() => root.unmount());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** A deployment with an identity system and a live session to sign back into. */
|
|
61
|
+
const sessionAuthProvider = (signOut: () => void): AuthProvider => ({
|
|
62
|
+
...noneAuthProvider,
|
|
63
|
+
Account: ({ children }) =>
|
|
64
|
+
createElement(
|
|
65
|
+
Fragment,
|
|
66
|
+
null,
|
|
67
|
+
children({ email: "reader@example.com", signOut }),
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const mount = (options: {
|
|
72
|
+
outcome: DeleteOutcome;
|
|
73
|
+
count?: number;
|
|
74
|
+
isDeleting?: boolean;
|
|
75
|
+
authProvider?: AuthProvider;
|
|
76
|
+
onConfirm?: () => void;
|
|
77
|
+
}) => {
|
|
78
|
+
const onConfirm = options.onConfirm ?? (() => undefined);
|
|
79
|
+
act(() =>
|
|
80
|
+
root.render(
|
|
81
|
+
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
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
text: () => dom.window.document.body.textContent ?? "",
|
|
97
|
+
button: (label: string) =>
|
|
98
|
+
Array.from(
|
|
99
|
+
dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
|
|
100
|
+
).find((b) => b.textContent === label),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
describe("DeleteConfirmDialog — the wording follows the consequence", () => {
|
|
105
|
+
it("offers the reversible move outside Trash", () => {
|
|
106
|
+
const view = mount({ outcome: "trash" });
|
|
107
|
+
assert.match(view.text(), /Move 1 message to Trash\?/);
|
|
108
|
+
assert.equal(view.button("Move to Trash")?.disabled, false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("asks about destruction inside Trash", () => {
|
|
112
|
+
const view = mount({ outcome: "permanent", count: 3 });
|
|
113
|
+
assert.match(view.text(), /Permanently delete 3 messages\?/);
|
|
114
|
+
assert.match(view.text(), /cannot be restored/);
|
|
115
|
+
assert.equal(view.button("Delete permanently")?.disabled, false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("holds the confirm while the appointment is still arriving", () => {
|
|
119
|
+
const view = mount({ outcome: "unknown" });
|
|
120
|
+
assert.match(view.text(), /Checking where this account files deleted mail/);
|
|
121
|
+
assert.equal(
|
|
122
|
+
view.button("Delete")?.disabled,
|
|
123
|
+
true,
|
|
124
|
+
"the answer decides which of two dialogs this is",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("holds the confirm while a delete is already in flight", () => {
|
|
129
|
+
const view = mount({ outcome: "trash", isDeleting: true });
|
|
130
|
+
assert.equal(view.button("Move to Trash")?.disabled, true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The blocking case. TanStack leaves a failed read as `status: "error"` with no
|
|
136
|
+
* data, and an empty Trash set read as "this folder is not Trash" — so an
|
|
137
|
+
* expired session was shown "Move to Trash?" over an expunge. A read that could
|
|
138
|
+
* not answer never renders as an answer.
|
|
139
|
+
*/
|
|
140
|
+
describe("DeleteConfirmDialog — a read that failed refuses the delete", () => {
|
|
141
|
+
it("states what failed instead of promising a move", () => {
|
|
142
|
+
const view = mount({ outcome: "unavailable" });
|
|
143
|
+
const text = view.text();
|
|
144
|
+
assert.match(text, /Can't delete 1 message/);
|
|
145
|
+
assert.match(text, /Nothing has been deleted/);
|
|
146
|
+
assert.doesNotMatch(text, /Move 1 message to Trash\?/);
|
|
147
|
+
assert.doesNotMatch(text, /restore them from Trash later/);
|
|
148
|
+
assert.doesNotMatch(
|
|
149
|
+
text,
|
|
150
|
+
/Checking where this account files deleted mail/,
|
|
151
|
+
"no read is in flight, so nothing may claim one is",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("re-authenticates from the affirmative control, and never deletes", () => {
|
|
156
|
+
let signedOut = 0;
|
|
157
|
+
let confirmed = 0;
|
|
158
|
+
const view = mount({
|
|
159
|
+
outcome: "unavailable",
|
|
160
|
+
authProvider: sessionAuthProvider(() => {
|
|
161
|
+
signedOut += 1;
|
|
162
|
+
}),
|
|
163
|
+
onConfirm: () => {
|
|
164
|
+
confirmed += 1;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const signIn = view.button("Sign in again");
|
|
169
|
+
assert.ok(signIn, "the way back in is on screen");
|
|
170
|
+
assert.equal(signIn?.disabled, false, "and it can be pressed");
|
|
171
|
+
|
|
172
|
+
act(() => signIn?.click());
|
|
173
|
+
assert.equal(signedOut, 1);
|
|
174
|
+
assert.equal(confirmed, 0, "the refusal never reaches the delete");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("offers a reload where there is no session to sign back into", () => {
|
|
178
|
+
const view = mount({ outcome: "unavailable" });
|
|
179
|
+
assert.equal(view.button("Sign in again"), undefined);
|
|
180
|
+
assert.equal(view.button("Reload reader")?.disabled, false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
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.
|
|
189
|
+
*/
|
|
190
|
+
describe("DeleteConfirmDialog — no Trash appointed is not a move", () => {
|
|
191
|
+
it("refuses rather than promising a restore", () => {
|
|
192
|
+
const view = mount({ outcome: "noTrash", count: 2 });
|
|
193
|
+
const text = view.text();
|
|
194
|
+
assert.match(text, /Can.t delete 2 messages/);
|
|
195
|
+
assert.match(text, /appointed as Trash/);
|
|
196
|
+
assert.doesNotMatch(text, /Move 2 messages to Trash?/);
|
|
197
|
+
assert.doesNotMatch(text, /restore them from Trash later/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("names the screen that fixes it, and cannot reach the delete", () => {
|
|
201
|
+
let confirmed = 0;
|
|
202
|
+
const view = mount({
|
|
203
|
+
outcome: "noTrash",
|
|
204
|
+
onConfirm: () => {
|
|
205
|
+
confirmed += 1;
|
|
206
|
+
},
|
|
207
|
+
});
|
|
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);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delete confirmation, worded from what the delete will actually do.
|
|
3
|
+
*
|
|
4
|
+
* One component for every list that deletes mail — the mailbox list, the brief
|
|
5
|
+
* and Flagged — so the wording and the refusal cannot drift apart again the way
|
|
6
|
+
* they did between #845 and #855.
|
|
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".
|
|
14
|
+
*/
|
|
15
|
+
import { ConfirmDialog } from "@remit/ui";
|
|
16
|
+
import { useAuthProvider } from "@/auth/provider";
|
|
17
|
+
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";
|
|
21
|
+
|
|
22
|
+
interface DeleteConfirmDialogProps {
|
|
23
|
+
isOpen: boolean;
|
|
24
|
+
/** How many messages the pending delete covers. */
|
|
25
|
+
count: number;
|
|
26
|
+
outcome: DeleteOutcome;
|
|
27
|
+
/** A delete is already in flight, so the confirm cannot be pressed again. */
|
|
28
|
+
isDeleting?: boolean;
|
|
29
|
+
onConfirm: () => void;
|
|
30
|
+
onCancel: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DeleteConfirmDialog = ({
|
|
34
|
+
isOpen,
|
|
35
|
+
count,
|
|
36
|
+
outcome,
|
|
37
|
+
isDeleting = false,
|
|
38
|
+
onConfirm,
|
|
39
|
+
onCancel,
|
|
40
|
+
}: DeleteConfirmDialogProps) => {
|
|
41
|
+
const { Account } = useAuthProvider();
|
|
42
|
+
const copy = deleteConfirmationCopy(count, outcome);
|
|
43
|
+
|
|
44
|
+
if (outcome === "noTrash") {
|
|
45
|
+
return (
|
|
46
|
+
<ConfirmDialog
|
|
47
|
+
isOpen={isOpen}
|
|
48
|
+
{...copy}
|
|
49
|
+
onConfirm={() => {
|
|
50
|
+
window.location.assign(FOLDER_SETTINGS_PATH);
|
|
51
|
+
}}
|
|
52
|
+
onCancel={onCancel}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (outcome === "unavailable") {
|
|
57
|
+
return (
|
|
58
|
+
<Account
|
|
59
|
+
fallback={
|
|
60
|
+
<ConfirmDialog
|
|
61
|
+
isOpen={isOpen}
|
|
62
|
+
{...copy}
|
|
63
|
+
// Nothing to sign back into on a deployment with no identity
|
|
64
|
+
// system, so the way forward is the read itself.
|
|
65
|
+
confirmLabel="Reload reader"
|
|
66
|
+
onConfirm={() => window.location.reload()}
|
|
67
|
+
onCancel={onCancel}
|
|
68
|
+
/>
|
|
69
|
+
}
|
|
70
|
+
>
|
|
71
|
+
{({ signOut }) => (
|
|
72
|
+
<ConfirmDialog
|
|
73
|
+
isOpen={isOpen}
|
|
74
|
+
{...copy}
|
|
75
|
+
onConfirm={() => signOut()}
|
|
76
|
+
onCancel={onCancel}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
</Account>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<ConfirmDialog
|
|
85
|
+
isOpen={isOpen}
|
|
86
|
+
{...copy}
|
|
87
|
+
destructive
|
|
88
|
+
// The confirm holds while the appointment is still arriving: the answer
|
|
89
|
+
// is seconds away and it decides which of two dialogs this is.
|
|
90
|
+
isBusy={isDeleting || outcome === "unknown"}
|
|
91
|
+
onConfirm={onConfirm}
|
|
92
|
+
onCancel={onCancel}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { describe, it } from "node:test";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { runEndingBanner } from "@/lib/bulk-action-copy";
|
|
7
|
+
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* A run states how it ended, to whoever is still there to read it (#521). The
|
|
@@ -10,37 +12,97 @@ import { fileURLToPath } from "node:url";
|
|
|
10
12
|
* the run with no screen of its own — so the list says what it reached, whether
|
|
11
13
|
* that was everything or a hundred out of three thousand.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Which ending gets which banner is `runEndingBanner`, and it is asserted here
|
|
16
|
+
* by its result. It used to be asserted by matching the source text of
|
|
17
|
+
* `reportRunOutcome`, which meant the rule held only as long as nobody moved
|
|
18
|
+
* those lines — adding an argument to the completion call broke all three
|
|
19
|
+
* cases while the behaviour they protect was intact. The one fact that still
|
|
20
|
+
* cannot be read off a result is the wiring: that the wizard is handed
|
|
21
|
+
* somewhere to report an ending after it has closed. That stays a source read,
|
|
22
|
+
* because reaching it otherwise means mounting the list's virtualizer, router
|
|
23
|
+
* and data hooks around a run that has already finished.
|
|
24
|
+
*
|
|
25
|
+
* Which runs reach this seam at all is `SelectionWizardHost.run-exit.test.ts`.
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
28
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
29
|
const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
|
|
22
30
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
|
|
32
|
+
done: 100,
|
|
33
|
+
failedIds: [],
|
|
34
|
+
cancelled: false,
|
|
35
|
+
...over,
|
|
36
|
+
});
|
|
26
37
|
|
|
27
38
|
describe("reporting how a run ended", () => {
|
|
28
39
|
it("hands the wizard somewhere to report an ending it can no longer show", () => {
|
|
29
|
-
assert.ok(reportBody, "the list reports no run outcome");
|
|
30
40
|
const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
|
|
31
41
|
assert.match(host, /onRunEnded=\{reportRunOutcome\}/);
|
|
42
|
+
assert.match(
|
|
43
|
+
source,
|
|
44
|
+
/const reportRunOutcome = useCallback\(/,
|
|
45
|
+
"the list reports no run outcome",
|
|
46
|
+
);
|
|
47
|
+
assert.match(
|
|
48
|
+
source,
|
|
49
|
+
/runEndingBanner\(kind, matched, outcome, deleteOutcome\)/,
|
|
50
|
+
"and the ending it reports is the one runEndingBanner decides",
|
|
51
|
+
);
|
|
32
52
|
});
|
|
33
53
|
|
|
34
54
|
it("names both endings: what it covered, and what it stopped short of", () => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
const stopped = runEndingBanner(
|
|
56
|
+
"delete",
|
|
57
|
+
3000,
|
|
58
|
+
ended({ cancelled: true }),
|
|
59
|
+
"trash",
|
|
39
60
|
);
|
|
61
|
+
assert.equal(stopped?.title, "Stopped after 100");
|
|
62
|
+
assert.match(stopped?.detail ?? "", /100 of 3,000 moved to Trash\./);
|
|
63
|
+
assert.match(stopped?.detail ?? "", /Nothing was sent for the rest/);
|
|
64
|
+
|
|
65
|
+
const covered = runEndingBanner("delete", 100, ended(), "trash");
|
|
66
|
+
assert.match(covered?.title ?? "", /^100 moved to Trash\./);
|
|
40
67
|
});
|
|
41
68
|
|
|
42
69
|
it("raises a stopped run as a warning rather than a passing note", () => {
|
|
43
|
-
assert.
|
|
70
|
+
assert.equal(
|
|
71
|
+
runEndingBanner("delete", 3000, ended({ cancelled: true }), "trash")
|
|
72
|
+
?.severity,
|
|
73
|
+
"warning",
|
|
74
|
+
"mail the user asked to be acted on was left untouched",
|
|
75
|
+
);
|
|
76
|
+
assert.equal(
|
|
77
|
+
runEndingBanner("delete", 100, ended(), "trash")?.severity,
|
|
78
|
+
"info",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("says nothing about a run a thrown batch already bannered", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
runEndingBanner(
|
|
85
|
+
"delete",
|
|
86
|
+
100,
|
|
87
|
+
ended({ error: new Error("boom") }),
|
|
88
|
+
"trash",
|
|
89
|
+
),
|
|
90
|
+
null,
|
|
91
|
+
"saying it twice is the one wrong answer",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("names an expunge as an expunge, however the run ended", () => {
|
|
96
|
+
assert.match(
|
|
97
|
+
runEndingBanner("delete", 100, ended(), "permanent")?.title ?? "",
|
|
98
|
+
/^100 permanently deleted\./,
|
|
99
|
+
);
|
|
100
|
+
assert.match(
|
|
101
|
+
runEndingBanner("delete", 3000, ended({ cancelled: true }), "permanent")
|
|
102
|
+
?.detail ?? "",
|
|
103
|
+
/100 of 3,000 permanently deleted\./,
|
|
104
|
+
"the half that ran is erased whether or not the rest did",
|
|
105
|
+
);
|
|
44
106
|
});
|
|
45
107
|
});
|
|
46
108
|
|
|
@@ -189,7 +189,7 @@ describe("MessageList confirmed delete stays on the list on mobile (#202)", () =
|
|
|
189
189
|
it("raises a completion banner on mobile so the delete is not silent", () => {
|
|
190
190
|
assert.match(
|
|
191
191
|
source,
|
|
192
|
-
/if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length\),?\s*\);\s*\}/,
|
|
192
|
+
/if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length, deleteOutcome\),?\s*\);\s*\}/,
|
|
193
193
|
);
|
|
194
194
|
});
|
|
195
195
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
2
|
import {
|
|
3
3
|
Banner,
|
|
4
|
-
ConfirmDialog,
|
|
5
4
|
cn,
|
|
6
5
|
type Density,
|
|
7
6
|
deriveIsMultiSelectMode,
|
|
@@ -21,7 +20,8 @@ import type { RefObject } from "react";
|
|
|
21
20
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
22
21
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
23
22
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
24
|
-
import { useJunkMailbox
|
|
23
|
+
import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
24
|
+
import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
|
|
25
25
|
import {
|
|
26
26
|
type EscalatedAction,
|
|
27
27
|
type EscalationSearchQuery,
|
|
@@ -37,8 +37,7 @@ import {
|
|
|
37
37
|
bulkActionCompletionText,
|
|
38
38
|
bulkActionProgressLabel,
|
|
39
39
|
bulkActionProgressTone,
|
|
40
|
-
|
|
41
|
-
bulkActionStoppedTitle,
|
|
40
|
+
runEndingBanner,
|
|
42
41
|
} from "@/lib/bulk-action-copy";
|
|
43
42
|
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
44
43
|
import {
|
|
@@ -46,11 +45,7 @@ import {
|
|
|
46
45
|
escalatedStatusLabel,
|
|
47
46
|
escalationActionLabel,
|
|
48
47
|
} from "@/lib/escalation-label";
|
|
49
|
-
import {
|
|
50
|
-
type DeleteOutcome,
|
|
51
|
-
deleteConfirmationCopy,
|
|
52
|
-
formatEmailDate,
|
|
53
|
-
} from "@/lib/format";
|
|
48
|
+
import { formatEmailDate } from "@/lib/format";
|
|
54
49
|
import { junkDestination } from "@/lib/junk-destination";
|
|
55
50
|
import { tabStopId } from "@/lib/list-focus";
|
|
56
51
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
@@ -58,6 +53,7 @@ import { listVerbRequest } from "@/lib/list-verb-request";
|
|
|
58
53
|
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
59
54
|
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
60
55
|
import { useRetainOpenPanels } from "@/routing";
|
|
56
|
+
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
|
61
57
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
62
58
|
import {
|
|
63
59
|
type EscalatedSelection,
|
|
@@ -302,15 +298,13 @@ export const MessageList = ({
|
|
|
302
298
|
|
|
303
299
|
// Deleting inside Trash is an expunge on the mail server, not a move, so the
|
|
304
300
|
// confirmation has to ask that question instead of "move to Trash?" (#845).
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
? "permanent"
|
|
313
|
-
: "trash";
|
|
301
|
+
// Every row here is filed in the open mailbox, so that one folder is the
|
|
302
|
+
// whole set the delete acts on.
|
|
303
|
+
const deleteScope = useMemo(
|
|
304
|
+
() => [{ accountId, mailboxId }],
|
|
305
|
+
[accountId, mailboxId],
|
|
306
|
+
);
|
|
307
|
+
const deleteOutcome = useDeleteOutcome(deleteScope);
|
|
314
308
|
|
|
315
309
|
// Selection state
|
|
316
310
|
const {
|
|
@@ -362,22 +356,12 @@ export const MessageList = ({
|
|
|
362
356
|
// once the user has left it, so an ending is never said twice.
|
|
363
357
|
const reportRunOutcome = useCallback(
|
|
364
358
|
(kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
|
|
359
|
+
const banner = runEndingBanner(kind, matched, outcome, deleteOutcome);
|
|
365
360
|
// A run stopped by a thrown batch is already banner-ed where it threw.
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
pushError({
|
|
369
|
-
severity: "warning",
|
|
370
|
-
title: bulkActionStoppedTitle(outcome.done),
|
|
371
|
-
detail: bulkActionStoppedDetail(kind, outcome.done, matched),
|
|
372
|
-
});
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
pushError({
|
|
376
|
-
severity: "info",
|
|
377
|
-
title: bulkActionCompletionText(kind, outcome.done),
|
|
378
|
-
});
|
|
361
|
+
if (!banner) return;
|
|
362
|
+
pushError(banner);
|
|
379
363
|
},
|
|
380
|
-
[pushError],
|
|
364
|
+
[pushError, deleteOutcome],
|
|
381
365
|
);
|
|
382
366
|
|
|
383
367
|
// The one way selection mode ends (#115): cancel, a completed delete or
|
|
@@ -759,7 +743,9 @@ export const MessageList = ({
|
|
|
759
743
|
// (#202). On desktop the rows leaving the list beside the reading pane is
|
|
760
744
|
// signal enough.
|
|
761
745
|
if (!isDesktop) {
|
|
762
|
-
setCompletionBanner(
|
|
746
|
+
setCompletionBanner(
|
|
747
|
+
bulkActionCompletionText("delete", ids.length, deleteOutcome),
|
|
748
|
+
);
|
|
763
749
|
}
|
|
764
750
|
}, [
|
|
765
751
|
pendingDelete,
|
|
@@ -769,6 +755,7 @@ export const MessageList = ({
|
|
|
769
755
|
openRow,
|
|
770
756
|
isDesktop,
|
|
771
757
|
setFocusedMessageId,
|
|
758
|
+
deleteOutcome,
|
|
772
759
|
]);
|
|
773
760
|
|
|
774
761
|
// Every way out of the confirmation that isn't the delete — Escape, Cancel,
|
|
@@ -1353,11 +1340,11 @@ export const MessageList = ({
|
|
|
1353
1340
|
(listState === "ready" ? virtualBody : undefined)
|
|
1354
1341
|
}
|
|
1355
1342
|
/>
|
|
1356
|
-
<
|
|
1343
|
+
<DeleteConfirmDialog
|
|
1357
1344
|
isOpen={pendingDelete !== null}
|
|
1358
|
-
{
|
|
1359
|
-
|
|
1360
|
-
|
|
1345
|
+
count={pendingDelete?.length ?? 0}
|
|
1346
|
+
outcome={deleteOutcome}
|
|
1347
|
+
isDeleting={isDeleting}
|
|
1361
1348
|
onConfirm={handleConfirmDelete}
|
|
1362
1349
|
onCancel={handleCancelDelete}
|
|
1363
1350
|
/>
|