@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.190",
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
- 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
  );
@@ -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(/&#x27;/g, "'")
40
+ .replace(/&#x2F;/g, "/")
41
+ .replace(/&quot;/g, '"')
42
+ .replace(/&amp;/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
+ }