@remit/web-client 0.0.100 → 0.0.102
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.render.test.ts +39 -10
- package/src/components/mail/MoveToTrigger.tsx +58 -41
- package/src/components/mail/SelectionWizardHost.tsx +7 -6
- package/src/components/mail/SpamRescue.tsx +4 -3
- package/src/components/settings/DeleteFolderDialog.render.test.ts +49 -11
- package/src/components/settings/DeleteFolderDialog.tsx +21 -26
- package/src/hooks/useCreateMailbox.ts +44 -9
- package/src/lib/move-options.test.ts +20 -6
- package/src/lib/move-options.ts +13 -4
- package/src/routes/settings/folders.tsx +121 -188
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.102",
|
|
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": {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The move-to-folder picker. It only fetches folders once it is opened, it
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* opens on the account's top level with nested folders behind the one holding
|
|
4
|
+
* them, it marks the folder the messages are already in rather than offering it
|
|
5
|
+
* as a destination, and on desktop Escape or a click outside puts it away.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import assert from "node:assert/strict";
|
|
@@ -54,19 +55,23 @@ const mount = (
|
|
|
54
55
|
const FOLDERS = [
|
|
55
56
|
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
56
57
|
makeMailbox({ mailboxId: "mbx-work", fullPath: "Work" }),
|
|
58
|
+
makeMailbox({ mailboxId: "mbx-clients", fullPath: "Work/Clients" }),
|
|
57
59
|
makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
|
|
58
60
|
];
|
|
59
61
|
|
|
62
|
+
const rowText = (dom: DomHarness): string[] =>
|
|
63
|
+
dom.queryAll("[role=treeitem]").map((row) => row.textContent ?? "");
|
|
64
|
+
|
|
60
65
|
describe("MoveToTrigger", () => {
|
|
61
66
|
it("reports itself as collapsed until it is opened", () => {
|
|
62
67
|
const dom = mount({ mailboxes: FOLDERS });
|
|
63
68
|
const trigger = dom.byLabel("Move to folder");
|
|
64
69
|
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
65
70
|
assert.equal(trigger.getAttribute("aria-controls"), null);
|
|
66
|
-
assert.equal(dom.query('[role="
|
|
71
|
+
assert.equal(dom.query('[role="tree"], input'), null);
|
|
67
72
|
});
|
|
68
73
|
|
|
69
|
-
it("opens
|
|
74
|
+
it("opens the top level and marks the folder we are in", () => {
|
|
70
75
|
const dom = mount({ mailboxes: FOLDERS });
|
|
71
76
|
dom.click(dom.byLabel("Move to folder"));
|
|
72
77
|
|
|
@@ -74,9 +79,7 @@ describe("MoveToTrigger", () => {
|
|
|
74
79
|
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
75
80
|
"true",
|
|
76
81
|
);
|
|
77
|
-
const labels = dom
|
|
78
|
-
.queryAll("[role=option]")
|
|
79
|
-
.map((option) => option.textContent ?? "");
|
|
82
|
+
const labels = rowText(dom);
|
|
80
83
|
assert.ok(labels.some((label) => label.includes("Work")));
|
|
81
84
|
assert.ok(labels.some((label) => label.includes("Receipts")));
|
|
82
85
|
|
|
@@ -86,15 +89,32 @@ describe("MoveToTrigger", () => {
|
|
|
86
89
|
assert.ok(current, "the source folder is marked as the current one");
|
|
87
90
|
});
|
|
88
91
|
|
|
89
|
-
it("
|
|
92
|
+
it("keeps a nested folder behind the folder that holds it", () => {
|
|
93
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
94
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
95
|
+
assert.equal(
|
|
96
|
+
rowText(dom).some((label) => label.includes("Clients")),
|
|
97
|
+
false,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
dom.click(dom.byLabel("Move to Work"));
|
|
101
|
+
assert.ok(rowText(dom).some((label) => label.includes("Clients")));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("waits for the move to be confirmed, then closes itself", () => {
|
|
90
105
|
const moved: string[] = [];
|
|
91
106
|
const dom = mount({
|
|
92
107
|
mailboxes: FOLDERS,
|
|
93
108
|
onMove: (id) => moved.push(id),
|
|
94
109
|
});
|
|
95
110
|
dom.click(dom.byLabel("Move to folder"));
|
|
96
|
-
dom.click(dom.byText("[role=option]", "Work"));
|
|
97
111
|
|
|
112
|
+
// Picking a folder also opens it, so the move is a separate press —
|
|
113
|
+
// otherwise the first tap would fire before anything nested was reachable.
|
|
114
|
+
dom.click(dom.byLabel("Move to Work"));
|
|
115
|
+
assert.deepEqual(moved, []);
|
|
116
|
+
|
|
117
|
+
dom.click(dom.byText("button", "Move to Work"));
|
|
98
118
|
assert.deepEqual(moved, ["mbx-work"]);
|
|
99
119
|
assert.equal(
|
|
100
120
|
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
@@ -102,6 +122,15 @@ describe("MoveToTrigger", () => {
|
|
|
102
122
|
);
|
|
103
123
|
});
|
|
104
124
|
|
|
125
|
+
it("names the destination on the button that runs the move", () => {
|
|
126
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
127
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
128
|
+
dom.click(dom.byLabel("Move to Work"));
|
|
129
|
+
dom.click(dom.byLabel("Move to Clients"));
|
|
130
|
+
|
|
131
|
+
assert.match(dom.text(), /Move to Clients/);
|
|
132
|
+
});
|
|
133
|
+
|
|
105
134
|
it("closes on Escape and on a click outside it", () => {
|
|
106
135
|
const dom = mount({ mailboxes: FOLDERS });
|
|
107
136
|
const isOpen = () =>
|
|
@@ -144,7 +173,7 @@ describe("MoveToTrigger", () => {
|
|
|
144
173
|
|
|
145
174
|
it("asks for the folder list only once the picker is opened", () => {
|
|
146
175
|
const dom = mount();
|
|
147
|
-
assert.equal(dom.queryAll("[role=
|
|
176
|
+
assert.equal(dom.queryAll("[role=treeitem]").length, 0);
|
|
148
177
|
dom.click(dom.byLabel("Move to folder"));
|
|
149
178
|
// No cached mailboxes and no network in the test harness: the picker
|
|
150
179
|
// shows its loading state rather than an empty list of destinations.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import { type
|
|
2
|
+
import { Button, type FolderTreeNode, FolderTreePicker } from "@remit/ui";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { FolderInput } from "lucide-react";
|
|
5
5
|
import {
|
|
@@ -17,7 +17,7 @@ import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
|
|
|
17
17
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
18
18
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
19
19
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
20
|
-
import { buildMoveOptions } from "@/lib/move-options";
|
|
20
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
22
|
|
|
23
23
|
interface MoveToTriggerProps {
|
|
@@ -66,6 +66,7 @@ export const MoveToTrigger = ({
|
|
|
66
66
|
label,
|
|
67
67
|
}: MoveToTriggerProps) => {
|
|
68
68
|
const [isOpen, setIsOpen] = useState(false);
|
|
69
|
+
const [pickedId, setPickedId] = useState<string>();
|
|
69
70
|
const isDesktop = useIsDesktop();
|
|
70
71
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
71
72
|
const triggerLabel = label ?? "Move to folder";
|
|
@@ -88,9 +89,9 @@ export const MoveToTrigger = ({
|
|
|
88
89
|
enabled: isOpen,
|
|
89
90
|
});
|
|
90
91
|
const folderAppointments = useFolderAppointments(accountId);
|
|
91
|
-
const {
|
|
92
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
92
93
|
|
|
93
|
-
const options = useMemo<
|
|
94
|
+
const options = useMemo<FolderTreeNode[]>(
|
|
94
95
|
() =>
|
|
95
96
|
buildMoveOptions({
|
|
96
97
|
mailboxes: mailboxesResponse?.items ?? [],
|
|
@@ -105,22 +106,23 @@ export const MoveToTrigger = ({
|
|
|
105
106
|
translator,
|
|
106
107
|
],
|
|
107
108
|
);
|
|
109
|
+
const delimiter = folderDelimiter(mailboxesResponse?.items ?? []);
|
|
108
110
|
|
|
109
|
-
const
|
|
110
|
-
(destinationMailboxId: string) => {
|
|
111
|
-
setIsOpen(false);
|
|
112
|
-
onMove(destinationMailboxId);
|
|
113
|
-
},
|
|
114
|
-
[onMove],
|
|
115
|
-
);
|
|
111
|
+
const picked = options.find((option) => option.id === pickedId);
|
|
116
112
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
const close = useCallback(() => {
|
|
114
|
+
setIsOpen(false);
|
|
115
|
+
setPickedId(undefined);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
// Tapping a folder both picks it and opens it, so the move waits for a
|
|
119
|
+
// confirmation — otherwise the first tap would fire before the user could
|
|
120
|
+
// reach anything nested inside it.
|
|
121
|
+
const handleMove = useCallback(() => {
|
|
122
|
+
if (!picked) return;
|
|
123
|
+
close();
|
|
124
|
+
onMove(picked.id);
|
|
125
|
+
}, [picked, close, onMove]);
|
|
124
126
|
|
|
125
127
|
// Desktop popover: dismiss on outside click + Escape.
|
|
126
128
|
useEffect(() => {
|
|
@@ -130,11 +132,11 @@ export const MoveToTrigger = ({
|
|
|
130
132
|
containerRef.current &&
|
|
131
133
|
!containerRef.current.contains(event.target as Node)
|
|
132
134
|
) {
|
|
133
|
-
|
|
135
|
+
close();
|
|
134
136
|
}
|
|
135
137
|
};
|
|
136
138
|
const handleKey = (event: KeyboardEvent) => {
|
|
137
|
-
if (event.key === "Escape")
|
|
139
|
+
if (event.key === "Escape") close();
|
|
138
140
|
};
|
|
139
141
|
document.addEventListener("mousedown", handlePointer);
|
|
140
142
|
document.addEventListener("keydown", handleKey);
|
|
@@ -142,7 +144,7 @@ export const MoveToTrigger = ({
|
|
|
142
144
|
document.removeEventListener("mousedown", handlePointer);
|
|
143
145
|
document.removeEventListener("keydown", handleKey);
|
|
144
146
|
};
|
|
145
|
-
}, [isOpen, isDesktop]);
|
|
147
|
+
}, [isOpen, isDesktop, close]);
|
|
146
148
|
|
|
147
149
|
const isTriggerDisabled = disabled || !!disabledHint;
|
|
148
150
|
|
|
@@ -156,10 +158,10 @@ export const MoveToTrigger = ({
|
|
|
156
158
|
}}
|
|
157
159
|
aria-label={triggerLabel}
|
|
158
160
|
// Mobile opens a vaul Drawer (modal dialog), desktop opens a
|
|
159
|
-
// non-modal popover whose only content is the
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
aria-haspopup={isDesktop ? "
|
|
161
|
+
// non-modal popover whose only content is the folder tree. Reflect
|
|
162
|
+
// each surface accurately so screen readers announce the right
|
|
163
|
+
// structure.
|
|
164
|
+
aria-haspopup={isDesktop ? "tree" : "dialog"}
|
|
163
165
|
aria-expanded={isOpen}
|
|
164
166
|
aria-controls={isOpen ? popoverId : undefined}
|
|
165
167
|
title={disabledHint}
|
|
@@ -182,33 +184,44 @@ export const MoveToTrigger = ({
|
|
|
182
184
|
/>
|
|
183
185
|
</div>
|
|
184
186
|
) : (
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
<FolderTreePicker
|
|
188
|
+
folders={options}
|
|
189
|
+
selectedId={pickedId}
|
|
190
|
+
delimiter={delimiter}
|
|
191
|
+
onSelect={setPickedId}
|
|
192
|
+
onCreateFolder={createFolderIn}
|
|
193
|
+
onCancel={close}
|
|
191
194
|
labels={{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
defaultValue: "Move to…",
|
|
195
|
+
filterPlaceholder: t("move_picker_filter_placeholder", {
|
|
196
|
+
defaultValue: "Filter folders…",
|
|
195
197
|
}),
|
|
196
|
-
|
|
198
|
+
filterAriaLabel: t("move_picker_filter_label", {
|
|
197
199
|
defaultValue: "Filter folders",
|
|
198
200
|
}),
|
|
199
|
-
optionLabel: (folderLabel) => `Move to ${folderLabel}`,
|
|
200
|
-
currentSuffix: "(current folder)",
|
|
201
|
-
currentTag: "current",
|
|
202
|
-
emptyMessage: (query) => `No folders match "${query}"`,
|
|
203
201
|
}}
|
|
204
202
|
/>
|
|
205
203
|
);
|
|
206
204
|
|
|
205
|
+
const confirmBar = picked && (
|
|
206
|
+
<div className="shrink-0 border-t border-line p-2">
|
|
207
|
+
<Button
|
|
208
|
+
variant="primary"
|
|
209
|
+
onClick={handleMove}
|
|
210
|
+
className="h-11 w-full font-semibold"
|
|
211
|
+
>
|
|
212
|
+
{`Move to ${picked.label}`}
|
|
213
|
+
</Button>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
|
|
207
217
|
if (!isDesktop) {
|
|
208
218
|
return (
|
|
209
219
|
<>
|
|
210
220
|
{TriggerButton}
|
|
211
|
-
<Drawer.Root
|
|
221
|
+
<Drawer.Root
|
|
222
|
+
open={isOpen}
|
|
223
|
+
onOpenChange={(next) => (next ? setIsOpen(true) : close())}
|
|
224
|
+
>
|
|
212
225
|
<Drawer.Portal>
|
|
213
226
|
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
|
|
214
227
|
<Drawer.Content
|
|
@@ -220,7 +233,10 @@ export const MoveToTrigger = ({
|
|
|
220
233
|
<Drawer.Title className="px-4 py-2 text-base font-semibold border-b border-line">
|
|
221
234
|
Move to folder
|
|
222
235
|
</Drawer.Title>
|
|
223
|
-
<div className="flex-1 overflow-hidden">
|
|
236
|
+
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
237
|
+
{pickerBody}
|
|
238
|
+
</div>
|
|
239
|
+
{confirmBar}
|
|
224
240
|
</Drawer.Content>
|
|
225
241
|
</Drawer.Portal>
|
|
226
242
|
</Drawer.Root>
|
|
@@ -240,6 +256,7 @@ export const MoveToTrigger = ({
|
|
|
240
256
|
)}
|
|
241
257
|
>
|
|
242
258
|
{pickerBody}
|
|
259
|
+
{confirmBar}
|
|
243
260
|
</div>
|
|
244
261
|
)}
|
|
245
262
|
</div>
|
|
@@ -7,10 +7,10 @@ import {
|
|
|
7
7
|
derivePropertyClauses,
|
|
8
8
|
deriveSenderClauses,
|
|
9
9
|
dominantSender,
|
|
10
|
+
type FolderTreeNode,
|
|
10
11
|
type MatchCount,
|
|
11
12
|
type MatchDoor,
|
|
12
13
|
type MatchMode,
|
|
13
|
-
type MoveMailboxOption,
|
|
14
14
|
type RuleClause,
|
|
15
15
|
type RunState,
|
|
16
16
|
type SearchConversion,
|
|
@@ -50,7 +50,7 @@ import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
|
50
50
|
import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
51
51
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
52
52
|
import { useMailContext } from "@/lib/mail-context";
|
|
53
|
-
import { buildMoveOptions } from "@/lib/move-options";
|
|
53
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
54
54
|
import {
|
|
55
55
|
buildWizardDraft,
|
|
56
56
|
canBackApplyDraft,
|
|
@@ -388,7 +388,7 @@ function SelectionWizardSession({
|
|
|
388
388
|
});
|
|
389
389
|
const folderAppointments = useFolderAppointments(accountId);
|
|
390
390
|
const translator = useFolderLabelTranslator();
|
|
391
|
-
const mailboxes = useMemo<
|
|
391
|
+
const mailboxes = useMemo<FolderTreeNode[]>(
|
|
392
392
|
() =>
|
|
393
393
|
buildMoveOptions({
|
|
394
394
|
mailboxes: mailboxesData?.items ?? [],
|
|
@@ -398,7 +398,7 @@ function SelectionWizardSession({
|
|
|
398
398
|
}),
|
|
399
399
|
[mailboxesData?.items, folderAppointments, mailboxId, translator],
|
|
400
400
|
);
|
|
401
|
-
const {
|
|
401
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
402
402
|
|
|
403
403
|
const folderLabel = mailboxes.find(
|
|
404
404
|
(mailbox) => mailbox.id === draft.moveMailboxId,
|
|
@@ -946,11 +946,12 @@ function SelectionWizardSession({
|
|
|
946
946
|
sample: { ...sample, label: "What this matches" },
|
|
947
947
|
}}
|
|
948
948
|
folder={{
|
|
949
|
-
mailboxes,
|
|
949
|
+
folders: mailboxes,
|
|
950
950
|
mailboxId: named.moveMailboxId,
|
|
951
|
+
delimiter: folderDelimiter(mailboxesData?.items ?? []),
|
|
951
952
|
onSelect: (moveMailboxId) =>
|
|
952
953
|
setDraft((held) => ({ ...held, moveMailboxId })),
|
|
953
|
-
onCreateFolder: accountId ?
|
|
954
|
+
onCreateFolder: accountId ? createFolderIn : undefined,
|
|
954
955
|
restriction: folderRestriction,
|
|
955
956
|
}}
|
|
956
957
|
rule={{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
2
|
import {
|
|
3
|
-
type
|
|
3
|
+
type FolderTreeNode,
|
|
4
4
|
RescueBanner,
|
|
5
5
|
type RescueCandidate,
|
|
6
6
|
RescueFromSpamFlow,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
useInboxMailbox,
|
|
20
20
|
} from "@/hooks/useArchiveMailbox";
|
|
21
21
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
22
|
-
import { buildMoveOptions } from "@/lib/move-options";
|
|
22
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
23
23
|
import {
|
|
24
24
|
recordRescueCandidatesSurfaced,
|
|
25
25
|
recordRescueCommitted,
|
|
@@ -60,7 +60,7 @@ export function SpamRescue({
|
|
|
60
60
|
staleTime: Infinity,
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
const folders = useMemo<
|
|
63
|
+
const folders = useMemo<FolderTreeNode[]>(
|
|
64
64
|
() =>
|
|
65
65
|
buildMoveOptions({
|
|
66
66
|
mailboxes: mailboxesResponse?.items ?? [],
|
|
@@ -117,6 +117,7 @@ export function SpamRescue({
|
|
|
117
117
|
candidates={candidates}
|
|
118
118
|
defaultDestinationId={defaultDestinationId}
|
|
119
119
|
availableFolders={folders}
|
|
120
|
+
delimiter={folderDelimiter(mailboxesResponse?.items ?? [])}
|
|
120
121
|
onConfirmMove={handleConfirmMove}
|
|
121
122
|
onCancel={() => setOpen(false)}
|
|
122
123
|
/>
|
|
@@ -5,8 +5,10 @@ import type {
|
|
|
5
5
|
RemitImapMailboxResponse,
|
|
6
6
|
} from "@remit/api-http-client/types.gen.ts";
|
|
7
7
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
8
|
+
import i18next from "i18next";
|
|
8
9
|
import React, { act, createElement } from "react";
|
|
9
10
|
import { createRoot, type Root } from "react-dom/client";
|
|
11
|
+
import { I18nextProvider, initReactI18next } from "react-i18next";
|
|
10
12
|
import { DeleteFolderDialog } from "./DeleteFolderDialog";
|
|
11
13
|
|
|
12
14
|
// remit-ui's `.tsx` is transpiled with the classic JSX runtime, which
|
|
@@ -48,6 +50,18 @@ const appointments: RemitImapFolderAppointment[] = [
|
|
|
48
50
|
{ role: "Archive", mailboxId: "archive" },
|
|
49
51
|
];
|
|
50
52
|
|
|
53
|
+
// The role labels the app resolves through i18n, so a folder appointed to a
|
|
54
|
+
// role reads as that role rather than as the provider's own leaf.
|
|
55
|
+
const i18n = i18next.createInstance();
|
|
56
|
+
i18n.use(initReactI18next).init({
|
|
57
|
+
lng: "en",
|
|
58
|
+
ns: ["mail"],
|
|
59
|
+
defaultNS: "mail",
|
|
60
|
+
resources: {
|
|
61
|
+
en: { mail: { sidebar: { trash: "Trash", archive: "Archive" } } },
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
51
65
|
let container: HTMLElement;
|
|
52
66
|
let root: Root;
|
|
53
67
|
const originalFetch = globalThis.fetch;
|
|
@@ -98,20 +112,26 @@ const render = (props: {
|
|
|
98
112
|
open: boolean;
|
|
99
113
|
folder: RemitImapMailboxResponse;
|
|
100
114
|
onClose?: () => void;
|
|
115
|
+
folderAppointments?: RemitImapFolderAppointment[];
|
|
116
|
+
allMailboxes?: RemitImapMailboxResponse[];
|
|
101
117
|
}) => {
|
|
102
118
|
act(() => {
|
|
103
119
|
root.render(
|
|
104
120
|
createElement(
|
|
105
|
-
|
|
106
|
-
{
|
|
107
|
-
createElement(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
I18nextProvider,
|
|
122
|
+
{ i18n },
|
|
123
|
+
createElement(
|
|
124
|
+
QueryClientProvider,
|
|
125
|
+
{ client: new QueryClient() },
|
|
126
|
+
createElement(DeleteFolderDialog, {
|
|
127
|
+
open: props.open,
|
|
128
|
+
accountId: "acc-1",
|
|
129
|
+
folder: props.folder,
|
|
130
|
+
mailboxes: props.allMailboxes ?? mailboxes,
|
|
131
|
+
appointments: props.folderAppointments ?? appointments,
|
|
132
|
+
onClose: props.onClose ?? (() => undefined),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
115
135
|
) as never,
|
|
116
136
|
);
|
|
117
137
|
});
|
|
@@ -158,6 +178,24 @@ describe("DeleteFolderDialog", () => {
|
|
|
158
178
|
assert.match(container.textContent ?? "", /What should happen to them/);
|
|
159
179
|
});
|
|
160
180
|
|
|
181
|
+
it("names the folder by its role, never by the provider's leaf", () => {
|
|
182
|
+
const trash = mailbox({
|
|
183
|
+
mailboxId: "trash",
|
|
184
|
+
fullPath: "Deleted Messages",
|
|
185
|
+
});
|
|
186
|
+
render({
|
|
187
|
+
open: true,
|
|
188
|
+
folder: trash,
|
|
189
|
+
allMailboxes: [...mailboxes, trash],
|
|
190
|
+
folderAppointments: [
|
|
191
|
+
...appointments,
|
|
192
|
+
{ role: "Trash", mailboxId: "trash" },
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
assert.match(container.textContent ?? "", /Delete Trash/);
|
|
196
|
+
assert.doesNotMatch(container.textContent ?? "", /Deleted Messages/);
|
|
197
|
+
});
|
|
198
|
+
|
|
161
199
|
it("offers a destination picker that excludes the folder being deleted", () => {
|
|
162
200
|
render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
|
|
163
201
|
act(() => buttonByText(/Move them to another folder/)?.click());
|
|
@@ -165,7 +203,7 @@ describe("DeleteFolderDialog", () => {
|
|
|
165
203
|
'input[aria-label="Filter folders"]',
|
|
166
204
|
);
|
|
167
205
|
assert.ok(search, "the move picker is shown");
|
|
168
|
-
const options = Array.from(container.querySelectorAll('[role="
|
|
206
|
+
const options = Array.from(container.querySelectorAll('[role="treeitem"]'))
|
|
169
207
|
.map((o) => o.textContent ?? "")
|
|
170
208
|
.join("|");
|
|
171
209
|
assert.doesNotMatch(options, /Receipts/);
|
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
Banner,
|
|
7
7
|
Button,
|
|
8
8
|
Dialog,
|
|
9
|
-
type
|
|
10
|
-
|
|
9
|
+
type FolderTreeNode,
|
|
10
|
+
FolderTreePicker,
|
|
11
11
|
} from "@remit/ui";
|
|
12
12
|
import { AlertTriangle, FolderInput, Loader2, Trash2, X } from "lucide-react";
|
|
13
13
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
@@ -15,7 +15,7 @@ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
|
15
15
|
import { useDeleteFolder } from "@/hooks/useDeleteFolder";
|
|
16
16
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
17
17
|
import { initialStage, moveProgressLabel } from "@/lib/delete-folder";
|
|
18
|
-
import {
|
|
18
|
+
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
19
19
|
import { buildMoveOptions } from "@/lib/move-options";
|
|
20
20
|
|
|
21
21
|
interface DeleteFolderDialogProps {
|
|
@@ -33,9 +33,6 @@ type FateStage =
|
|
|
33
33
|
| "confirm-delete-all"
|
|
34
34
|
| "pick-destination";
|
|
35
35
|
|
|
36
|
-
const folderLabel = (folder: RemitImapMailboxResponse): string =>
|
|
37
|
-
folder.displayNameOverride?.trim() || getMailboxDisplayName(folder.fullPath);
|
|
38
|
-
|
|
39
36
|
const emailCount = (count: number): string =>
|
|
40
37
|
`${count} ${count === 1 ? "email" : "emails"}`;
|
|
41
38
|
|
|
@@ -57,7 +54,7 @@ export function DeleteFolderDialog({
|
|
|
57
54
|
const [stage, setStage] = useState<FateStage>(() =>
|
|
58
55
|
initialStage(folder.messageCount),
|
|
59
56
|
);
|
|
60
|
-
const {
|
|
57
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
61
58
|
const translator = useFolderLabelTranslator();
|
|
62
59
|
const {
|
|
63
60
|
phase,
|
|
@@ -86,7 +83,7 @@ export function DeleteFolderDialog({
|
|
|
86
83
|
onClose();
|
|
87
84
|
}, [cancel, onClose]);
|
|
88
85
|
|
|
89
|
-
const destinations = useMemo<
|
|
86
|
+
const destinations = useMemo<FolderTreeNode[]>(
|
|
90
87
|
() =>
|
|
91
88
|
buildMoveOptions({
|
|
92
89
|
mailboxes,
|
|
@@ -97,17 +94,18 @@ export function DeleteFolderDialog({
|
|
|
97
94
|
[mailboxes, appointments, folder.mailboxId, translator],
|
|
98
95
|
);
|
|
99
96
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
const name = useMemo(
|
|
98
|
+
() =>
|
|
99
|
+
labelForMailbox(
|
|
100
|
+
folder,
|
|
101
|
+
buildMailboxRoleMap(appointments).get(folder.mailboxId),
|
|
102
|
+
translator,
|
|
103
|
+
),
|
|
104
|
+
[folder, appointments, translator],
|
|
105
|
+
);
|
|
107
106
|
|
|
108
107
|
if (!open) return null;
|
|
109
108
|
|
|
110
|
-
const name = folderLabel(folder);
|
|
111
109
|
const title = `Delete ${name}`;
|
|
112
110
|
|
|
113
111
|
const body = (() => {
|
|
@@ -262,24 +260,21 @@ export function DeleteFolderDialog({
|
|
|
262
260
|
}
|
|
263
261
|
|
|
264
262
|
return (
|
|
265
|
-
<div className="flex h-
|
|
263
|
+
<div className="flex h-[26rem] flex-col">
|
|
266
264
|
<p className="px-5 pt-4 text-sm text-fg-muted">
|
|
267
265
|
Move the {emailCount(folder.messageCount)} in{" "}
|
|
268
266
|
<strong className="text-fg">{name}</strong> to:
|
|
269
267
|
</p>
|
|
270
|
-
<div className="min-h-0 flex-1
|
|
271
|
-
<
|
|
272
|
-
|
|
268
|
+
<div className="min-h-0 flex-1">
|
|
269
|
+
<FolderTreePicker
|
|
270
|
+
folders={destinations}
|
|
271
|
+
delimiter={mailboxes[0]?.hierarchyDelimiter ?? "/"}
|
|
273
272
|
onSelect={(destinationMailboxId) =>
|
|
274
273
|
moveThenDelete(destinationMailboxId)
|
|
275
274
|
}
|
|
276
|
-
onCreateFolder={
|
|
275
|
+
onCreateFolder={createFolderIn}
|
|
277
276
|
onCancel={() => setStage("choose-fate")}
|
|
278
|
-
labels={{
|
|
279
|
-
searchPlaceholder: "Move emails to…",
|
|
280
|
-
optionLabel: (label) => `Move to ${label}`,
|
|
281
|
-
createLabel: (query) => `Create "${query}"`,
|
|
282
|
-
}}
|
|
277
|
+
labels={{ filterPlaceholder: "Move emails to…" }}
|
|
283
278
|
/>
|
|
284
279
|
</div>
|
|
285
280
|
</div>
|
|
@@ -3,12 +3,16 @@ import {
|
|
|
3
3
|
mailboxOperationsListMailboxesOptions,
|
|
4
4
|
mailboxOperationsListMailboxesQueryKey,
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
-
import type {
|
|
6
|
+
import type { FolderTreeNode } from "@remit/ui";
|
|
7
7
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
8
|
import { useCallback, useRef } from "react";
|
|
9
9
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
10
10
|
import { waitForMailboxSynced } from "@/lib/mailbox-sync-wait";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
composeFolderPath,
|
|
13
|
+
type FolderTarget,
|
|
14
|
+
validateNewFolderName,
|
|
15
|
+
} from "@/lib/new-folder";
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* Creates a mailbox for an account and refreshes the folder list on success.
|
|
@@ -37,8 +41,12 @@ import { composeFolderPath, validateNewFolderName } from "@/lib/new-folder";
|
|
|
37
41
|
* `createFolder` takes an `AbortSignal` the surface aborts on unmount/cancel/
|
|
38
42
|
* close, so a folder that confirms after the surface is gone resolves nothing.
|
|
39
43
|
*
|
|
44
|
+
* `createFolderIn` is the same seam for a folder made inside another one: the
|
|
45
|
+
* parent is named by its provider path, and the name is joined to it with that
|
|
46
|
+
* parent's own hierarchy delimiter. `createFolder` is it with no parent.
|
|
47
|
+
*
|
|
40
48
|
* `mutation` is exposed for callers that drive their own form state and want the
|
|
41
|
-
* optimistic, non-waiting create
|
|
49
|
+
* optimistic, non-waiting create.
|
|
42
50
|
*/
|
|
43
51
|
export function useCreateMailbox(accountId: string | undefined) {
|
|
44
52
|
const queryClient = useQueryClient();
|
|
@@ -69,21 +77,41 @@ export function useCreateMailbox(accountId: string | undefined) {
|
|
|
69
77
|
// resumes the wait on it instead of re-creating. Cleared once it confirms.
|
|
70
78
|
const pendingByPath = useRef(new Map<string, string>());
|
|
71
79
|
|
|
72
|
-
const
|
|
73
|
-
async (
|
|
80
|
+
const createFolderIn = useCallback(
|
|
81
|
+
async (
|
|
82
|
+
name: string,
|
|
83
|
+
parentPath: string,
|
|
84
|
+
signal?: AbortSignal,
|
|
85
|
+
): Promise<FolderTreeNode> => {
|
|
74
86
|
if (!accountId) {
|
|
75
87
|
throw new Error(
|
|
76
88
|
"No account to create the folder in. Pick messages from a single account first.",
|
|
77
89
|
);
|
|
78
90
|
}
|
|
79
|
-
const
|
|
91
|
+
const items = data?.items ?? [];
|
|
92
|
+
const parentMailbox = parentPath
|
|
93
|
+
? items.find((item) => item.fullPath === parentPath)
|
|
94
|
+
: undefined;
|
|
95
|
+
if (parentPath && !parentMailbox) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Couldn't find the folder "${parentPath}" to create this one inside.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const parent: FolderTarget | undefined = parentMailbox
|
|
101
|
+
? {
|
|
102
|
+
fullPath: parentMailbox.fullPath,
|
|
103
|
+
hierarchyDelimiter: parentMailbox.hierarchyDelimiter,
|
|
104
|
+
}
|
|
105
|
+
: undefined;
|
|
106
|
+
const fullPath = composeFolderPath(name, parent);
|
|
80
107
|
let mailboxId = pendingByPath.current.get(fullPath);
|
|
81
108
|
if (!mailboxId) {
|
|
82
|
-
const
|
|
83
|
-
|
|
109
|
+
const delimiter =
|
|
110
|
+
parent?.hierarchyDelimiter ?? items[0]?.hierarchyDelimiter ?? "/";
|
|
84
111
|
const problem = validateNewFolderName({
|
|
85
112
|
name,
|
|
86
113
|
delimiter,
|
|
114
|
+
parent,
|
|
87
115
|
existingPaths: items.map((item) => item.fullPath),
|
|
88
116
|
});
|
|
89
117
|
if (problem) throw new Error(problem);
|
|
@@ -109,10 +137,17 @@ export function useCreateMailbox(accountId: string | undefined) {
|
|
|
109
137
|
return {
|
|
110
138
|
id: confirmed.mailboxId,
|
|
111
139
|
label: getMailboxDisplayName(confirmed.fullPath),
|
|
140
|
+
path: confirmed.fullPath,
|
|
112
141
|
};
|
|
113
142
|
},
|
|
114
143
|
[mutation, accountId, data, queryClient],
|
|
115
144
|
);
|
|
116
145
|
|
|
117
|
-
|
|
146
|
+
const createFolder = useCallback(
|
|
147
|
+
(name: string, signal?: AbortSignal): Promise<FolderTreeNode> =>
|
|
148
|
+
createFolderIn(name, "", signal),
|
|
149
|
+
[createFolderIn],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return { createFolder, createFolderIn, mutation };
|
|
118
153
|
}
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
RemitImapFolderAppointment,
|
|
7
7
|
RemitImapMailboxResponse,
|
|
8
8
|
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
-
import { buildMoveOptions } from "./move-options.js";
|
|
9
|
+
import { buildMoveOptions, folderDelimiter } from "./move-options.js";
|
|
10
10
|
|
|
11
11
|
const englishBundle = JSON.parse(
|
|
12
12
|
readFileSync(
|
|
@@ -100,16 +100,15 @@ describe("buildMoveOptions", () => {
|
|
|
100
100
|
assert.equal(labelOf(options, "mb-receipts"), "Receipts");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
test("the
|
|
103
|
+
test("a role label keeps the provider path it nests under", () => {
|
|
104
104
|
const options = buildMoveOptions({
|
|
105
105
|
mailboxes: applePaths,
|
|
106
106
|
folderAppointments: appleAppointments,
|
|
107
107
|
translator: translate,
|
|
108
108
|
});
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
109
|
+
const trash = options.find((option) => option.id === "mb-trash");
|
|
110
|
+
assert.equal(trash?.label, "Trash");
|
|
111
|
+
assert.equal(trash?.path, "INBOX/Deleted Messages");
|
|
113
112
|
});
|
|
114
113
|
|
|
115
114
|
test("the current mailbox is marked and nothing else is", () => {
|
|
@@ -138,3 +137,18 @@ describe("buildMoveOptions", () => {
|
|
|
138
137
|
);
|
|
139
138
|
});
|
|
140
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("folderDelimiter", () => {
|
|
142
|
+
test("takes the account's own hierarchy separator", () => {
|
|
143
|
+
assert.equal(
|
|
144
|
+
folderDelimiter([
|
|
145
|
+
make({ mailboxId: "mb-1", fullPath: "INBOX", hierarchyDelimiter: "." }),
|
|
146
|
+
]),
|
|
147
|
+
".",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("falls back to a slash when there are no mailboxes yet", () => {
|
|
152
|
+
assert.equal(folderDelimiter([]), "/");
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/lib/move-options.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
RemitImapFolderAppointment,
|
|
3
3
|
RemitImapMailboxResponse,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
-
import type {
|
|
5
|
+
import type { FolderTreeNode } from "@remit/ui";
|
|
6
6
|
import { excludeFolder } from "./delete-folder.js";
|
|
7
7
|
import { buildMailboxRoleMap, labelForMailbox } from "./folder-roles.js";
|
|
8
8
|
import { buildMoveTargets } from "./move-targets.js";
|
|
@@ -19,11 +19,20 @@ interface MoveOptionsInput {
|
|
|
19
19
|
translator?: Translator;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The account's hierarchy separator, which the tree splits paths on. Every
|
|
24
|
+
* mailbox of an account reports the same one, so the first is the account's.
|
|
25
|
+
*/
|
|
26
|
+
export const folderDelimiter = (
|
|
27
|
+
mailboxes: readonly RemitImapMailboxResponse[],
|
|
28
|
+
): string => mailboxes[0]?.hierarchyDelimiter ?? "/";
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* The single shaping of a mailbox list into Move-to picker options. Labels
|
|
24
32
|
* follow the account's role appointments (RFC 032, #976) and any
|
|
25
33
|
* `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
|
|
26
|
-
* provider's leaf
|
|
34
|
+
* provider's leaf, while the provider path it nests and filters under stays
|
|
35
|
+
* untouched.
|
|
27
36
|
*/
|
|
28
37
|
export const buildMoveOptions = ({
|
|
29
38
|
mailboxes,
|
|
@@ -31,7 +40,7 @@ export const buildMoveOptions = ({
|
|
|
31
40
|
currentMailboxId,
|
|
32
41
|
excludeMailboxId,
|
|
33
42
|
translator,
|
|
34
|
-
}: MoveOptionsInput):
|
|
43
|
+
}: MoveOptionsInput): FolderTreeNode[] => {
|
|
35
44
|
const targets = buildMoveTargets(mailboxes, folderAppointments);
|
|
36
45
|
const roleMap = buildMailboxRoleMap(folderAppointments);
|
|
37
46
|
const destinations = excludeMailboxId
|
|
@@ -40,7 +49,7 @@ export const buildMoveOptions = ({
|
|
|
40
49
|
return destinations.map((mailbox) => ({
|
|
41
50
|
id: mailbox.mailboxId,
|
|
42
51
|
label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
|
|
43
|
-
|
|
52
|
+
path: mailbox.fullPath,
|
|
44
53
|
isCurrent: mailbox.mailboxId === currentMailboxId,
|
|
45
54
|
}));
|
|
46
55
|
};
|
|
@@ -12,32 +12,28 @@ import type {
|
|
|
12
12
|
} from "@remit/api-http-client/types.gen.ts";
|
|
13
13
|
import {
|
|
14
14
|
Banner,
|
|
15
|
-
Button,
|
|
16
15
|
type CandidateFolder,
|
|
16
|
+
FolderManager,
|
|
17
|
+
FolderRenameDialog,
|
|
17
18
|
type FolderRole,
|
|
18
|
-
|
|
19
|
+
type ManagedFolder,
|
|
19
20
|
RoleAppointmentList,
|
|
20
|
-
Select,
|
|
21
21
|
SettingsShell,
|
|
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 {
|
|
26
|
-
import { useState } from "react";
|
|
25
|
+
import { useMemo, useState } from "react";
|
|
27
26
|
import { DeleteFolderDialog } from "@/components/settings/DeleteFolderDialog";
|
|
28
27
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
29
28
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
29
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
30
30
|
import { guardFolderDeletion } from "@/lib/delete-folder";
|
|
31
31
|
import {
|
|
32
|
+
buildMailboxRoleMap,
|
|
32
33
|
CANONICAL_TO_NAV_ROLE,
|
|
33
|
-
|
|
34
|
+
labelForMailbox,
|
|
34
35
|
NAV_ROLE_TO_CANONICAL,
|
|
35
36
|
} from "@/lib/folder-roles";
|
|
36
|
-
import {
|
|
37
|
-
composeFolderPath,
|
|
38
|
-
type FolderTarget,
|
|
39
|
-
validateNewFolderName,
|
|
40
|
-
} from "@/lib/new-folder";
|
|
41
37
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
42
38
|
|
|
43
39
|
export const Route = createFileRoute("/settings/folders")({
|
|
@@ -58,140 +54,25 @@ const foldersHelp = (
|
|
|
58
54
|
compose flow).
|
|
59
55
|
</p>
|
|
60
56
|
<p>
|
|
61
|
-
<strong className="text-fg">
|
|
62
|
-
folder
|
|
57
|
+
<strong className="text-fg">Your folders</strong> is the account's real
|
|
58
|
+
hierarchy. Open a folder to see what's inside it, make a new one where
|
|
59
|
+
you're looking, and rename or delete any of them from its row.
|
|
63
60
|
</p>
|
|
64
61
|
</div>
|
|
65
62
|
);
|
|
66
63
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
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
|
-
|
|
182
|
-
/** One account's folder roles, fed to the kit list. Owns its own queries + mutations. */
|
|
183
|
-
function AccountFolderRoles({
|
|
184
|
-
account,
|
|
185
|
-
}: {
|
|
186
|
-
account: RemitImapAccountResponse;
|
|
187
|
-
}) {
|
|
64
|
+
/** One account's folder roles and its folder hierarchy. Owns its own queries + mutations. */
|
|
65
|
+
function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
|
|
188
66
|
const queryClient = useQueryClient();
|
|
189
67
|
const accountId = account.accountId;
|
|
68
|
+
const translator = useFolderLabelTranslator();
|
|
190
69
|
|
|
191
70
|
const { data, isPending, isError, error, refetch } = useQuery(
|
|
192
71
|
mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
193
72
|
);
|
|
194
73
|
|
|
74
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
75
|
+
|
|
195
76
|
const appointMutation = useMutation({
|
|
196
77
|
...folderRoleOperationsAppointFolderRoleMutation(),
|
|
197
78
|
onSuccess: () => {
|
|
@@ -213,6 +94,33 @@ function AccountFolderRoles({
|
|
|
213
94
|
});
|
|
214
95
|
|
|
215
96
|
const [deletingMailboxId, setDeletingMailboxId] = useState<string>();
|
|
97
|
+
const [renamingMailboxId, setRenamingMailboxId] = useState<string>();
|
|
98
|
+
const [renameDraft, setRenameDraft] = useState("");
|
|
99
|
+
|
|
100
|
+
const mailboxes = useMemo(() => data?.items ?? [], [data]);
|
|
101
|
+
const roleMap = useMemo(
|
|
102
|
+
() => buildMailboxRoleMap(account.folderAppointments),
|
|
103
|
+
[account.folderAppointments],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const folders = useMemo<ManagedFolder[]>(
|
|
107
|
+
() =>
|
|
108
|
+
mailboxes.map((mailbox) => ({
|
|
109
|
+
id: mailbox.mailboxId,
|
|
110
|
+
label: labelForMailbox(
|
|
111
|
+
mailbox,
|
|
112
|
+
roleMap.get(mailbox.mailboxId),
|
|
113
|
+
translator,
|
|
114
|
+
),
|
|
115
|
+
path: mailbox.fullPath,
|
|
116
|
+
deleteBlockedReason: guardFolderDeletion(
|
|
117
|
+
mailbox,
|
|
118
|
+
mailboxes,
|
|
119
|
+
account.folderAppointments,
|
|
120
|
+
).message,
|
|
121
|
+
})),
|
|
122
|
+
[mailboxes, roleMap, translator, account.folderAppointments],
|
|
123
|
+
);
|
|
216
124
|
|
|
217
125
|
const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
|
|
218
126
|
appointMutation.mutate({
|
|
@@ -253,7 +161,7 @@ function AccountFolderRoles({
|
|
|
253
161
|
);
|
|
254
162
|
}
|
|
255
163
|
|
|
256
|
-
const
|
|
164
|
+
const candidates: CandidateFolder[] = mailboxes.map((mailbox) => ({
|
|
257
165
|
mailboxId: mailbox.mailboxId,
|
|
258
166
|
providerPath: mailbox.fullPath,
|
|
259
167
|
messageCount: mailbox.messageCount,
|
|
@@ -266,14 +174,22 @@ function AccountFolderRoles({
|
|
|
266
174
|
}
|
|
267
175
|
|
|
268
176
|
const displayNames: Record<string, string> = {};
|
|
269
|
-
for (const mailbox of
|
|
177
|
+
for (const mailbox of mailboxes) {
|
|
270
178
|
if (mailbox.displayNameOverride) {
|
|
271
179
|
displayNames[mailbox.mailboxId] = mailbox.displayNameOverride;
|
|
272
180
|
}
|
|
273
181
|
}
|
|
274
182
|
|
|
183
|
+
const findMailbox = (
|
|
184
|
+
mailboxId: string | undefined,
|
|
185
|
+
): RemitImapMailboxResponse | undefined =>
|
|
186
|
+
mailboxes.find((mailbox) => mailbox.mailboxId === mailboxId);
|
|
187
|
+
|
|
188
|
+
const renaming = findMailbox(renamingMailboxId);
|
|
189
|
+
const deleting = findMailbox(deletingMailboxId);
|
|
190
|
+
|
|
275
191
|
return (
|
|
276
|
-
<div className="space-y-
|
|
192
|
+
<div className="space-y-4">
|
|
277
193
|
{(appointMutation.isError || renameMutation.isError) && (
|
|
278
194
|
<Banner tone="danger" variant="soft">
|
|
279
195
|
Couldn't save that change. Please try again.
|
|
@@ -281,61 +197,78 @@ function AccountFolderRoles({
|
|
|
281
197
|
)}
|
|
282
198
|
<RoleAppointmentList
|
|
283
199
|
accountEmail={account.email}
|
|
284
|
-
folders={
|
|
200
|
+
folders={candidates}
|
|
285
201
|
appointments={appointments}
|
|
286
202
|
displayNames={displayNames}
|
|
287
203
|
onAppoint={handleAppoint}
|
|
288
204
|
onRename={handleRename}
|
|
289
205
|
/>
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
206
|
+
<section className="space-y-1.5">
|
|
207
|
+
<h3 className="text-sm font-semibold text-fg">
|
|
208
|
+
Your folders — {account.email}
|
|
209
|
+
</h3>
|
|
210
|
+
<div className="flex h-[28rem] flex-col overflow-hidden rounded-sm border border-line bg-surface">
|
|
211
|
+
<FolderManager
|
|
212
|
+
folders={folders}
|
|
213
|
+
delimiter={mailboxes[0]?.hierarchyDelimiter ?? "/"}
|
|
214
|
+
onCreateFolder={createFolderIn}
|
|
215
|
+
onRename={(folder) => {
|
|
216
|
+
setRenamingMailboxId(folder.id);
|
|
217
|
+
setRenameDraft(
|
|
218
|
+
findMailbox(folder.id)?.displayNameOverride?.trim() ?? "",
|
|
219
|
+
);
|
|
220
|
+
}}
|
|
221
|
+
onDelete={(folder) => setDeletingMailboxId(folder.id)}
|
|
222
|
+
labels={{ treeAriaLabel: `All folders for ${account.email}` }}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
{renaming && (
|
|
227
|
+
<FolderRenameDialog
|
|
228
|
+
open
|
|
229
|
+
folderLabel={labelForMailbox(
|
|
230
|
+
renaming,
|
|
231
|
+
roleMap.get(renaming.mailboxId),
|
|
232
|
+
translator,
|
|
233
|
+
)}
|
|
234
|
+
defaultLabel={labelForMailbox(
|
|
235
|
+
{ fullPath: renaming.fullPath },
|
|
236
|
+
roleMap.get(renaming.mailboxId),
|
|
237
|
+
translator,
|
|
238
|
+
)}
|
|
239
|
+
name={renameDraft}
|
|
240
|
+
onNameChange={setRenameDraft}
|
|
241
|
+
pending={renameMutation.isPending}
|
|
242
|
+
error={
|
|
243
|
+
renameMutation.isError
|
|
244
|
+
? "Couldn't save that name. Please try again."
|
|
245
|
+
: undefined
|
|
246
|
+
}
|
|
247
|
+
onSubmit={() => {
|
|
248
|
+
const trimmed = renameDraft.trim();
|
|
249
|
+
renameMutation.mutate(
|
|
250
|
+
{
|
|
251
|
+
path: { accountId, mailboxId: renaming.mailboxId },
|
|
252
|
+
body: {
|
|
253
|
+
displayNameOverride: trimmed === "" ? null : trimmed,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{ onSuccess: () => setRenamingMailboxId(undefined) },
|
|
257
|
+
);
|
|
258
|
+
}}
|
|
259
|
+
onClose={() => setRenamingMailboxId(undefined)}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
{deleting && (
|
|
263
|
+
<DeleteFolderDialog
|
|
264
|
+
open
|
|
265
|
+
accountId={accountId}
|
|
266
|
+
folder={deleting}
|
|
267
|
+
mailboxes={mailboxes}
|
|
268
|
+
appointments={account.folderAppointments}
|
|
269
|
+
onClose={() => setDeletingMailboxId(undefined)}
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
339
272
|
</div>
|
|
340
273
|
);
|
|
341
274
|
}
|
|
@@ -389,7 +322,7 @@ function FoldersSettings() {
|
|
|
389
322
|
) : (
|
|
390
323
|
<div className="space-y-8">
|
|
391
324
|
{config.accounts.map((account) => (
|
|
392
|
-
<
|
|
325
|
+
<AccountFolders key={account.accountId} account={account} />
|
|
393
326
|
))}
|
|
394
327
|
</div>
|
|
395
328
|
)}
|