@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,58 @@
1
+ export interface FolderTarget {
2
+ fullPath: string;
3
+ hierarchyDelimiter: string;
4
+ }
5
+
6
+ /**
7
+ * The full path of a new folder: the leaf name on its own for a root folder, or
8
+ * the parent's path joined to the name by the parent's hierarchy delimiter.
9
+ */
10
+ export function composeFolderPath(name: string, parent?: FolderTarget): string {
11
+ const trimmed = name.trim();
12
+ if (!parent) return trimmed;
13
+ return `${parent.fullPath}${parent.hierarchyDelimiter}${trimmed}`;
14
+ }
15
+
16
+ /**
17
+ * IMAP treats the INBOX root case-insensitively; every other path segment is
18
+ * exact. Canonicalizing only a leading `inbox` segment lets the collision check
19
+ * match `inbox/x` against `INBOX/x` without folding case anywhere else.
20
+ */
21
+ const canonicalizePath = (path: string, delimiter: string): string => {
22
+ const parts = path.split(delimiter);
23
+ if (parts[0]?.toLowerCase() === "inbox") parts[0] = "INBOX";
24
+ return parts.join(delimiter);
25
+ };
26
+
27
+ /**
28
+ * Why the folder can't be created, or `undefined` when the name is valid. A
29
+ * name must be non-empty, must not contain the hierarchy delimiter (nesting is
30
+ * expressed by the parent select, not by typing a path), and must not collide
31
+ * with an existing folder — INBOX compared case-insensitively, everything else
32
+ * exactly.
33
+ */
34
+ export function validateNewFolderName({
35
+ name,
36
+ delimiter,
37
+ parent,
38
+ existingPaths,
39
+ }: {
40
+ name: string;
41
+ delimiter: string;
42
+ parent?: FolderTarget;
43
+ existingPaths: readonly string[];
44
+ }): string | undefined {
45
+ const trimmed = name.trim();
46
+ if (trimmed === "") return "Enter a folder name.";
47
+ if (trimmed.includes(delimiter))
48
+ return `A folder name can't contain "${delimiter}".`;
49
+ const candidate = canonicalizePath(
50
+ composeFolderPath(trimmed, parent),
51
+ delimiter,
52
+ );
53
+ const collides = existingPaths.some(
54
+ (path) => canonicalizePath(path, delimiter) === candidate,
55
+ );
56
+ if (collides) return "A folder with that name already exists.";
57
+ return undefined;
58
+ }
@@ -6,22 +6,38 @@ import {
6
6
  mailboxOperationsListMailboxesOptions,
7
7
  mailboxOperationsListMailboxesQueryKey,
8
8
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
9
- import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
9
+ import type {
10
+ RemitImapAccountResponse,
11
+ RemitImapMailboxResponse,
12
+ } from "@remit/api-http-client/types.gen.ts";
10
13
  import {
11
14
  Banner,
15
+ Button,
12
16
  type CandidateFolder,
13
17
  type FolderRole,
18
+ Input,
14
19
  RoleAppointmentList,
20
+ Select,
15
21
  SettingsShell,
16
22
  } from "@remit/ui";
17
23
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
18
24
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
25
+ import { Trash2 } from "lucide-react";
19
26
  import { useState } from "react";
27
+ import { DeleteFolderDialog } from "@/components/settings/DeleteFolderDialog";
20
28
  import { ErrorState } from "@/components/ui/ErrorState";
29
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
30
+ import { guardFolderDeletion } from "@/lib/delete-folder";
21
31
  import {
22
32
  CANONICAL_TO_NAV_ROLE,
33
+ getMailboxDisplayName,
23
34
  NAV_ROLE_TO_CANONICAL,
24
35
  } from "@/lib/folder-roles";
36
+ import {
37
+ composeFolderPath,
38
+ type FolderTarget,
39
+ validateNewFolderName,
40
+ } from "@/lib/new-folder";
25
41
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
26
42
 
27
43
  export const Route = createFileRoute("/settings/folders")({
@@ -48,6 +64,121 @@ const foldersHelp = (
48
64
  </div>
49
65
  );
50
66
 
67
+ /**
68
+ * Create a folder for one account. A name, an optional parent to nest under,
69
+ * and a create button. The new folder is queued on the server with a pending
70
+ * sync and appears in the list below once it refetches.
71
+ */
72
+ function NewFolder({
73
+ accountId,
74
+ mailboxes,
75
+ }: {
76
+ accountId: string;
77
+ mailboxes: RemitImapMailboxResponse[];
78
+ }) {
79
+ const { mutation } = useCreateMailbox(accountId);
80
+ const [name, setName] = useState("");
81
+ const [parentId, setParentId] = useState("");
82
+ const [validationError, setValidationError] = useState<string>();
83
+
84
+ const accountDelimiter = mailboxes[0]?.hierarchyDelimiter ?? "/";
85
+ const parentMailbox = mailboxes.find((box) => box.mailboxId === parentId);
86
+ const parent: FolderTarget | undefined = parentMailbox
87
+ ? {
88
+ fullPath: parentMailbox.fullPath,
89
+ hierarchyDelimiter: parentMailbox.hierarchyDelimiter,
90
+ }
91
+ : undefined;
92
+ const delimiter = parent?.hierarchyDelimiter ?? accountDelimiter;
93
+
94
+ const handleCreate = () => {
95
+ const problem = validateNewFolderName({
96
+ name,
97
+ delimiter,
98
+ parent,
99
+ existingPaths: mailboxes.map((box) => box.fullPath),
100
+ });
101
+ if (problem) {
102
+ setValidationError(problem);
103
+ return;
104
+ }
105
+ setValidationError(undefined);
106
+ mutation.mutate(
107
+ {
108
+ path: { accountId },
109
+ body: {
110
+ fullPath: composeFolderPath(name, parent),
111
+ namespaceType: "personal",
112
+ },
113
+ },
114
+ {
115
+ onSuccess: () => {
116
+ setName("");
117
+ setParentId("");
118
+ },
119
+ },
120
+ );
121
+ };
122
+
123
+ return (
124
+ <div className="space-y-2 rounded-sm border border-line bg-surface p-3">
125
+ <p className="text-sm font-medium text-fg">New folder</p>
126
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
127
+ <div className="flex-1 space-y-1">
128
+ <span className="text-xs text-fg-muted">Name</span>
129
+ <Input
130
+ value={name}
131
+ onChange={(event) => {
132
+ setName(event.target.value);
133
+ if (validationError) setValidationError(undefined);
134
+ }}
135
+ placeholder="e.g. Receipts"
136
+ aria-label="Folder name"
137
+ onKeyDown={(event) => {
138
+ if (event.key === "Enter") {
139
+ event.preventDefault();
140
+ handleCreate();
141
+ }
142
+ }}
143
+ />
144
+ </div>
145
+ <div className="flex-1 space-y-1">
146
+ <span className="text-xs text-fg-muted">Inside (optional)</span>
147
+ <Select
148
+ value={parentId}
149
+ onChange={(event) => setParentId(event.target.value)}
150
+ aria-label="Parent folder"
151
+ >
152
+ <option value="">No parent — top level</option>
153
+ {mailboxes.map((box) => (
154
+ <option key={box.mailboxId} value={box.mailboxId}>
155
+ {box.fullPath}
156
+ </option>
157
+ ))}
158
+ </Select>
159
+ </div>
160
+ <Button
161
+ variant="primary"
162
+ onClick={handleCreate}
163
+ disabled={mutation.isPending || name.trim() === ""}
164
+ >
165
+ {mutation.isPending ? "Creating…" : "Create folder"}
166
+ </Button>
167
+ </div>
168
+ {validationError && (
169
+ <p className="text-xs text-danger" role="alert">
170
+ {validationError}
171
+ </p>
172
+ )}
173
+ {mutation.isError && (
174
+ <Banner tone="danger" variant="soft">
175
+ Couldn't create that folder. Please try again.
176
+ </Banner>
177
+ )}
178
+ </div>
179
+ );
180
+ }
181
+
51
182
  /** One account's folder roles, fed to the kit list. Owns its own queries + mutations. */
52
183
  function AccountFolderRoles({
53
184
  account,
@@ -81,6 +212,8 @@ function AccountFolderRoles({
81
212
  },
82
213
  });
83
214
 
215
+ const [deletingMailboxId, setDeletingMailboxId] = useState<string>();
216
+
84
217
  const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
85
218
  appointMutation.mutate({
86
219
  path: { accountId, role: NAV_ROLE_TO_CANONICAL[role] },
@@ -154,6 +287,55 @@ function AccountFolderRoles({
154
287
  onAppoint={handleAppoint}
155
288
  onRename={handleRename}
156
289
  />
290
+ <NewFolder accountId={accountId} mailboxes={data.items} />
291
+ <ul className="space-y-1" aria-label={`All folders for ${account.email}`}>
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
+ })}
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
+ })()}
157
339
  </div>
158
340
  );
159
341
  }