@remit/web-client 0.0.99 → 0.0.101
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 +17 -32
- package/src/components/mail/SelectionWizardHost.tsx +10 -11
- package/src/components/mail/SpamRescue.tsx +17 -30
- package/src/components/settings/DeleteFolderDialog.render.test.ts +49 -11
- package/src/components/settings/DeleteFolderDialog.tsx +40 -42
- package/src/hooks/useCreateMailbox.ts +44 -9
- package/src/hooks/useFolderLabelTranslator.ts +19 -0
- package/src/lib/move-options.test.ts +140 -0
- package/src/lib/move-options.ts +46 -0
- 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.101",
|
|
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": {
|
|
@@ -15,9 +15,9 @@ import { Drawer } from "vaul";
|
|
|
15
15
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
16
16
|
import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
|
|
17
17
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
18
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
18
19
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
19
|
-
import {
|
|
20
|
-
import { buildMoveTargets } from "@/lib/move-targets";
|
|
20
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
22
|
|
|
23
23
|
interface MoveToTriggerProps {
|
|
@@ -71,15 +71,7 @@ export const MoveToTrigger = ({
|
|
|
71
71
|
const triggerLabel = label ?? "Move to folder";
|
|
72
72
|
const popoverId = useId();
|
|
73
73
|
const { t } = useTranslation("mail", { useSuspense: false });
|
|
74
|
-
|
|
75
|
-
// fallback)` shape; i18next's `t` treats the second argument as an options
|
|
76
|
-
// object — passing it raw breaks fallback behavior. Wrap it the same way
|
|
77
|
-
// the sidebar adapter does, memoized so it's a stable `useMemo` dependency.
|
|
78
|
-
const translator = useCallback(
|
|
79
|
-
(key: string, fallback: string): string =>
|
|
80
|
-
t(key, { defaultValue: fallback }),
|
|
81
|
-
[t],
|
|
82
|
-
);
|
|
74
|
+
const translator = useFolderLabelTranslator();
|
|
83
75
|
|
|
84
76
|
const {
|
|
85
77
|
data: mailboxesResponse,
|
|
@@ -98,28 +90,21 @@ export const MoveToTrigger = ({
|
|
|
98
90
|
const folderAppointments = useFolderAppointments(accountId);
|
|
99
91
|
const { createFolder } = useCreateMailbox(accountId);
|
|
100
92
|
|
|
101
|
-
const options = useMemo<MoveMailboxOption[]>(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return targets.map((mailbox) => ({
|
|
108
|
-
id: mailbox.mailboxId,
|
|
109
|
-
label: labelForMailbox(
|
|
110
|
-
mailbox,
|
|
111
|
-
roleMap.get(mailbox.mailboxId),
|
|
93
|
+
const options = useMemo<MoveMailboxOption[]>(
|
|
94
|
+
() =>
|
|
95
|
+
buildMoveOptions({
|
|
96
|
+
mailboxes: mailboxesResponse?.items ?? [],
|
|
97
|
+
folderAppointments,
|
|
98
|
+
currentMailboxId,
|
|
112
99
|
translator,
|
|
113
|
-
),
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
translator,
|
|
122
|
-
]);
|
|
100
|
+
}),
|
|
101
|
+
[
|
|
102
|
+
mailboxesResponse?.items,
|
|
103
|
+
folderAppointments,
|
|
104
|
+
currentMailboxId,
|
|
105
|
+
translator,
|
|
106
|
+
],
|
|
107
|
+
);
|
|
123
108
|
|
|
124
109
|
const handleSelect = useCallback(
|
|
125
110
|
(destinationMailboxId: string) => {
|
|
@@ -41,16 +41,16 @@ import {
|
|
|
41
41
|
useEscalatedActions,
|
|
42
42
|
} from "@/hooks/useEscalatedActions";
|
|
43
43
|
import { useCreateFilter } from "@/hooks/useFilters";
|
|
44
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
44
45
|
import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
|
|
45
46
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
46
47
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
47
48
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
48
49
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
49
50
|
import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
50
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
51
51
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
52
52
|
import { useMailContext } from "@/lib/mail-context";
|
|
53
|
-
import {
|
|
53
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
54
54
|
import {
|
|
55
55
|
buildWizardDraft,
|
|
56
56
|
canBackApplyDraft,
|
|
@@ -387,17 +387,16 @@ function SelectionWizardSession({
|
|
|
387
387
|
staleTime: Number.POSITIVE_INFINITY,
|
|
388
388
|
});
|
|
389
389
|
const folderAppointments = useFolderAppointments(accountId);
|
|
390
|
+
const translator = useFolderLabelTranslator();
|
|
390
391
|
const mailboxes = useMemo<MoveMailboxOption[]>(
|
|
391
392
|
() =>
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
),
|
|
400
|
-
[mailboxesData?.items, folderAppointments, mailboxId],
|
|
393
|
+
buildMoveOptions({
|
|
394
|
+
mailboxes: mailboxesData?.items ?? [],
|
|
395
|
+
folderAppointments,
|
|
396
|
+
currentMailboxId: mailboxId,
|
|
397
|
+
translator,
|
|
398
|
+
}),
|
|
399
|
+
[mailboxesData?.items, folderAppointments, mailboxId, translator],
|
|
401
400
|
);
|
|
402
401
|
const { createFolder } = useCreateMailbox(accountId);
|
|
403
402
|
|
|
@@ -14,13 +14,12 @@ import {
|
|
|
14
14
|
useRef,
|
|
15
15
|
useState,
|
|
16
16
|
} from "react";
|
|
17
|
-
import { useTranslation } from "react-i18next";
|
|
18
17
|
import {
|
|
19
18
|
useFolderAppointments,
|
|
20
19
|
useInboxMailbox,
|
|
21
20
|
} from "@/hooks/useArchiveMailbox";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
21
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
22
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
24
23
|
import {
|
|
25
24
|
recordRescueCandidatesSurfaced,
|
|
26
25
|
recordRescueCommitted,
|
|
@@ -51,12 +50,7 @@ export function SpamRescue({
|
|
|
51
50
|
}: SpamRescueProps) {
|
|
52
51
|
const [open, setOpen] = useState(false);
|
|
53
52
|
const telemetry = useTelemetry();
|
|
54
|
-
const
|
|
55
|
-
const translator = useCallback(
|
|
56
|
-
(key: string, fallback: string): string =>
|
|
57
|
-
t(key, { defaultValue: fallback }),
|
|
58
|
-
[t],
|
|
59
|
-
);
|
|
53
|
+
const translator = useFolderLabelTranslator();
|
|
60
54
|
|
|
61
55
|
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
62
56
|
const folderAppointments = useFolderAppointments(accountId);
|
|
@@ -66,28 +60,21 @@ export function SpamRescue({
|
|
|
66
60
|
staleTime: Infinity,
|
|
67
61
|
});
|
|
68
62
|
|
|
69
|
-
const folders = useMemo<MoveMailboxOption[]>(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return targets.map((mailbox) => ({
|
|
76
|
-
id: mailbox.mailboxId,
|
|
77
|
-
label: labelForMailbox(
|
|
78
|
-
mailbox,
|
|
79
|
-
roleMap.get(mailbox.mailboxId),
|
|
63
|
+
const folders = useMemo<MoveMailboxOption[]>(
|
|
64
|
+
() =>
|
|
65
|
+
buildMoveOptions({
|
|
66
|
+
mailboxes: mailboxesResponse?.items ?? [],
|
|
67
|
+
folderAppointments,
|
|
68
|
+
currentMailboxId,
|
|
80
69
|
translator,
|
|
81
|
-
),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
translator,
|
|
90
|
-
]);
|
|
70
|
+
}),
|
|
71
|
+
[
|
|
72
|
+
mailboxesResponse?.items,
|
|
73
|
+
folderAppointments,
|
|
74
|
+
currentMailboxId,
|
|
75
|
+
translator,
|
|
76
|
+
],
|
|
77
|
+
);
|
|
91
78
|
|
|
92
79
|
const defaultDestinationId = useMemo(() => {
|
|
93
80
|
if (inboxMailboxId) return inboxMailboxId;
|
|
@@ -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,20 +6,17 @@ 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";
|
|
14
14
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
15
15
|
import { useDeleteFolder } from "@/hooks/useDeleteFolder";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from "@/lib/delete-folder";
|
|
21
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
22
|
-
import { buildMoveTargets } from "@/lib/move-targets";
|
|
16
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
17
|
+
import { initialStage, moveProgressLabel } from "@/lib/delete-folder";
|
|
18
|
+
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
19
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
23
20
|
|
|
24
21
|
interface DeleteFolderDialogProps {
|
|
25
22
|
open: boolean;
|
|
@@ -36,9 +33,6 @@ type FateStage =
|
|
|
36
33
|
| "confirm-delete-all"
|
|
37
34
|
| "pick-destination";
|
|
38
35
|
|
|
39
|
-
const folderLabel = (folder: RemitImapMailboxResponse): string =>
|
|
40
|
-
folder.displayNameOverride?.trim() || getMailboxDisplayName(folder.fullPath);
|
|
41
|
-
|
|
42
36
|
const emailCount = (count: number): string =>
|
|
43
37
|
`${count} ${count === 1 ? "email" : "emails"}`;
|
|
44
38
|
|
|
@@ -60,7 +54,8 @@ export function DeleteFolderDialog({
|
|
|
60
54
|
const [stage, setStage] = useState<FateStage>(() =>
|
|
61
55
|
initialStage(folder.messageCount),
|
|
62
56
|
);
|
|
63
|
-
const {
|
|
57
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
58
|
+
const translator = useFolderLabelTranslator();
|
|
64
59
|
const {
|
|
65
60
|
phase,
|
|
66
61
|
progress,
|
|
@@ -88,30 +83,36 @@ export function DeleteFolderDialog({
|
|
|
88
83
|
onClose();
|
|
89
84
|
}, [cancel, onClose]);
|
|
90
85
|
|
|
91
|
-
|
|
86
|
+
// `buildMoveOptions` carries the label and the searchable path but not the
|
|
87
|
+
// provider path the tree nests by, so the path is joined back on by id here.
|
|
88
|
+
const destinations = useMemo<FolderTreeNode[]>(() => {
|
|
89
|
+
const pathById = new Map(
|
|
90
|
+
mailboxes.map((mailbox) => [mailbox.mailboxId, mailbox.fullPath]),
|
|
91
|
+
);
|
|
92
|
+
return buildMoveOptions({
|
|
93
|
+
mailboxes,
|
|
94
|
+
folderAppointments: appointments,
|
|
95
|
+
excludeMailboxId: folder.mailboxId,
|
|
96
|
+
translator,
|
|
97
|
+
}).map((option) => ({
|
|
98
|
+
id: option.id,
|
|
99
|
+
label: option.label,
|
|
100
|
+
path: pathById.get(option.id) ?? option.label,
|
|
101
|
+
}));
|
|
102
|
+
}, [mailboxes, appointments, folder.mailboxId, translator]);
|
|
103
|
+
|
|
104
|
+
const name = useMemo(
|
|
92
105
|
() =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
folder.mailboxId,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
searchValue: mailbox.fullPath,
|
|
100
|
-
})),
|
|
101
|
-
[mailboxes, appointments, folder.mailboxId],
|
|
106
|
+
labelForMailbox(
|
|
107
|
+
folder,
|
|
108
|
+
buildMailboxRoleMap(appointments).get(folder.mailboxId),
|
|
109
|
+
translator,
|
|
110
|
+
),
|
|
111
|
+
[folder, appointments, translator],
|
|
102
112
|
);
|
|
103
113
|
|
|
104
|
-
const handleCreateFolder = async (
|
|
105
|
-
name: string,
|
|
106
|
-
signal?: AbortSignal,
|
|
107
|
-
): Promise<MoveMailboxOption> => {
|
|
108
|
-
const created = await createFolder(name, signal);
|
|
109
|
-
return { id: created.id, label: created.label };
|
|
110
|
-
};
|
|
111
|
-
|
|
112
114
|
if (!open) return null;
|
|
113
115
|
|
|
114
|
-
const name = folderLabel(folder);
|
|
115
116
|
const title = `Delete ${name}`;
|
|
116
117
|
|
|
117
118
|
const body = (() => {
|
|
@@ -266,24 +267,21 @@ export function DeleteFolderDialog({
|
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
return (
|
|
269
|
-
<div className="flex h-
|
|
270
|
+
<div className="flex h-[26rem] flex-col">
|
|
270
271
|
<p className="px-5 pt-4 text-sm text-fg-muted">
|
|
271
272
|
Move the {emailCount(folder.messageCount)} in{" "}
|
|
272
273
|
<strong className="text-fg">{name}</strong> to:
|
|
273
274
|
</p>
|
|
274
|
-
<div className="min-h-0 flex-1
|
|
275
|
-
<
|
|
276
|
-
|
|
275
|
+
<div className="min-h-0 flex-1">
|
|
276
|
+
<FolderTreePicker
|
|
277
|
+
folders={destinations}
|
|
278
|
+
delimiter={mailboxes[0]?.hierarchyDelimiter ?? "/"}
|
|
277
279
|
onSelect={(destinationMailboxId) =>
|
|
278
280
|
moveThenDelete(destinationMailboxId)
|
|
279
281
|
}
|
|
280
|
-
onCreateFolder={
|
|
282
|
+
onCreateFolder={createFolderIn}
|
|
281
283
|
onCancel={() => setStage("choose-fate")}
|
|
282
|
-
labels={{
|
|
283
|
-
searchPlaceholder: "Move emails to…",
|
|
284
|
-
optionLabel: (label) => `Move to ${label}`,
|
|
285
|
-
createLabel: (query) => `Create "${query}"`,
|
|
286
|
-
}}
|
|
284
|
+
labels={{ filterPlaceholder: "Move emails to…" }}
|
|
287
285
|
/>
|
|
288
286
|
</div>
|
|
289
287
|
</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
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `labelForMailbox` expects a positional `(key, fallback)` translator; i18next's
|
|
6
|
+
* `t` reads its second argument as an options object, which drops the fallback.
|
|
7
|
+
* Memoized so callers can hold it as a `useMemo` dependency.
|
|
8
|
+
*/
|
|
9
|
+
export const useFolderLabelTranslator = (): ((
|
|
10
|
+
key: string,
|
|
11
|
+
fallback: string,
|
|
12
|
+
) => string) => {
|
|
13
|
+
const { t } = useTranslation("mail", { useSuspense: false });
|
|
14
|
+
return useCallback(
|
|
15
|
+
(key: string, fallback: string): string =>
|
|
16
|
+
t(key, { defaultValue: fallback }),
|
|
17
|
+
[t],
|
|
18
|
+
);
|
|
19
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type {
|
|
6
|
+
RemitImapFolderAppointment,
|
|
7
|
+
RemitImapMailboxResponse,
|
|
8
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { buildMoveOptions } from "./move-options.js";
|
|
10
|
+
|
|
11
|
+
const englishBundle = JSON.parse(
|
|
12
|
+
readFileSync(
|
|
13
|
+
fileURLToPath(
|
|
14
|
+
new URL("../../public/locales/en/mail.json", import.meta.url),
|
|
15
|
+
),
|
|
16
|
+
"utf8",
|
|
17
|
+
),
|
|
18
|
+
) as { sidebar: Record<string, string> };
|
|
19
|
+
|
|
20
|
+
// The picker is handed the same shipped English bundle the app loads, so the
|
|
21
|
+
// expected labels are the product's own, not strings restated by the test.
|
|
22
|
+
const translate = (key: string, fallback: string): string =>
|
|
23
|
+
englishBundle.sidebar[key.replace("sidebar.", "")] ?? fallback;
|
|
24
|
+
|
|
25
|
+
const make = (
|
|
26
|
+
overrides: Partial<RemitImapMailboxResponse> & {
|
|
27
|
+
mailboxId: string;
|
|
28
|
+
fullPath: string;
|
|
29
|
+
},
|
|
30
|
+
): RemitImapMailboxResponse =>
|
|
31
|
+
({
|
|
32
|
+
accountId: "acct-1",
|
|
33
|
+
namespaceType: "personal",
|
|
34
|
+
namespacePrefix: "",
|
|
35
|
+
hierarchyDelimiter: "/",
|
|
36
|
+
messageCount: 0,
|
|
37
|
+
unseenCount: 0,
|
|
38
|
+
deletedCount: 0,
|
|
39
|
+
createdAt: 0,
|
|
40
|
+
updatedAt: 0,
|
|
41
|
+
...overrides,
|
|
42
|
+
}) as RemitImapMailboxResponse;
|
|
43
|
+
|
|
44
|
+
const appoint = (
|
|
45
|
+
role: RemitImapFolderAppointment["role"],
|
|
46
|
+
mailboxId: string,
|
|
47
|
+
): RemitImapFolderAppointment => ({ role, mailboxId });
|
|
48
|
+
|
|
49
|
+
const applePaths = [
|
|
50
|
+
make({ mailboxId: "mb-inbox", fullPath: "INBOX" }),
|
|
51
|
+
make({ mailboxId: "mb-trash", fullPath: "INBOX/Deleted Messages" }),
|
|
52
|
+
make({ mailboxId: "mb-junk", fullPath: "INBOX/Junk" }),
|
|
53
|
+
make({ mailboxId: "mb-receipts", fullPath: "INBOX/Receipts" }),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const appleAppointments = [
|
|
57
|
+
appoint("Inbox", "mb-inbox"),
|
|
58
|
+
appoint("Trash", "mb-trash"),
|
|
59
|
+
appoint("Junk", "mb-junk"),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const labelOf = (
|
|
63
|
+
options: ReturnType<typeof buildMoveOptions>,
|
|
64
|
+
id: string,
|
|
65
|
+
): string | undefined => options.find((option) => option.id === id)?.label;
|
|
66
|
+
|
|
67
|
+
describe("buildMoveOptions", () => {
|
|
68
|
+
test("labels appointed folders by role, not by the provider's leaf", () => {
|
|
69
|
+
const options = buildMoveOptions({
|
|
70
|
+
mailboxes: applePaths,
|
|
71
|
+
folderAppointments: appleAppointments,
|
|
72
|
+
translator: translate,
|
|
73
|
+
});
|
|
74
|
+
assert.equal(labelOf(options, "mb-inbox"), "Inbox");
|
|
75
|
+
assert.equal(labelOf(options, "mb-trash"), "Trash");
|
|
76
|
+
assert.equal(labelOf(options, "mb-junk"), "Spam");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a displayNameOverride wins over the role label", () => {
|
|
80
|
+
const options = buildMoveOptions({
|
|
81
|
+
mailboxes: [
|
|
82
|
+
make({
|
|
83
|
+
mailboxId: "mb-trash",
|
|
84
|
+
fullPath: "INBOX/Deleted Messages",
|
|
85
|
+
displayNameOverride: "Bin",
|
|
86
|
+
}),
|
|
87
|
+
],
|
|
88
|
+
folderAppointments: [appoint("Trash", "mb-trash")],
|
|
89
|
+
translator: translate,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(labelOf(options, "mb-trash"), "Bin");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an unappointed folder keeps its own name", () => {
|
|
95
|
+
const options = buildMoveOptions({
|
|
96
|
+
mailboxes: applePaths,
|
|
97
|
+
folderAppointments: appleAppointments,
|
|
98
|
+
translator: translate,
|
|
99
|
+
});
|
|
100
|
+
assert.equal(labelOf(options, "mb-receipts"), "Receipts");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the full provider path stays searchable", () => {
|
|
104
|
+
const options = buildMoveOptions({
|
|
105
|
+
mailboxes: applePaths,
|
|
106
|
+
folderAppointments: appleAppointments,
|
|
107
|
+
translator: translate,
|
|
108
|
+
});
|
|
109
|
+
assert.equal(
|
|
110
|
+
options.find((option) => option.id === "mb-trash")?.searchValue,
|
|
111
|
+
"INBOX/Deleted Messages",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the current mailbox is marked and nothing else is", () => {
|
|
116
|
+
const options = buildMoveOptions({
|
|
117
|
+
mailboxes: applePaths,
|
|
118
|
+
folderAppointments: appleAppointments,
|
|
119
|
+
currentMailboxId: "mb-junk",
|
|
120
|
+
translator: translate,
|
|
121
|
+
});
|
|
122
|
+
assert.deepStrictEqual(
|
|
123
|
+
options.filter((option) => option.isCurrent).map((option) => option.id),
|
|
124
|
+
["mb-junk"],
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an excluded mailbox is not offered as a destination", () => {
|
|
129
|
+
const options = buildMoveOptions({
|
|
130
|
+
mailboxes: applePaths,
|
|
131
|
+
folderAppointments: appleAppointments,
|
|
132
|
+
excludeMailboxId: "mb-receipts",
|
|
133
|
+
translator: translate,
|
|
134
|
+
});
|
|
135
|
+
assert.equal(
|
|
136
|
+
options.some((option) => option.id === "mb-receipts"),
|
|
137
|
+
false,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapFolderAppointment,
|
|
3
|
+
RemitImapMailboxResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { MoveMailboxOption } from "@remit/ui";
|
|
6
|
+
import { excludeFolder } from "./delete-folder.js";
|
|
7
|
+
import { buildMailboxRoleMap, labelForMailbox } from "./folder-roles.js";
|
|
8
|
+
import { buildMoveTargets } from "./move-targets.js";
|
|
9
|
+
|
|
10
|
+
type Translator = (key: string, fallback: string) => string;
|
|
11
|
+
|
|
12
|
+
interface MoveOptionsInput {
|
|
13
|
+
mailboxes: readonly RemitImapMailboxResponse[];
|
|
14
|
+
folderAppointments: readonly RemitImapFolderAppointment[];
|
|
15
|
+
/** Rendered as a non-selectable row, so the user sees where mail lives now. */
|
|
16
|
+
currentMailboxId?: string;
|
|
17
|
+
/** Dropped entirely — the folder being deleted is not its own destination. */
|
|
18
|
+
excludeMailboxId?: string;
|
|
19
|
+
translator?: Translator;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The single shaping of a mailbox list into Move-to picker options. Labels
|
|
24
|
+
* follow the account's role appointments (RFC 032, #976) and any
|
|
25
|
+
* `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
|
|
26
|
+
* provider's leaf; the full path stays searchable.
|
|
27
|
+
*/
|
|
28
|
+
export const buildMoveOptions = ({
|
|
29
|
+
mailboxes,
|
|
30
|
+
folderAppointments,
|
|
31
|
+
currentMailboxId,
|
|
32
|
+
excludeMailboxId,
|
|
33
|
+
translator,
|
|
34
|
+
}: MoveOptionsInput): MoveMailboxOption[] => {
|
|
35
|
+
const targets = buildMoveTargets(mailboxes, folderAppointments);
|
|
36
|
+
const roleMap = buildMailboxRoleMap(folderAppointments);
|
|
37
|
+
const destinations = excludeMailboxId
|
|
38
|
+
? excludeFolder(targets, excludeMailboxId)
|
|
39
|
+
: targets;
|
|
40
|
+
return destinations.map((mailbox) => ({
|
|
41
|
+
id: mailbox.mailboxId,
|
|
42
|
+
label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
|
|
43
|
+
searchValue: mailbox.fullPath,
|
|
44
|
+
isCurrent: mailbox.mailboxId === currentMailboxId,
|
|
45
|
+
}));
|
|
46
|
+
};
|
|
@@ -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
|
)}
|