@remit/web-client 0.0.190 → 0.0.192
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 +208 -26
- package/src/components/mail/DeleteConfirmDialog.tsx +53 -17
- package/src/components/mail/EmptyTrashBar.render.test.ts +133 -0
- package/src/components/mail/EmptyTrashBar.tsx +181 -0
- package/src/components/mail/MailboxPane.tsx +39 -1
- package/src/components/mail/MessageList.tsx +71 -62
- package/src/components/mail/RoleAppointmentPromptProvider.render.test.ts +292 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +246 -0
- package/src/components/mail/ThreadListInteraction.test.ts +30 -21
- package/src/components/mail/ThreadListInteraction.tsx +18 -8
- package/src/components/mail/empty-trash-bar.stories.tsx +121 -0
- package/src/components/ui/folder-role-refusal.test.ts +94 -0
- package/src/components/ui/folder-role-refusal.ts +81 -0
- package/src/hooks/useArchiveMailbox.ts +21 -5
- package/src/hooks/useCurrentMailboxName.ts +33 -0
- package/src/hooks/useDeleteMessages.ts +41 -5
- package/src/hooks/useDeleteOutcome.ts +37 -7
- package/src/hooks/useEmptyTrash.render.test.ts +194 -0
- package/src/hooks/useEmptyTrash.ts +187 -0
- package/src/lib/format.test.ts +133 -11
- package/src/lib/format.ts +95 -17
- package/src/lib/move-options.ts +5 -0
- package/src/routes/__root.tsx +14 -11
- package/src/routes/settings/folders.tsx +12 -3
- package/src/test-support/dom.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.192",
|
|
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
|
-
|
|
83
|
-
{
|
|
84
|
-
createElement(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
186
|
-
*
|
|
187
|
-
* moving anything
|
|
188
|
-
*
|
|
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 —
|
|
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, /
|
|
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("
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
assert.
|
|
211
|
-
assert.equal(
|
|
212
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* could not be read reader cannot say
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
127
|
+
onConfirm={confirm}
|
|
92
128
|
onCancel={onCancel}
|
|
93
129
|
/>
|
|
94
130
|
);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Empty Trash strip and its three refusals (#847). Rendered as the app
|
|
3
|
+
* renders it — no providers, because every fact it shows arrives as a prop and
|
|
4
|
+
* the 409 is what decides which one.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import React, { createElement } from "react";
|
|
10
|
+
import { renderToString } from "react-dom/server";
|
|
11
|
+
import {
|
|
12
|
+
EmptyTrashBar,
|
|
13
|
+
type EmptyTrashBarProps,
|
|
14
|
+
emptyTrashConfirmCopy,
|
|
15
|
+
} from "@/components/mail/EmptyTrashBar";
|
|
16
|
+
import { deleteConfirmationCopy } from "@/lib/format";
|
|
17
|
+
|
|
18
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
19
|
+
|
|
20
|
+
const noop = () => {};
|
|
21
|
+
|
|
22
|
+
const propsFor = (
|
|
23
|
+
overrides: Partial<EmptyTrashBarProps>,
|
|
24
|
+
): EmptyTrashBarProps => ({
|
|
25
|
+
messageCount: 128,
|
|
26
|
+
isEmptying: false,
|
|
27
|
+
onEmpty: noop,
|
|
28
|
+
onRepair: noop,
|
|
29
|
+
children: createElement("div", { id: "list" }, "list"),
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const render = (overrides: Partial<EmptyTrashBarProps>): string =>
|
|
34
|
+
renderToString(createElement(EmptyTrashBar, propsFor(overrides)));
|
|
35
|
+
|
|
36
|
+
const text = (html: string): string =>
|
|
37
|
+
html
|
|
38
|
+
.replace(/<[^>]*>/g, " ")
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(///g, "/")
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/&/g, "&")
|
|
43
|
+
.replace(/\s+/g, " ")
|
|
44
|
+
.trim();
|
|
45
|
+
|
|
46
|
+
describe("EmptyTrashBar", () => {
|
|
47
|
+
it("offers the verb when the open Trash folder holds mail", () => {
|
|
48
|
+
const html = render({});
|
|
49
|
+
assert.match(text(html), /Empty Trash/);
|
|
50
|
+
assert.match(html, /id="list"/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("offers nothing over an empty folder, and still renders the list", () => {
|
|
54
|
+
const html = render({ messageCount: 0 });
|
|
55
|
+
assert.doesNotMatch(text(html), /Empty Trash/);
|
|
56
|
+
assert.match(html, /id="list"/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("disables the button while the empty is in flight", () => {
|
|
60
|
+
const html = render({ isEmptying: true });
|
|
61
|
+
assert.match(text(html), /Emptying/);
|
|
62
|
+
assert.match(html, /disabled/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reports the service's own count once the run finishes", () => {
|
|
66
|
+
const html = render({ messageCount: 0, deletedCount: 128 });
|
|
67
|
+
assert.match(text(html), /128 messages erased from the mail server\./);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps the refusal standing after the folder count drops to zero", () => {
|
|
71
|
+
const html = render({ messageCount: 0, refusalReason: "none" });
|
|
72
|
+
assert.match(text(html), /No folder on this account is set as Trash\./);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("words `unconfirmed` with deleteConfirmationCopy's own sentence", () => {
|
|
76
|
+
const copy = deleteConfirmationCopy(0, "unconfirmed", {
|
|
77
|
+
trashFolderLabel: "Deleted Items",
|
|
78
|
+
});
|
|
79
|
+
const html = text(
|
|
80
|
+
render({
|
|
81
|
+
refusalReason: "unconfirmed",
|
|
82
|
+
trashFolderLabel: "Deleted Items",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
assert.match(html, new RegExp(literal(copy.title)));
|
|
86
|
+
assert.match(html, new RegExp(literal(copy.description)));
|
|
87
|
+
assert.match(html, new RegExp(literal(copy.confirmLabel)));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names the folder a stale appointment lost", () => {
|
|
91
|
+
const html = text(
|
|
92
|
+
render({ refusalReason: "stale", staleFolderLabel: "Archive/Bin" }),
|
|
93
|
+
);
|
|
94
|
+
assert.match(html, /Nothing was emptied\./);
|
|
95
|
+
assert.match(html, /Archive\/Bin — is gone from the mail server\./);
|
|
96
|
+
assert.match(html, /Pick another folder/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("drops the folder clause when the lost folder has no name", () => {
|
|
100
|
+
const html = text(render({ refusalReason: "stale" }));
|
|
101
|
+
assert.match(
|
|
102
|
+
html,
|
|
103
|
+
/The folder you chose for Trash is gone from the mail server\./,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("offers a folder to pick when the account appoints no Trash", () => {
|
|
108
|
+
const html = text(render({ refusalReason: "none" }));
|
|
109
|
+
assert.match(html, /No folder on this account is set as Trash\./);
|
|
110
|
+
assert.match(html, /Pick a folder/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("asks the confirmation as the expunge it is", () => {
|
|
114
|
+
const copy = emptyTrashConfirmCopy(128);
|
|
115
|
+
assert.equal(copy.title, "Empty Trash?");
|
|
116
|
+
assert.equal(
|
|
117
|
+
copy.description,
|
|
118
|
+
"128 messages are erased from the mail server and cannot be restored.",
|
|
119
|
+
);
|
|
120
|
+
assert.equal(copy.confirmLabel, "Empty Trash");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("counts a single message in the singular", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
emptyTrashConfirmCopy(1).description,
|
|
126
|
+
"1 message is erased from the mail server and cannot be restored.",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function literal(value: string): string {
|
|
132
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
133
|
+
}
|