@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 +1 -1
- package/src/components/mail/MoveToTrigger.tsx +12 -0
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +3 -0
- package/src/components/settings/DeleteFolderDialog.render.test.ts +467 -0
- package/src/components/settings/DeleteFolderDialog.tsx +308 -0
- package/src/components/settings/FilterEditor.tsx +3 -0
- package/src/hooks/useCreateMailbox.render.test.ts +131 -0
- package/src/hooks/useCreateMailbox.ts +65 -0
- package/src/hooks/useDeleteFolder.ts +267 -0
- package/src/lib/delete-folder.test.ts +186 -0
- package/src/lib/delete-folder.ts +151 -0
- package/src/lib/new-folder.test.ts +109 -0
- package/src/lib/new-folder.ts +58 -0
- package/src/routes/settings/folders.tsx +183 -1
|
@@ -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 {
|
|
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
|
}
|