@remit/web-client 0.0.71 → 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.
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ advanceMove,
5
+ appointedRole,
6
+ beginMove,
7
+ excludeFolder,
8
+ type FolderNode,
9
+ failMove,
10
+ guardFolderDeletion,
11
+ hasChildFolders,
12
+ initialStage,
13
+ moveProgressLabel,
14
+ } from "./delete-folder.js";
15
+
16
+ const folder = (
17
+ over: Partial<FolderNode> & { mailboxId: string },
18
+ ): FolderNode => ({
19
+ fullPath: over.mailboxId,
20
+ hierarchyDelimiter: "/",
21
+ messageCount: 0,
22
+ ...over,
23
+ });
24
+
25
+ describe("guardFolderDeletion", () => {
26
+ it("blocks the inbox by its reserved path, case-insensitively", () => {
27
+ const target = folder({ mailboxId: "a", fullPath: "InBoX" });
28
+ const guard = guardFolderDeletion(target, [target], []);
29
+ assert.equal(guard.deletable, false);
30
+ assert.equal(guard.reason, "inbox");
31
+ assert.match(guard.message ?? "", /inbox can't be deleted/i);
32
+ });
33
+
34
+ it("blocks a folder appointed to a canonical role and names the role", () => {
35
+ const target = folder({ mailboxId: "arch", fullPath: "Archive" });
36
+ const guard = guardFolderDeletion(
37
+ target,
38
+ [target],
39
+ [{ role: "Archive", mailboxId: "arch" }],
40
+ );
41
+ assert.equal(guard.deletable, false);
42
+ assert.equal(guard.reason, "role");
43
+ assert.match(guard.message ?? "", /Archive/);
44
+ });
45
+
46
+ it("blocks a folder that has subfolders", () => {
47
+ const target = folder({ mailboxId: "work", fullPath: "Work" });
48
+ const child = folder({ mailboxId: "sub", fullPath: "Work/Shop" });
49
+ const guard = guardFolderDeletion(target, [target, child], []);
50
+ assert.equal(guard.deletable, false);
51
+ assert.equal(guard.reason, "children");
52
+ assert.match(guard.message ?? "", /subfolders/i);
53
+ });
54
+
55
+ it("allows a plain leaf folder with no role and no children", () => {
56
+ const target = folder({
57
+ mailboxId: "r",
58
+ fullPath: "Receipts",
59
+ messageCount: 4,
60
+ });
61
+ const other = folder({ mailboxId: "n", fullPath: "News" });
62
+ const guard = guardFolderDeletion(
63
+ target,
64
+ [target, other],
65
+ [{ role: "Archive", mailboxId: "other-box" }],
66
+ );
67
+ assert.deepEqual(guard, { deletable: true });
68
+ });
69
+
70
+ it("ignores an unfilled role appointment with no mailbox", () => {
71
+ const target = folder({ mailboxId: "r", fullPath: "Receipts" });
72
+ const guard = guardFolderDeletion(target, [target], [{ role: "Junk" }]);
73
+ assert.equal(guard.deletable, true);
74
+ });
75
+ });
76
+
77
+ describe("hasChildFolders", () => {
78
+ it("treats a delimiter-separated nested path as a child", () => {
79
+ const parent = {
80
+ mailboxId: "p",
81
+ fullPath: "Work",
82
+ hierarchyDelimiter: "/",
83
+ };
84
+ const kids = [{ mailboxId: "c", fullPath: "Work/Q3" }];
85
+ assert.equal(hasChildFolders(parent, [...kids, parent]), true);
86
+ });
87
+
88
+ it("does not treat a shared string prefix without a delimiter as a child", () => {
89
+ const parent = {
90
+ mailboxId: "p",
91
+ fullPath: "Work",
92
+ hierarchyDelimiter: "/",
93
+ };
94
+ const lookalike = [{ mailboxId: "w", fullPath: "Workshop" }];
95
+ assert.equal(hasChildFolders(parent, [...lookalike, parent]), false);
96
+ });
97
+
98
+ it("honours a non-slash delimiter", () => {
99
+ const parent = {
100
+ mailboxId: "p",
101
+ fullPath: "INBOX",
102
+ hierarchyDelimiter: ".",
103
+ };
104
+ const kids = [{ mailboxId: "c", fullPath: "INBOX.Sub" }];
105
+ assert.equal(hasChildFolders(parent, [...kids, parent]), true);
106
+ });
107
+ });
108
+
109
+ describe("appointedRole", () => {
110
+ it("returns the role a mailbox fills", () => {
111
+ assert.equal(
112
+ appointedRole("a", [{ role: "Trash", mailboxId: "a" }]),
113
+ "Trash",
114
+ );
115
+ });
116
+
117
+ it("returns undefined when the mailbox fills no role", () => {
118
+ assert.equal(
119
+ appointedRole("a", [{ role: "Trash", mailboxId: "b" }]),
120
+ undefined,
121
+ );
122
+ });
123
+ });
124
+
125
+ describe("excludeFolder", () => {
126
+ it("drops the folder being deleted from the destination list", () => {
127
+ const targets = [
128
+ { mailboxId: "a" },
129
+ { mailboxId: "b" },
130
+ { mailboxId: "c" },
131
+ ];
132
+ assert.deepEqual(excludeFolder(targets, "b"), [
133
+ { mailboxId: "a" },
134
+ { mailboxId: "c" },
135
+ ]);
136
+ });
137
+ });
138
+
139
+ describe("move progress", () => {
140
+ it("begins at zero and moving for a non-empty set", () => {
141
+ assert.deepEqual(beginMove(120), { total: 120, moved: 0, phase: "moving" });
142
+ });
143
+
144
+ it("begins already moved for an empty set", () => {
145
+ assert.deepEqual(beginMove(0), { total: 0, moved: 0, phase: "moved" });
146
+ });
147
+
148
+ it("advances by the batch size and finishes at the total", () => {
149
+ let progress = beginMove(150);
150
+ progress = advanceMove(progress, 100);
151
+ assert.deepEqual(progress, { total: 150, moved: 100, phase: "moving" });
152
+ progress = advanceMove(progress, 50);
153
+ assert.deepEqual(progress, { total: 150, moved: 150, phase: "moved" });
154
+ });
155
+
156
+ it("clamps moved to the total if the folder shrank underneath the count", () => {
157
+ const progress = advanceMove(beginMove(80), 100);
158
+ assert.equal(progress.moved, 80);
159
+ assert.equal(progress.phase, "moved");
160
+ });
161
+
162
+ it("stops progress on failure and does not advance afterwards", () => {
163
+ const failed = failMove(beginMove(150), "boom");
164
+ assert.equal(failed.phase, "failed");
165
+ assert.equal(failed.moved, 0);
166
+ const after = advanceMove(failed, 100);
167
+ assert.deepEqual(after, failed);
168
+ });
169
+
170
+ it("labels progress as moved-of-total", () => {
171
+ assert.equal(
172
+ moveProgressLabel({ total: 150, moved: 100, phase: "moving" }),
173
+ "Moved 100 of 150",
174
+ );
175
+ });
176
+ });
177
+
178
+ describe("initialStage", () => {
179
+ it("opens on the empty confirm for a folder with no mail", () => {
180
+ assert.equal(initialStage(0), "confirm-empty");
181
+ });
182
+
183
+ it("opens on the fate choice for a folder holding mail", () => {
184
+ assert.equal(initialStage(3), "choose-fate");
185
+ });
186
+ });
@@ -0,0 +1,151 @@
1
+ export const MOVE_BATCH_SIZE = 100;
2
+
3
+ export interface FolderNode {
4
+ mailboxId: string;
5
+ fullPath: string;
6
+ hierarchyDelimiter: string;
7
+ messageCount: number;
8
+ }
9
+
10
+ export interface RoleAppointment {
11
+ role: string;
12
+ mailboxId?: string | null;
13
+ }
14
+
15
+ export type DeleteBlockReason = "inbox" | "role" | "children";
16
+
17
+ export interface DeleteGuard {
18
+ deletable: boolean;
19
+ reason?: DeleteBlockReason;
20
+ message?: string;
21
+ }
22
+
23
+ const isInboxPath = (fullPath: string): boolean =>
24
+ fullPath.trim().toLowerCase() === "inbox";
25
+
26
+ /**
27
+ * A folder has children when another mailbox's path is nested under it — the
28
+ * folder's own path followed by the hierarchy delimiter. The delimiter matters:
29
+ * `Work` is not a parent of `Workshop`, only of `Work/Shop`.
30
+ */
31
+ export const hasChildFolders = (
32
+ folder: Pick<FolderNode, "mailboxId" | "fullPath" | "hierarchyDelimiter">,
33
+ all: readonly Pick<FolderNode, "mailboxId" | "fullPath">[],
34
+ ): boolean => {
35
+ const prefix = `${folder.fullPath}${folder.hierarchyDelimiter}`;
36
+ return all.some(
37
+ (other) =>
38
+ other.mailboxId !== folder.mailboxId && other.fullPath.startsWith(prefix),
39
+ );
40
+ };
41
+
42
+ /** The canonical role a mailbox is appointed to, or `undefined` when unfilled. */
43
+ export const appointedRole = (
44
+ mailboxId: string,
45
+ appointments: readonly RoleAppointment[],
46
+ ): string | undefined =>
47
+ appointments.find((a) => a.mailboxId != null && a.mailboxId === mailboxId)
48
+ ?.role;
49
+
50
+ /**
51
+ * Why a folder can't be deleted, or `{ deletable: true }` when it can. The
52
+ * inbox is reserved, a folder that fills a canonical role must be released
53
+ * first, and a folder with subfolders must have them handled before it goes.
54
+ */
55
+ export function guardFolderDeletion(
56
+ folder: FolderNode,
57
+ all: readonly FolderNode[],
58
+ appointments: readonly RoleAppointment[],
59
+ ): DeleteGuard {
60
+ if (isInboxPath(folder.fullPath))
61
+ return {
62
+ deletable: false,
63
+ reason: "inbox",
64
+ message: "The inbox can't be deleted.",
65
+ };
66
+
67
+ const role = appointedRole(folder.mailboxId, appointments);
68
+ if (role)
69
+ return {
70
+ deletable: false,
71
+ reason: "role",
72
+ message: `This folder is your ${role} folder. Reassign that role before deleting it.`,
73
+ };
74
+
75
+ if (hasChildFolders(folder, all))
76
+ return {
77
+ deletable: false,
78
+ reason: "children",
79
+ message: "This folder has subfolders. Delete them first.",
80
+ };
81
+
82
+ return { deletable: true };
83
+ }
84
+
85
+ /** Destinations with the folder being deleted removed. */
86
+ export function excludeFolder<T extends { mailboxId: string }>(
87
+ targets: readonly T[],
88
+ excludeMailboxId: string,
89
+ ): T[] {
90
+ return targets.filter((target) => target.mailboxId !== excludeMailboxId);
91
+ }
92
+
93
+ export type MovePhase = "moving" | "moved" | "failed";
94
+
95
+ export interface MoveProgress {
96
+ total: number;
97
+ moved: number;
98
+ phase: MovePhase;
99
+ error?: string;
100
+ }
101
+
102
+ /** Progress at the start of a move; an empty set is already `moved`. */
103
+ export function beginMove(total: number): MoveProgress {
104
+ return { total, moved: 0, phase: total === 0 ? "moved" : "moving" };
105
+ }
106
+
107
+ /**
108
+ * Count a successful batch. Clamps to `total` so a folder that grew mid-move
109
+ * never shows more moved than started, and only a still-`moving` progress
110
+ * advances — a failed or finished move stays put.
111
+ */
112
+ export function advanceMove(
113
+ progress: MoveProgress,
114
+ batchSize: number,
115
+ ): MoveProgress {
116
+ if (progress.phase !== "moving") return progress;
117
+ const moved = Math.min(
118
+ progress.total,
119
+ progress.moved + Math.max(0, batchSize),
120
+ );
121
+ return {
122
+ ...progress,
123
+ moved,
124
+ phase: moved >= progress.total ? "moved" : "moving",
125
+ };
126
+ }
127
+
128
+ /** A failed batch stops progress and surfaces the error; nothing advances after. */
129
+ export function failMove(progress: MoveProgress, error: string): MoveProgress {
130
+ return { ...progress, phase: "failed", error };
131
+ }
132
+
133
+ export function moveProgressLabel(progress: MoveProgress): string {
134
+ return `Moved ${progress.moved} of ${progress.total}`;
135
+ }
136
+
137
+ export type DeleteStage =
138
+ | "confirm-empty"
139
+ | "choose-fate"
140
+ | "confirm-delete-all"
141
+ | "pick-destination"
142
+ | "moving"
143
+ | "deleting"
144
+ | "error";
145
+
146
+ /** Where the wizard opens: a straight confirm for an empty folder, otherwise the fate step. */
147
+ export function initialStage(
148
+ messageCount: number,
149
+ ): "confirm-empty" | "choose-fate" {
150
+ return messageCount > 0 ? "choose-fate" : "confirm-empty";
151
+ }
@@ -22,9 +22,12 @@ import {
22
22
  } from "@remit/ui";
23
23
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
24
24
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
25
+ import { Trash2 } from "lucide-react";
25
26
  import { useState } from "react";
27
+ import { DeleteFolderDialog } from "@/components/settings/DeleteFolderDialog";
26
28
  import { ErrorState } from "@/components/ui/ErrorState";
27
29
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
30
+ import { guardFolderDeletion } from "@/lib/delete-folder";
28
31
  import {
29
32
  CANONICAL_TO_NAV_ROLE,
30
33
  getMailboxDisplayName,
@@ -209,6 +212,8 @@ function AccountFolderRoles({
209
212
  },
210
213
  });
211
214
 
215
+ const [deletingMailboxId, setDeletingMailboxId] = useState<string>();
216
+
212
217
  const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
213
218
  appointMutation.mutate({
214
219
  path: { accountId, role: NAV_ROLE_TO_CANONICAL[role] },
@@ -284,20 +289,53 @@ function AccountFolderRoles({
284
289
  />
285
290
  <NewFolder accountId={accountId} mailboxes={data.items} />
286
291
  <ul className="space-y-1" aria-label={`All folders for ${account.email}`}>
287
- {data.items.map((mailbox) => (
288
- <li
289
- key={mailbox.mailboxId}
290
- className="flex items-center justify-between rounded-sm px-2 py-1 text-sm text-fg"
291
- >
292
- <span className="truncate">
293
- {getMailboxDisplayName(mailbox.fullPath)}
294
- </span>
295
- <span className="shrink-0 text-xs text-fg-muted">
296
- {mailbox.fullPath}
297
- </span>
298
- </li>
299
- ))}
292
+ {data.items.map((mailbox) => {
293
+ const guard = guardFolderDeletion(
294
+ mailbox,
295
+ data.items,
296
+ account.folderAppointments,
297
+ );
298
+ const name = getMailboxDisplayName(mailbox.fullPath);
299
+ return (
300
+ <li
301
+ key={mailbox.mailboxId}
302
+ className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm text-fg"
303
+ >
304
+ <span className="truncate">{name}</span>
305
+ <span className="ml-auto shrink-0 text-xs text-fg-muted">
306
+ {mailbox.fullPath}
307
+ </span>
308
+ <Button
309
+ variant="ghost"
310
+ size="sm"
311
+ icon={<Trash2 className="size-3.5" />}
312
+ aria-label={`Delete ${name}`}
313
+ disabled={!guard.deletable}
314
+ title={guard.deletable ? undefined : guard.message}
315
+ onClick={() => setDeletingMailboxId(mailbox.mailboxId)}
316
+ className={guard.deletable ? undefined : "opacity-40"}
317
+ />
318
+ </li>
319
+ );
320
+ })}
300
321
  </ul>
322
+ {deletingMailboxId &&
323
+ (() => {
324
+ const folder = data.items.find(
325
+ (box) => box.mailboxId === deletingMailboxId,
326
+ );
327
+ if (!folder) return null;
328
+ return (
329
+ <DeleteFolderDialog
330
+ open
331
+ accountId={accountId}
332
+ folder={folder}
333
+ mailboxes={data.items}
334
+ appointments={account.folderAppointments}
335
+ onClose={() => setDeletingMailboxId(undefined)}
336
+ />
337
+ );
338
+ })()}
301
339
  </div>
302
340
  );
303
341
  }