@remit/web-client 0.0.70 → 0.0.72

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.70",
3
+ "version": "0.0.72",
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": {
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
14
14
  import { Drawer } from "vaul";
15
15
  import { ErrorState } from "@/components/ui/ErrorState";
16
16
  import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
17
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
17
18
  import { useIsDesktop } from "@/hooks/useMediaQuery";
18
19
  import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
19
20
  import { buildMoveTargets } from "@/lib/move-targets";
@@ -95,6 +96,7 @@ export const MoveToTrigger = ({
95
96
  enabled: isOpen,
96
97
  });
97
98
  const folderAppointments = useFolderAppointments(accountId);
99
+ const { createFolder } = useCreateMailbox(accountId);
98
100
 
99
101
  const options = useMemo<MoveMailboxOption[]>(() => {
100
102
  const targets = buildMoveTargets(
@@ -127,6 +129,14 @@ export const MoveToTrigger = ({
127
129
  [onMove],
128
130
  );
129
131
 
132
+ const handleCreateFolder = useCallback(
133
+ async (name: string): Promise<MoveMailboxOption> => {
134
+ const folder = await createFolder(name);
135
+ return { id: folder.id, label: folder.label };
136
+ },
137
+ [createFolder],
138
+ );
139
+
130
140
  // Desktop popover: dismiss on outside click + Escape.
131
141
  useEffect(() => {
132
142
  if (!isOpen || !isDesktop) return;
@@ -190,9 +200,11 @@ export const MoveToTrigger = ({
190
200
  <MoveMailboxPicker
191
201
  mailboxes={options}
192
202
  onSelect={handleSelect}
203
+ onCreateFolder={handleCreateFolder}
193
204
  onCancel={() => setIsOpen(false)}
194
205
  autoFocus={!isDesktop}
195
206
  labels={{
207
+ createLabel: (folderName) => `Create "${folderName}"`,
196
208
  searchPlaceholder: t("move_picker_placeholder", {
197
209
  defaultValue: "Move to…",
198
210
  }),
@@ -7,6 +7,7 @@ import {
7
7
  } from "@remit/ui";
8
8
  import { useQuery } from "@tanstack/react-query";
9
9
  import { useMemo, useState } from "react";
10
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
10
11
  import { useCreateFilter } from "@/hooks/useFilters";
11
12
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
12
13
  import { useRuleEditorState } from "@/hooks/useRuleEditorState";
@@ -104,6 +105,7 @@ export function OrganizeRuleEditor({
104
105
 
105
106
  const organizeJob = useOrganizeJob(accountId);
106
107
  const createFilter = useCreateFilter(accountId);
108
+ const { createFolder } = useCreateMailbox(accountId);
107
109
 
108
110
  const commit = () => {
109
111
  const draft = ruleToDraft(rule, anchorMessageId);
@@ -153,6 +155,7 @@ export function OrganizeRuleEditor({
153
155
  semanticAvailable={!semanticUnavailable}
154
156
  clauseFields={SUPPORTED_CLAUSE_FIELDS}
155
157
  {...handlers}
158
+ onCreateFolder={createFolder}
156
159
  onCommit={commit}
157
160
  onCancel={onClose}
158
161
  />
@@ -0,0 +1,467 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapFolderAppointment,
5
+ RemitImapMailboxResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
8
+ import React, { act, createElement } from "react";
9
+ import { createRoot, type Root } from "react-dom/client";
10
+ import { DeleteFolderDialog } from "./DeleteFolderDialog";
11
+
12
+ // remit-ui's `.tsx` is transpiled with the classic JSX runtime, which
13
+ // references a global `React`; the app uses the automatic runtime, so this
14
+ // shim only exists for the test harness.
15
+ (globalThis as { React?: typeof React }).React = React;
16
+
17
+ const mailbox = (
18
+ over: Partial<RemitImapMailboxResponse> & {
19
+ mailboxId: string;
20
+ fullPath: string;
21
+ },
22
+ ): RemitImapMailboxResponse =>
23
+ ({
24
+ accountId: "acc-1",
25
+ namespaceType: "personal",
26
+ namespacePrefix: "",
27
+ hierarchyDelimiter: "/",
28
+ messageCount: 0,
29
+ unseenCount: 0,
30
+ deletedCount: 0,
31
+ lastSyncUid: 0,
32
+ highWaterMarkUid: 0,
33
+ lastMessageSyncAt: 0,
34
+ createdAt: 0,
35
+ updatedAt: 0,
36
+ ...over,
37
+ }) as RemitImapMailboxResponse;
38
+
39
+ const mailboxes: RemitImapMailboxResponse[] = [
40
+ mailbox({ mailboxId: "inbox", fullPath: "INBOX" }),
41
+ mailbox({ mailboxId: "receipts", fullPath: "Receipts", messageCount: 3 }),
42
+ mailbox({ mailboxId: "empty", fullPath: "Empty" }),
43
+ mailbox({ mailboxId: "archive", fullPath: "Archive" }),
44
+ ];
45
+
46
+ const appointments: RemitImapFolderAppointment[] = [
47
+ { role: "Inbox", mailboxId: "inbox" },
48
+ { role: "Archive", mailboxId: "archive" },
49
+ ];
50
+
51
+ let container: HTMLElement;
52
+ let root: Root;
53
+ const originalFetch = globalThis.fetch;
54
+
55
+ interface FetchCall {
56
+ url: string;
57
+ method: string;
58
+ body: string;
59
+ }
60
+ type FetchRoute = (call: FetchCall) => Response;
61
+ let route: FetchRoute = () => new Response("{}", { status: 200 });
62
+
63
+ const json = (body: unknown): Response =>
64
+ new Response(JSON.stringify(body), {
65
+ status: 200,
66
+ headers: { "Content-Type": "application/json" },
67
+ });
68
+
69
+ const threadItems = (ids: readonly string[]) => ({
70
+ items: ids.map((id) => ({
71
+ threadMessageId: `t-${id}`,
72
+ threadId: "th",
73
+ messageId: id,
74
+ accountConfigId: "acc-1",
75
+ mailboxId: "receipts",
76
+ isDeleted: false,
77
+ })),
78
+ });
79
+
80
+ beforeEach(() => {
81
+ container = document.createElement("div");
82
+ document.body.appendChild(container);
83
+ root = createRoot(container);
84
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
85
+ const request = input as Request;
86
+ const body = request.method === "GET" ? "" : await request.clone().text();
87
+ return route({ url: request.url, method: request.method, body });
88
+ }) as typeof fetch;
89
+ });
90
+
91
+ afterEach(() => {
92
+ act(() => root.unmount());
93
+ container.remove();
94
+ globalThis.fetch = originalFetch;
95
+ });
96
+
97
+ const render = (props: {
98
+ open: boolean;
99
+ folder: RemitImapMailboxResponse;
100
+ onClose?: () => void;
101
+ }) => {
102
+ act(() => {
103
+ root.render(
104
+ createElement(
105
+ QueryClientProvider,
106
+ { client: new QueryClient() },
107
+ createElement(DeleteFolderDialog, {
108
+ open: props.open,
109
+ accountId: "acc-1",
110
+ folder: props.folder,
111
+ mailboxes,
112
+ appointments,
113
+ onClose: props.onClose ?? (() => undefined),
114
+ }),
115
+ ) as never,
116
+ );
117
+ });
118
+ };
119
+
120
+ const buttonByText = (re: RegExp): HTMLButtonElement | undefined =>
121
+ Array.from(container.querySelectorAll("button")).find((b) =>
122
+ re.test(b.textContent ?? ""),
123
+ );
124
+
125
+ const flush = async () => {
126
+ for (let i = 0; i < 12; i += 1) {
127
+ await act(async () => {
128
+ await new Promise((resolve) => setTimeout(resolve, 0));
129
+ });
130
+ }
131
+ };
132
+
133
+ describe("DeleteFolderDialog", () => {
134
+ it("renders nothing when closed", () => {
135
+ render({ open: false, folder: mailboxes[1] as RemitImapMailboxResponse });
136
+ assert.equal(container.textContent, "");
137
+ });
138
+
139
+ it("confirms a single destructive step for an empty folder", () => {
140
+ render({ open: true, folder: mailboxes[2] as RemitImapMailboxResponse });
141
+ assert.match(container.textContent ?? "", /empty and will be removed/);
142
+ assert.ok(buttonByText(/^Delete folder$/), "delete folder button present");
143
+ });
144
+
145
+ it("asks what happens to the mail for a non-empty folder", () => {
146
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
147
+ assert.match(container.textContent ?? "", /holds 3 emails/);
148
+ assert.ok(buttonByText(/Delete them with the folder/));
149
+ assert.ok(buttonByText(/Move them to another folder/));
150
+ });
151
+
152
+ it("routes the delete-with-folder choice to a counted confirm and back", () => {
153
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
154
+ act(() => buttonByText(/Delete them with the folder/)?.click());
155
+ assert.match(container.textContent ?? "", /and its 3 emails/);
156
+ assert.ok(buttonByText(/Delete folder and emails/));
157
+ act(() => buttonByText(/^Back$/)?.click());
158
+ assert.match(container.textContent ?? "", /What should happen to them/);
159
+ });
160
+
161
+ it("offers a destination picker that excludes the folder being deleted", () => {
162
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
163
+ act(() => buttonByText(/Move them to another folder/)?.click());
164
+ const search = container.querySelector(
165
+ 'input[aria-label="Filter folders"]',
166
+ );
167
+ assert.ok(search, "the move picker is shown");
168
+ const options = Array.from(container.querySelectorAll('[role="option"]'))
169
+ .map((o) => o.textContent ?? "")
170
+ .join("|");
171
+ assert.doesNotMatch(options, /Receipts/);
172
+ assert.match(options, /Archive/);
173
+ });
174
+
175
+ it("deletes an empty folder and closes on success", async () => {
176
+ let closed = false;
177
+ route = ({ method }) => {
178
+ if (method === "DELETE") return new Response(null, { status: 204 });
179
+ return new Response("{}", { status: 200 });
180
+ };
181
+ render({
182
+ open: true,
183
+ folder: mailboxes[2] as RemitImapMailboxResponse,
184
+ onClose: () => {
185
+ closed = true;
186
+ },
187
+ });
188
+ await act(async () => {
189
+ buttonByText(/^Delete folder$/)?.click();
190
+ });
191
+ await flush();
192
+ assert.equal(closed, true);
193
+ });
194
+
195
+ it("moves the mail in batches then deletes the emptied folder", async () => {
196
+ let closed = false;
197
+ let moved: string[] = [];
198
+ let threadCalls = 0;
199
+ route = ({ url, method, body: reqBody }) => {
200
+ if (method === "DELETE") return new Response(null, { status: 204 });
201
+ if (url.includes("/messages/move")) {
202
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
203
+ moved = moved.concat(body.messageIds);
204
+ return new Response(JSON.stringify({ moved: body.messageIds.length }), {
205
+ status: 200,
206
+ headers: { "Content-Type": "application/json" },
207
+ });
208
+ }
209
+ if (url.includes("/threads")) {
210
+ threadCalls += 1;
211
+ const items =
212
+ threadCalls === 1
213
+ ? ["m1", "m2", "m3"].map((id) => ({
214
+ threadMessageId: `t-${id}`,
215
+ threadId: "th",
216
+ messageId: id,
217
+ accountConfigId: "acc-1",
218
+ mailboxId: "receipts",
219
+ isDeleted: false,
220
+ }))
221
+ : [];
222
+ return new Response(JSON.stringify({ items }), {
223
+ status: 200,
224
+ headers: { "Content-Type": "application/json" },
225
+ });
226
+ }
227
+ return new Response(JSON.stringify({ items: mailboxes }), {
228
+ status: 200,
229
+ headers: { "Content-Type": "application/json" },
230
+ });
231
+ };
232
+ render({
233
+ open: true,
234
+ folder: mailboxes[1] as RemitImapMailboxResponse,
235
+ onClose: () => {
236
+ closed = true;
237
+ },
238
+ });
239
+ act(() => buttonByText(/Move them to another folder/)?.click());
240
+ await act(async () => {
241
+ (
242
+ container.querySelector('button[aria-label="Move to Archive"]') as
243
+ | HTMLButtonElement
244
+ | undefined
245
+ )?.click();
246
+ });
247
+ await flush();
248
+ assert.deepEqual(moved.sort(), ["m1", "m2", "m3"]);
249
+ assert.equal(closed, true);
250
+ });
251
+
252
+ it("surfaces a failed batch and keeps the dialog open", async () => {
253
+ let closed = false;
254
+ route = ({ url }) => {
255
+ if (url.includes("/messages/move"))
256
+ return new Response(JSON.stringify({ detail: "server exploded" }), {
257
+ status: 500,
258
+ headers: { "Content-Type": "application/json" },
259
+ });
260
+ if (url.includes("/threads"))
261
+ return new Response(
262
+ JSON.stringify({
263
+ items: [
264
+ {
265
+ threadMessageId: "t1",
266
+ threadId: "th",
267
+ messageId: "m1",
268
+ accountConfigId: "acc-1",
269
+ mailboxId: "receipts",
270
+ isDeleted: false,
271
+ },
272
+ ],
273
+ }),
274
+ { status: 200, headers: { "Content-Type": "application/json" } },
275
+ );
276
+ return new Response(JSON.stringify({ items: mailboxes }), {
277
+ status: 200,
278
+ headers: { "Content-Type": "application/json" },
279
+ });
280
+ };
281
+ render({
282
+ open: true,
283
+ folder: mailboxes[1] as RemitImapMailboxResponse,
284
+ onClose: () => {
285
+ closed = true;
286
+ },
287
+ });
288
+ act(() => buttonByText(/Move them to another folder/)?.click());
289
+ await act(async () => {
290
+ (
291
+ container.querySelector('button[aria-label="Move to Archive"]') as
292
+ | HTMLButtonElement
293
+ | undefined
294
+ )?.click();
295
+ });
296
+ await flush();
297
+ assert.match(container.textContent ?? "", /stay moved/);
298
+ assert.equal(closed, false);
299
+ });
300
+
301
+ const clickMoveToArchive = async () => {
302
+ act(() => buttonByText(/Move them to another folder/)?.click());
303
+ await act(async () => {
304
+ (
305
+ container.querySelector('button[aria-label="Move to Archive"]') as
306
+ | HTMLButtonElement
307
+ | undefined
308
+ )?.click();
309
+ });
310
+ };
311
+
312
+ it("iterates multiple batches and deletes only after the folder drains", async () => {
313
+ let deleted = false;
314
+ const moveCalls: string[][] = [];
315
+ let threadRound = 0;
316
+ route = ({ url, method, body: reqBody }) => {
317
+ if (method === "DELETE") {
318
+ deleted = true;
319
+ return new Response(null, { status: 204 });
320
+ }
321
+ if (url.includes("/messages/move")) {
322
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
323
+ moveCalls.push(body.messageIds);
324
+ return json({ moved: body.messageIds.length });
325
+ }
326
+ if (url.includes("/threads")) {
327
+ threadRound += 1;
328
+ if (threadRound === 1) return json(threadItems(["m1", "m2"]));
329
+ if (threadRound === 2) return json(threadItems(["m3", "m4"]));
330
+ return json(threadItems([]));
331
+ }
332
+ return json({ items: mailboxes });
333
+ };
334
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
335
+ await clickMoveToArchive();
336
+ await flush();
337
+ assert.equal(moveCalls.length, 2, "two move calls, one per batch");
338
+ assert.deepEqual(moveCalls[0], ["m1", "m2"]);
339
+ assert.deepEqual(moveCalls[1], ["m3", "m4"]);
340
+ assert.equal(deleted, true, "delete fires only after drain and recheck");
341
+ });
342
+
343
+ it("does not delete or error when the dialog is closed mid-move", async () => {
344
+ let deleted = false;
345
+ let closed = false;
346
+ const moveCalls: string[][] = [];
347
+ route = ({ url, method, body: reqBody }) => {
348
+ if (method === "DELETE") {
349
+ deleted = true;
350
+ return new Response(null, { status: 204 });
351
+ }
352
+ if (url.includes("/messages/move")) {
353
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
354
+ moveCalls.push(body.messageIds);
355
+ container
356
+ .querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')
357
+ ?.click();
358
+ return json({ moved: body.messageIds.length });
359
+ }
360
+ if (url.includes("/threads")) return json(threadItems(["m1", "m2"]));
361
+ return json({ items: mailboxes });
362
+ };
363
+ render({
364
+ open: true,
365
+ folder: mailboxes[1] as RemitImapMailboxResponse,
366
+ onClose: () => {
367
+ closed = true;
368
+ },
369
+ });
370
+ await clickMoveToArchive();
371
+ await flush();
372
+ assert.equal(closed, true, "closing the dialog aborts the run");
373
+ assert.equal(moveCalls.length, 1, "the in-flight batch completes");
374
+ assert.equal(deleted, false, "abort never reaches the delete");
375
+ assert.doesNotMatch(
376
+ container.textContent ?? "",
377
+ /went wrong|still syncing|stay moved/,
378
+ "aborting is a cancel, not an error",
379
+ );
380
+ });
381
+
382
+ it("refuses the delete when an independent recheck still sees mail", async () => {
383
+ let deleted = false;
384
+ const moveCalls: string[][] = [];
385
+ route = ({ url, method, body: reqBody }) => {
386
+ if (method === "DELETE") {
387
+ deleted = true;
388
+ return new Response(null, { status: 204 });
389
+ }
390
+ if (url.includes("/messages/move")) {
391
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
392
+ moveCalls.push(body.messageIds);
393
+ return json({ moved: body.messageIds.length });
394
+ }
395
+ if (url.includes("/threads")) return json(threadItems(["m1"]));
396
+ return json({ items: mailboxes });
397
+ };
398
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
399
+ await clickMoveToArchive();
400
+ await flush();
401
+ assert.equal(
402
+ moveCalls.length,
403
+ 1,
404
+ "m1 moves once, then the drain filters it",
405
+ );
406
+ assert.equal(
407
+ deleted,
408
+ false,
409
+ "a live message still listed blocks the delete",
410
+ );
411
+ assert.match(container.textContent ?? "", /still syncing/);
412
+ });
413
+
414
+ it("resumes after re-open and skips already-moved mail", async () => {
415
+ let deleted = false;
416
+ const moveCalls: string[][] = [];
417
+ const movedOnServer = new Set<string>();
418
+ const folderIds = ["m1", "m2"];
419
+ let interruptNextMove = true;
420
+ route = ({ url, method, body: reqBody }) => {
421
+ if (method === "DELETE") {
422
+ deleted = true;
423
+ return new Response(null, { status: 204 });
424
+ }
425
+ if (url.includes("/messages/move")) {
426
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
427
+ moveCalls.push(body.messageIds);
428
+ for (const id of body.messageIds) movedOnServer.add(id);
429
+ if (interruptNextMove) {
430
+ interruptNextMove = false;
431
+ container
432
+ .querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')
433
+ ?.click();
434
+ }
435
+ return json({ moved: body.messageIds.length });
436
+ }
437
+ if (url.includes("/threads"))
438
+ return json(
439
+ threadItems(folderIds.filter((id) => !movedOnServer.has(id))),
440
+ );
441
+ return json({ items: mailboxes });
442
+ };
443
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
444
+ await clickMoveToArchive();
445
+ await flush();
446
+ assert.deepEqual(
447
+ moveCalls,
448
+ [["m1", "m2"]],
449
+ "run one moves the first batch",
450
+ );
451
+ assert.equal(deleted, false, "the interrupted run does not delete");
452
+
453
+ folderIds.push("m3");
454
+ render({ open: false, folder: mailboxes[1] as RemitImapMailboxResponse });
455
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
456
+ await clickMoveToArchive();
457
+ await flush();
458
+
459
+ const secondRunIds = moveCalls.slice(1).flat();
460
+ assert.deepEqual(
461
+ secondRunIds,
462
+ ["m3"],
463
+ "the resumed run moves only what is left, skipping already-moved ids",
464
+ );
465
+ assert.equal(deleted, true, "the resumed run drains and deletes");
466
+ });
467
+ });