@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.
@@ -0,0 +1,308 @@
1
+ import type {
2
+ RemitImapFolderAppointment,
3
+ RemitImapMailboxResponse,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import {
6
+ Banner,
7
+ Button,
8
+ Dialog,
9
+ type MoveMailboxOption,
10
+ MoveMailboxPicker,
11
+ } from "@remit/ui";
12
+ import { AlertTriangle, FolderInput, Loader2, Trash2, X } from "lucide-react";
13
+ import { useCallback, useEffect, useMemo, useState } from "react";
14
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
15
+ import { useDeleteFolder } from "@/hooks/useDeleteFolder";
16
+ import {
17
+ excludeFolder,
18
+ initialStage,
19
+ moveProgressLabel,
20
+ } from "@/lib/delete-folder";
21
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
22
+ import { buildMoveTargets } from "@/lib/move-targets";
23
+
24
+ interface DeleteFolderDialogProps {
25
+ open: boolean;
26
+ accountId: string;
27
+ folder: RemitImapMailboxResponse;
28
+ mailboxes: readonly RemitImapMailboxResponse[];
29
+ appointments: readonly RemitImapFolderAppointment[];
30
+ onClose: () => void;
31
+ }
32
+
33
+ type FateStage =
34
+ | "confirm-empty"
35
+ | "choose-fate"
36
+ | "confirm-delete-all"
37
+ | "pick-destination";
38
+
39
+ const folderLabel = (folder: RemitImapMailboxResponse): string =>
40
+ folder.displayNameOverride?.trim() || getMailboxDisplayName(folder.fullPath);
41
+
42
+ const emailCount = (count: number): string =>
43
+ `${count} ${count === 1 ? "email" : "emails"}`;
44
+
45
+ /**
46
+ * Per-folder delete wizard. An empty folder is a single destructive confirm; a
47
+ * folder with mail first asks what happens to the messages — delete them with
48
+ * the folder, or move them elsewhere (batched, with visible progress) before
49
+ * the now-empty folder is removed. Closing mid-move keeps already-moved mail
50
+ * moved; re-opening enumerates what's left and continues.
51
+ */
52
+ export function DeleteFolderDialog({
53
+ open,
54
+ accountId,
55
+ folder,
56
+ mailboxes,
57
+ appointments,
58
+ onClose,
59
+ }: DeleteFolderDialogProps) {
60
+ const [stage, setStage] = useState<FateStage>(() =>
61
+ initialStage(folder.messageCount),
62
+ );
63
+ const { createFolder } = useCreateMailbox(accountId);
64
+ const {
65
+ phase,
66
+ progress,
67
+ errorMessage,
68
+ deleteMailbox,
69
+ moveThenDelete,
70
+ cancel,
71
+ reset,
72
+ } = useDeleteFolder({
73
+ accountId,
74
+ mailboxId: folder.mailboxId,
75
+ onDeleted: onClose,
76
+ });
77
+
78
+ useEffect(() => {
79
+ if (!open) return;
80
+ setStage(initialStage(folder.messageCount));
81
+ reset();
82
+ }, [open, folder.messageCount, reset]);
83
+
84
+ useEffect(() => cancel, [cancel]);
85
+
86
+ const handleClose = useCallback(() => {
87
+ cancel();
88
+ onClose();
89
+ }, [cancel, onClose]);
90
+
91
+ const destinations = useMemo<MoveMailboxOption[]>(
92
+ () =>
93
+ excludeFolder(
94
+ buildMoveTargets(mailboxes, appointments),
95
+ folder.mailboxId,
96
+ ).map((mailbox) => ({
97
+ id: mailbox.mailboxId,
98
+ label: getMailboxDisplayName(mailbox.fullPath),
99
+ searchValue: mailbox.fullPath,
100
+ })),
101
+ [mailboxes, appointments, folder.mailboxId],
102
+ );
103
+
104
+ const handleCreateFolder = async (
105
+ name: string,
106
+ ): Promise<MoveMailboxOption> => {
107
+ const created = await createFolder(name);
108
+ return { id: created.id, label: created.label };
109
+ };
110
+
111
+ if (!open) return null;
112
+
113
+ const name = folderLabel(folder);
114
+ const title = `Delete ${name}`;
115
+
116
+ const body = (() => {
117
+ if (phase === "moving") {
118
+ return (
119
+ <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
120
+ <Loader2 className="size-8 animate-spin text-accent-2" />
121
+ <p className="text-sm font-medium text-fg">Moving emails…</p>
122
+ <p className="text-xs text-fg-muted" role="status" aria-live="polite">
123
+ {progress ? moveProgressLabel(progress) : "Moved 0 of 0"}
124
+ </p>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ if (phase === "deleting" || phase === "done") {
130
+ return (
131
+ <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
132
+ <Loader2 className="size-8 animate-spin text-accent-2" />
133
+ <p className="text-sm font-medium text-fg">Deleting folder…</p>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ if (phase === "error") {
139
+ return (
140
+ <div className="space-y-4 px-5 py-4">
141
+ <Banner tone="danger" variant="soft">
142
+ {errorMessage ?? "Something went wrong. Please try again."}
143
+ </Banner>
144
+ {progress && (
145
+ <p className="text-xs text-fg-muted">
146
+ {moveProgressLabel(progress)}. Already-moved emails stay moved —
147
+ re-open delete to continue with the rest.
148
+ </p>
149
+ )}
150
+ <div className="flex justify-end">
151
+ <Button variant="secondary" size="sm" onClick={handleClose}>
152
+ Close
153
+ </Button>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ if (stage === "confirm-empty") {
160
+ return (
161
+ <>
162
+ <div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
163
+ <p>
164
+ Delete <strong className="text-fg">{name}</strong>? This folder is
165
+ empty and will be removed from the server.
166
+ </p>
167
+ </div>
168
+ <footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
169
+ <Button variant="secondary" size="sm" onClick={handleClose}>
170
+ Cancel
171
+ </Button>
172
+ <Button
173
+ variant="danger"
174
+ size="sm"
175
+ icon={<Trash2 className="size-3.5" />}
176
+ onClick={() => deleteMailbox()}
177
+ >
178
+ Delete folder
179
+ </Button>
180
+ </footer>
181
+ </>
182
+ );
183
+ }
184
+
185
+ if (stage === "choose-fate") {
186
+ return (
187
+ <>
188
+ <div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
189
+ <p>
190
+ <strong className="text-fg">{name}</strong> holds{" "}
191
+ {emailCount(folder.messageCount)}. What should happen to them?
192
+ </p>
193
+ <div className="space-y-2">
194
+ <button
195
+ type="button"
196
+ onClick={() => setStage("confirm-delete-all")}
197
+ className="flex w-full items-center gap-3 rounded-sm border border-line px-3 py-2.5 text-left hover:bg-surface-raised"
198
+ >
199
+ <Trash2 className="size-4 shrink-0 text-danger" />
200
+ <span className="flex-1">
201
+ <span className="block text-sm font-medium text-fg">
202
+ Delete them with the folder
203
+ </span>
204
+ <span className="block text-xs text-fg-muted">
205
+ The emails are removed from the server and can't be
206
+ recovered.
207
+ </span>
208
+ </span>
209
+ </button>
210
+ <button
211
+ type="button"
212
+ onClick={() => setStage("pick-destination")}
213
+ className="flex w-full items-center gap-3 rounded-sm border border-line px-3 py-2.5 text-left hover:bg-surface-raised"
214
+ >
215
+ <FolderInput className="size-4 shrink-0 text-accent-2" />
216
+ <span className="flex-1">
217
+ <span className="block text-sm font-medium text-fg">
218
+ Move them to another folder
219
+ </span>
220
+ <span className="block text-xs text-fg-muted">
221
+ Keep the emails, then delete the emptied folder.
222
+ </span>
223
+ </span>
224
+ </button>
225
+ </div>
226
+ </div>
227
+ <footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
228
+ <Button variant="secondary" size="sm" onClick={handleClose}>
229
+ Cancel
230
+ </Button>
231
+ </footer>
232
+ </>
233
+ );
234
+ }
235
+
236
+ if (stage === "confirm-delete-all") {
237
+ return (
238
+ <>
239
+ <div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
240
+ <p>
241
+ Delete <strong className="text-fg">{name}</strong> and its{" "}
242
+ {emailCount(folder.messageCount)}? The messages are removed from
243
+ the server with the folder and can't be recovered.
244
+ </p>
245
+ </div>
246
+ <footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
247
+ <Button
248
+ variant="secondary"
249
+ size="sm"
250
+ onClick={() => setStage("choose-fate")}
251
+ >
252
+ Back
253
+ </Button>
254
+ <Button
255
+ variant="danger"
256
+ size="sm"
257
+ icon={<Trash2 className="size-3.5" />}
258
+ onClick={() => deleteMailbox()}
259
+ >
260
+ Delete folder and emails
261
+ </Button>
262
+ </footer>
263
+ </>
264
+ );
265
+ }
266
+
267
+ return (
268
+ <div className="flex h-80 flex-col">
269
+ <p className="px-5 pt-4 text-sm text-fg-muted">
270
+ Move the {emailCount(folder.messageCount)} in{" "}
271
+ <strong className="text-fg">{name}</strong> to:
272
+ </p>
273
+ <div className="min-h-0 flex-1 px-2 py-2">
274
+ <MoveMailboxPicker
275
+ mailboxes={destinations}
276
+ onSelect={(destinationMailboxId) =>
277
+ moveThenDelete(destinationMailboxId)
278
+ }
279
+ onCreateFolder={handleCreateFolder}
280
+ onCancel={() => setStage("choose-fate")}
281
+ labels={{
282
+ searchPlaceholder: "Move emails to…",
283
+ optionLabel: (label) => `Move to ${label}`,
284
+ createLabel: (query) => `Create "${query}"`,
285
+ }}
286
+ />
287
+ </div>
288
+ </div>
289
+ );
290
+ })();
291
+
292
+ return (
293
+ <Dialog open={open} onClose={handleClose} title={title}>
294
+ <header className="flex items-center gap-2 border-b border-line px-5 py-3">
295
+ <AlertTriangle className="size-4 shrink-0 text-danger" />
296
+ <span className="flex-1 text-sm font-semibold text-fg">{title}</span>
297
+ <Button
298
+ variant="ghost"
299
+ size="sm"
300
+ icon={<X className="size-3.5" />}
301
+ onClick={handleClose}
302
+ aria-label="Cancel"
303
+ />
304
+ </header>
305
+ {body}
306
+ </Dialog>
307
+ );
308
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "@remit/ui";
12
12
  import { CheckCircle2, Loader2 } from "lucide-react";
13
13
  import { useMemo, useRef, useState } from "react";
14
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
14
15
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
15
16
  import { useRulePreview } from "@/hooks/useRulePreview";
16
17
  import { useUpdateFilter } from "@/hooks/useUpdateFilter";
@@ -67,6 +68,7 @@ export function FilterEditor({
67
68
  const preview = useRulePreview(accountId, rulePredicate(rule));
68
69
  const update = useUpdateFilter(accountId, filter.filterId);
69
70
  const organizeJob = useOrganizeJob(accountId);
71
+ const { createFolder } = useCreateMailbox(accountId);
70
72
 
71
73
  const startAddClause = () =>
72
74
  setClauseEdit({
@@ -203,6 +205,7 @@ export function FilterEditor({
203
205
  onCancelClause={() => setClauseEdit(undefined)}
204
206
  onChangeMatchOperator={changeMatchOperator}
205
207
  onChangeMove={changeMove}
208
+ onCreateFolder={createFolder}
206
209
  onChangeName={changeName}
207
210
  onCommit={commit}
208
211
  onCancel={onClose}
@@ -0,0 +1,131 @@
1
+ /**
2
+ * useCreateMailbox.createFolder — the shared create seam the kit surfaces call.
3
+ * It validates the typed name against the account's current folders with the
4
+ * same IMAP-aware rules the settings form uses, and rejects with the
5
+ * human-readable reason before any request. The mailbox list is seeded into the
6
+ * query cache the hook reads, so validation runs against real paths.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { afterEach, describe, it } from "node:test";
11
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
12
+ import type {
13
+ MailboxOperationsListMailboxesResponse,
14
+ RemitImapMailboxResponse,
15
+ } from "@remit/api-http-client/types.gen.ts";
16
+ import type { FolderOption } from "@remit/ui";
17
+ import { act, createElement } from "react";
18
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
19
+ import { type HttpMock, mockFetch } from "../test-support/http";
20
+ import { useCreateMailbox } from "./useCreateMailbox";
21
+
22
+ const ACCOUNT = "acc-1";
23
+
24
+ let harness: DomHarness | undefined;
25
+ let http: HttpMock | undefined;
26
+ let createFolder: ((name: string) => Promise<FolderOption>) | undefined;
27
+
28
+ afterEach(() => {
29
+ harness?.close();
30
+ harness = undefined;
31
+ http?.restore();
32
+ http = undefined;
33
+ createFolder = undefined;
34
+ });
35
+
36
+ const mailbox = (
37
+ fullPath: string,
38
+ delimiter: string,
39
+ ): RemitImapMailboxResponse =>
40
+ ({
41
+ mailboxId: `mbx-${fullPath}`,
42
+ accountId: ACCOUNT,
43
+ fullPath,
44
+ hierarchyDelimiter: delimiter,
45
+ }) as RemitImapMailboxResponse;
46
+
47
+ function Probe() {
48
+ createFolder = useCreateMailbox(ACCOUNT).createFolder;
49
+ return null;
50
+ }
51
+
52
+ const mount = (items: RemitImapMailboxResponse[]) => {
53
+ http = mockFetch((call) => {
54
+ if (call.method === "POST") {
55
+ const body = call.body as { fullPath: string };
56
+ return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
57
+ }
58
+ return { items };
59
+ });
60
+ harness = createDomHarness();
61
+ harness.queryClient.setQueryData<MailboxOperationsListMailboxesResponse>(
62
+ mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT } }),
63
+ { items },
64
+ );
65
+ harness.renderApp(createElement(Probe));
66
+ };
67
+
68
+ const reject = async (name: string): Promise<unknown> => {
69
+ const run = createFolder;
70
+ if (!run) throw new Error("createFolder is not mounted");
71
+ let caught: unknown;
72
+ await act(async () => {
73
+ caught = await run(name).then(
74
+ () => undefined,
75
+ (error: unknown) => error,
76
+ );
77
+ });
78
+ return caught;
79
+ };
80
+
81
+ const postCount = (): number =>
82
+ (http?.calls ?? []).filter((call) => call.method === "POST").length;
83
+
84
+ describe("useCreateMailbox.createFolder validation", () => {
85
+ it("rejects a name containing the account delimiter before any request", async () => {
86
+ mount([mailbox("INBOX", "/")]);
87
+ const error = await reject("Work/Receipts");
88
+ assert.ok(error instanceof Error);
89
+ assert.match(error.message, /can't contain/);
90
+ assert.equal(postCount(), 0);
91
+ });
92
+
93
+ it("honours a non-slash account delimiter", async () => {
94
+ mount([mailbox("INBOX", ".")]);
95
+ const error = await reject("Work.Receipts");
96
+ assert.ok(error instanceof Error);
97
+ assert.match(error.message, /can't contain/);
98
+ assert.equal(postCount(), 0);
99
+ });
100
+
101
+ it("rejects a collision with an existing top-level fullPath before any request", async () => {
102
+ mount([mailbox("INBOX", "/"), mailbox("Work", "/")]);
103
+ const error = await reject("Work");
104
+ assert.ok(error instanceof Error);
105
+ assert.match(error.message, /already exists/);
106
+ assert.equal(postCount(), 0);
107
+ });
108
+
109
+ it("treats INBOX case-insensitively for collisions", async () => {
110
+ mount([mailbox("INBOX", "/")]);
111
+ const error = await reject("inbox");
112
+ assert.ok(error instanceof Error);
113
+ assert.match(error.message, /already exists/);
114
+ assert.equal(postCount(), 0);
115
+ });
116
+
117
+ it("passes a valid name through to the create request and maps the result", async () => {
118
+ mount([mailbox("INBOX", "/")]);
119
+ let result: FolderOption | undefined;
120
+ await act(async () => {
121
+ result = await createFolder?.("Taxes");
122
+ });
123
+ const posts = (http?.calls ?? []).filter((call) => call.method === "POST");
124
+ assert.equal(posts.length, 1);
125
+ assert.deepEqual(posts[0].body, {
126
+ fullPath: "Taxes",
127
+ namespaceType: "personal",
128
+ });
129
+ assert.equal(result?.label, "Taxes");
130
+ });
131
+ });
@@ -0,0 +1,65 @@
1
+ import {
2
+ mailboxOperationsCreateMailboxMutation,
3
+ mailboxOperationsListMailboxesOptions,
4
+ mailboxOperationsListMailboxesQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type { FolderOption } from "@remit/ui";
7
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ import { useCallback } from "react";
9
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
10
+ import { composeFolderPath, validateNewFolderName } from "@/lib/new-folder";
11
+
12
+ /**
13
+ * Creates a mailbox for an account and refreshes the folder list on success.
14
+ * The backend creates the row with a pending sync status and queues the IMAP
15
+ * create, so the folder is usable as a move destination immediately.
16
+ *
17
+ * `createFolder` takes a leaf name, validates it against the account's current
18
+ * folders with the same IMAP-aware rules the settings form uses (non-empty, no
19
+ * hierarchy delimiter, no collision — INBOX case-insensitive), and rejects with
20
+ * the human-readable reason before any request. The kit surfaces that pick the
21
+ * result render that rejection inline. `mutation` is exposed for callers that
22
+ * drive their own form state and error surface.
23
+ */
24
+ export function useCreateMailbox(accountId: string) {
25
+ const queryClient = useQueryClient();
26
+
27
+ const { data } = useQuery(
28
+ mailboxOperationsListMailboxesOptions({ path: { accountId } }),
29
+ );
30
+
31
+ const mutation = useMutation({
32
+ ...mailboxOperationsCreateMailboxMutation(),
33
+ onSuccess: () => {
34
+ queryClient.invalidateQueries({
35
+ queryKey: mailboxOperationsListMailboxesQueryKey({
36
+ path: { accountId },
37
+ }),
38
+ });
39
+ },
40
+ });
41
+
42
+ const createFolder = useCallback(
43
+ async (name: string): Promise<FolderOption> => {
44
+ const items = data?.items ?? [];
45
+ const delimiter = items[0]?.hierarchyDelimiter ?? "/";
46
+ const problem = validateNewFolderName({
47
+ name,
48
+ delimiter,
49
+ existingPaths: items.map((item) => item.fullPath),
50
+ });
51
+ if (problem) throw new Error(problem);
52
+ const mailbox = await mutation.mutateAsync({
53
+ path: { accountId },
54
+ body: { fullPath: composeFolderPath(name), namespaceType: "personal" },
55
+ });
56
+ return {
57
+ id: mailbox.mailboxId,
58
+ label: getMailboxDisplayName(mailbox.fullPath),
59
+ };
60
+ },
61
+ [mutation, accountId, data],
62
+ );
63
+
64
+ return { createFolder, mutation };
65
+ }