@remit/web-client 0.0.69 → 0.0.71
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/FilterEditor.tsx +3 -0
- package/src/hooks/useCreateMailbox.render.test.ts +131 -0
- package/src/hooks/useCreateMailbox.ts +65 -0
- package/src/lib/new-folder.test.ts +109 -0
- package/src/lib/new-folder.ts +58 -0
- package/src/lib/self-update-state.test.ts +2 -2
- package/src/lib/self-update-state.ts +5 -3
- package/src/routes/settings/folders.tsx +145 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.71",
|
|
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": {
|
|
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
|
|
|
14
14
|
import { Drawer } from "vaul";
|
|
15
15
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
16
16
|
import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
|
|
17
|
+
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
17
18
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
18
19
|
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
19
20
|
import { buildMoveTargets } from "@/lib/move-targets";
|
|
@@ -95,6 +96,7 @@ export const MoveToTrigger = ({
|
|
|
95
96
|
enabled: isOpen,
|
|
96
97
|
});
|
|
97
98
|
const folderAppointments = useFolderAppointments(accountId);
|
|
99
|
+
const { createFolder } = useCreateMailbox(accountId);
|
|
98
100
|
|
|
99
101
|
const options = useMemo<MoveMailboxOption[]>(() => {
|
|
100
102
|
const targets = buildMoveTargets(
|
|
@@ -127,6 +129,14 @@ export const MoveToTrigger = ({
|
|
|
127
129
|
[onMove],
|
|
128
130
|
);
|
|
129
131
|
|
|
132
|
+
const handleCreateFolder = useCallback(
|
|
133
|
+
async (name: string): Promise<MoveMailboxOption> => {
|
|
134
|
+
const folder = await createFolder(name);
|
|
135
|
+
return { id: folder.id, label: folder.label };
|
|
136
|
+
},
|
|
137
|
+
[createFolder],
|
|
138
|
+
);
|
|
139
|
+
|
|
130
140
|
// Desktop popover: dismiss on outside click + Escape.
|
|
131
141
|
useEffect(() => {
|
|
132
142
|
if (!isOpen || !isDesktop) return;
|
|
@@ -190,9 +200,11 @@ export const MoveToTrigger = ({
|
|
|
190
200
|
<MoveMailboxPicker
|
|
191
201
|
mailboxes={options}
|
|
192
202
|
onSelect={handleSelect}
|
|
203
|
+
onCreateFolder={handleCreateFolder}
|
|
193
204
|
onCancel={() => setIsOpen(false)}
|
|
194
205
|
autoFocus={!isDesktop}
|
|
195
206
|
labels={{
|
|
207
|
+
createLabel: (folderName) => `Create "${folderName}"`,
|
|
196
208
|
searchPlaceholder: t("move_picker_placeholder", {
|
|
197
209
|
defaultValue: "Move to…",
|
|
198
210
|
}),
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "@remit/ui";
|
|
8
8
|
import { useQuery } from "@tanstack/react-query";
|
|
9
9
|
import { useMemo, useState } from "react";
|
|
10
|
+
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
10
11
|
import { useCreateFilter } from "@/hooks/useFilters";
|
|
11
12
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
12
13
|
import { useRuleEditorState } from "@/hooks/useRuleEditorState";
|
|
@@ -104,6 +105,7 @@ export function OrganizeRuleEditor({
|
|
|
104
105
|
|
|
105
106
|
const organizeJob = useOrganizeJob(accountId);
|
|
106
107
|
const createFilter = useCreateFilter(accountId);
|
|
108
|
+
const { createFolder } = useCreateMailbox(accountId);
|
|
107
109
|
|
|
108
110
|
const commit = () => {
|
|
109
111
|
const draft = ruleToDraft(rule, anchorMessageId);
|
|
@@ -153,6 +155,7 @@ export function OrganizeRuleEditor({
|
|
|
153
155
|
semanticAvailable={!semanticUnavailable}
|
|
154
156
|
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
155
157
|
{...handlers}
|
|
158
|
+
onCreateFolder={createFolder}
|
|
156
159
|
onCommit={commit}
|
|
157
160
|
onCancel={onClose}
|
|
158
161
|
/>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@remit/ui";
|
|
12
12
|
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
13
13
|
import { useMemo, useRef, useState } from "react";
|
|
14
|
+
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
14
15
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
15
16
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
16
17
|
import { useUpdateFilter } from "@/hooks/useUpdateFilter";
|
|
@@ -67,6 +68,7 @@ export function FilterEditor({
|
|
|
67
68
|
const preview = useRulePreview(accountId, rulePredicate(rule));
|
|
68
69
|
const update = useUpdateFilter(accountId, filter.filterId);
|
|
69
70
|
const organizeJob = useOrganizeJob(accountId);
|
|
71
|
+
const { createFolder } = useCreateMailbox(accountId);
|
|
70
72
|
|
|
71
73
|
const startAddClause = () =>
|
|
72
74
|
setClauseEdit({
|
|
@@ -203,6 +205,7 @@ export function FilterEditor({
|
|
|
203
205
|
onCancelClause={() => setClauseEdit(undefined)}
|
|
204
206
|
onChangeMatchOperator={changeMatchOperator}
|
|
205
207
|
onChangeMove={changeMove}
|
|
208
|
+
onCreateFolder={createFolder}
|
|
206
209
|
onChangeName={changeName}
|
|
207
210
|
onCommit={commit}
|
|
208
211
|
onCancel={onClose}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCreateMailbox.createFolder — the shared create seam the kit surfaces call.
|
|
3
|
+
* It validates the typed name against the account's current folders with the
|
|
4
|
+
* same IMAP-aware rules the settings form uses, and rejects with the
|
|
5
|
+
* human-readable reason before any request. The mailbox list is seeded into the
|
|
6
|
+
* query cache the hook reads, so validation runs against real paths.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { afterEach, describe, it } from "node:test";
|
|
11
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
12
|
+
import type {
|
|
13
|
+
MailboxOperationsListMailboxesResponse,
|
|
14
|
+
RemitImapMailboxResponse,
|
|
15
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
16
|
+
import type { FolderOption } from "@remit/ui";
|
|
17
|
+
import { act, createElement } from "react";
|
|
18
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
19
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
20
|
+
import { useCreateMailbox } from "./useCreateMailbox";
|
|
21
|
+
|
|
22
|
+
const ACCOUNT = "acc-1";
|
|
23
|
+
|
|
24
|
+
let harness: DomHarness | undefined;
|
|
25
|
+
let http: HttpMock | undefined;
|
|
26
|
+
let createFolder: ((name: string) => Promise<FolderOption>) | undefined;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
harness?.close();
|
|
30
|
+
harness = undefined;
|
|
31
|
+
http?.restore();
|
|
32
|
+
http = undefined;
|
|
33
|
+
createFolder = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const mailbox = (
|
|
37
|
+
fullPath: string,
|
|
38
|
+
delimiter: string,
|
|
39
|
+
): RemitImapMailboxResponse =>
|
|
40
|
+
({
|
|
41
|
+
mailboxId: `mbx-${fullPath}`,
|
|
42
|
+
accountId: ACCOUNT,
|
|
43
|
+
fullPath,
|
|
44
|
+
hierarchyDelimiter: delimiter,
|
|
45
|
+
}) as RemitImapMailboxResponse;
|
|
46
|
+
|
|
47
|
+
function Probe() {
|
|
48
|
+
createFolder = useCreateMailbox(ACCOUNT).createFolder;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const mount = (items: RemitImapMailboxResponse[]) => {
|
|
53
|
+
http = mockFetch((call) => {
|
|
54
|
+
if (call.method === "POST") {
|
|
55
|
+
const body = call.body as { fullPath: string };
|
|
56
|
+
return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
|
|
57
|
+
}
|
|
58
|
+
return { items };
|
|
59
|
+
});
|
|
60
|
+
harness = createDomHarness();
|
|
61
|
+
harness.queryClient.setQueryData<MailboxOperationsListMailboxesResponse>(
|
|
62
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT } }),
|
|
63
|
+
{ items },
|
|
64
|
+
);
|
|
65
|
+
harness.renderApp(createElement(Probe));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const reject = async (name: string): Promise<unknown> => {
|
|
69
|
+
const run = createFolder;
|
|
70
|
+
if (!run) throw new Error("createFolder is not mounted");
|
|
71
|
+
let caught: unknown;
|
|
72
|
+
await act(async () => {
|
|
73
|
+
caught = await run(name).then(
|
|
74
|
+
() => undefined,
|
|
75
|
+
(error: unknown) => error,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
return caught;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const postCount = (): number =>
|
|
82
|
+
(http?.calls ?? []).filter((call) => call.method === "POST").length;
|
|
83
|
+
|
|
84
|
+
describe("useCreateMailbox.createFolder validation", () => {
|
|
85
|
+
it("rejects a name containing the account delimiter before any request", async () => {
|
|
86
|
+
mount([mailbox("INBOX", "/")]);
|
|
87
|
+
const error = await reject("Work/Receipts");
|
|
88
|
+
assert.ok(error instanceof Error);
|
|
89
|
+
assert.match(error.message, /can't contain/);
|
|
90
|
+
assert.equal(postCount(), 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("honours a non-slash account delimiter", async () => {
|
|
94
|
+
mount([mailbox("INBOX", ".")]);
|
|
95
|
+
const error = await reject("Work.Receipts");
|
|
96
|
+
assert.ok(error instanceof Error);
|
|
97
|
+
assert.match(error.message, /can't contain/);
|
|
98
|
+
assert.equal(postCount(), 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects a collision with an existing top-level fullPath before any request", async () => {
|
|
102
|
+
mount([mailbox("INBOX", "/"), mailbox("Work", "/")]);
|
|
103
|
+
const error = await reject("Work");
|
|
104
|
+
assert.ok(error instanceof Error);
|
|
105
|
+
assert.match(error.message, /already exists/);
|
|
106
|
+
assert.equal(postCount(), 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("treats INBOX case-insensitively for collisions", async () => {
|
|
110
|
+
mount([mailbox("INBOX", "/")]);
|
|
111
|
+
const error = await reject("inbox");
|
|
112
|
+
assert.ok(error instanceof Error);
|
|
113
|
+
assert.match(error.message, /already exists/);
|
|
114
|
+
assert.equal(postCount(), 0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("passes a valid name through to the create request and maps the result", async () => {
|
|
118
|
+
mount([mailbox("INBOX", "/")]);
|
|
119
|
+
let result: FolderOption | undefined;
|
|
120
|
+
await act(async () => {
|
|
121
|
+
result = await createFolder?.("Taxes");
|
|
122
|
+
});
|
|
123
|
+
const posts = (http?.calls ?? []).filter((call) => call.method === "POST");
|
|
124
|
+
assert.equal(posts.length, 1);
|
|
125
|
+
assert.deepEqual(posts[0].body, {
|
|
126
|
+
fullPath: "Taxes",
|
|
127
|
+
namespaceType: "personal",
|
|
128
|
+
});
|
|
129
|
+
assert.equal(result?.label, "Taxes");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mailboxOperationsCreateMailboxMutation,
|
|
3
|
+
mailboxOperationsListMailboxesOptions,
|
|
4
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
5
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
+
import type { FolderOption } from "@remit/ui";
|
|
7
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
import { useCallback } from "react";
|
|
9
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
10
|
+
import { composeFolderPath, validateNewFolderName } from "@/lib/new-folder";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a mailbox for an account and refreshes the folder list on success.
|
|
14
|
+
* The backend creates the row with a pending sync status and queues the IMAP
|
|
15
|
+
* create, so the folder is usable as a move destination immediately.
|
|
16
|
+
*
|
|
17
|
+
* `createFolder` takes a leaf name, validates it against the account's current
|
|
18
|
+
* folders with the same IMAP-aware rules the settings form uses (non-empty, no
|
|
19
|
+
* hierarchy delimiter, no collision — INBOX case-insensitive), and rejects with
|
|
20
|
+
* the human-readable reason before any request. The kit surfaces that pick the
|
|
21
|
+
* result render that rejection inline. `mutation` is exposed for callers that
|
|
22
|
+
* drive their own form state and error surface.
|
|
23
|
+
*/
|
|
24
|
+
export function useCreateMailbox(accountId: string) {
|
|
25
|
+
const queryClient = useQueryClient();
|
|
26
|
+
|
|
27
|
+
const { data } = useQuery(
|
|
28
|
+
mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const mutation = useMutation({
|
|
32
|
+
...mailboxOperationsCreateMailboxMutation(),
|
|
33
|
+
onSuccess: () => {
|
|
34
|
+
queryClient.invalidateQueries({
|
|
35
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
36
|
+
path: { accountId },
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const createFolder = useCallback(
|
|
43
|
+
async (name: string): Promise<FolderOption> => {
|
|
44
|
+
const items = data?.items ?? [];
|
|
45
|
+
const delimiter = items[0]?.hierarchyDelimiter ?? "/";
|
|
46
|
+
const problem = validateNewFolderName({
|
|
47
|
+
name,
|
|
48
|
+
delimiter,
|
|
49
|
+
existingPaths: items.map((item) => item.fullPath),
|
|
50
|
+
});
|
|
51
|
+
if (problem) throw new Error(problem);
|
|
52
|
+
const mailbox = await mutation.mutateAsync({
|
|
53
|
+
path: { accountId },
|
|
54
|
+
body: { fullPath: composeFolderPath(name), namespaceType: "personal" },
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
id: mailbox.mailboxId,
|
|
58
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
[mutation, accountId, data],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return { createFolder, mutation };
|
|
65
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
composeFolderPath,
|
|
5
|
+
type FolderTarget,
|
|
6
|
+
validateNewFolderName,
|
|
7
|
+
} from "./new-folder.js";
|
|
8
|
+
|
|
9
|
+
const parent: FolderTarget = { fullPath: "INBOX", hierarchyDelimiter: "/" };
|
|
10
|
+
|
|
11
|
+
describe("composeFolderPath", () => {
|
|
12
|
+
it("returns the trimmed name for a root folder", () => {
|
|
13
|
+
assert.equal(composeFolderPath(" Receipts "), "Receipts");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("joins the parent path by the parent's delimiter", () => {
|
|
17
|
+
assert.equal(composeFolderPath("Receipts", parent), "INBOX/Receipts");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("honours a non-slash delimiter", () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
composeFolderPath("Receipts", {
|
|
23
|
+
fullPath: "INBOX",
|
|
24
|
+
hierarchyDelimiter: ".",
|
|
25
|
+
}),
|
|
26
|
+
"INBOX.Receipts",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("validateNewFolderName", () => {
|
|
32
|
+
const base = { delimiter: "/", existingPaths: ["INBOX", "INBOX/Archive"] };
|
|
33
|
+
|
|
34
|
+
it("rejects an empty or whitespace name", () => {
|
|
35
|
+
assert.match(
|
|
36
|
+
validateNewFolderName({ ...base, name: " " }) ?? "",
|
|
37
|
+
/Enter a folder name/,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects a name containing the hierarchy delimiter", () => {
|
|
42
|
+
assert.match(
|
|
43
|
+
validateNewFolderName({ ...base, name: "a/b" }) ?? "",
|
|
44
|
+
/can't contain/,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects a name that would contain a non-slash delimiter", () => {
|
|
49
|
+
assert.match(
|
|
50
|
+
validateNewFolderName({
|
|
51
|
+
delimiter: ".",
|
|
52
|
+
existingPaths: [],
|
|
53
|
+
name: "a.b",
|
|
54
|
+
}) ?? "",
|
|
55
|
+
/can't contain/,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects an exact collision with an existing path", () => {
|
|
60
|
+
assert.match(
|
|
61
|
+
validateNewFolderName({
|
|
62
|
+
...base,
|
|
63
|
+
name: "Archive",
|
|
64
|
+
parent,
|
|
65
|
+
}) ?? "",
|
|
66
|
+
/already exists/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects a root name that collides with an existing top-level fullPath", () => {
|
|
71
|
+
assert.match(
|
|
72
|
+
validateNewFolderName({
|
|
73
|
+
delimiter: "/",
|
|
74
|
+
existingPaths: ["Work"],
|
|
75
|
+
name: "Work",
|
|
76
|
+
}) ?? "",
|
|
77
|
+
/already exists/,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("treats INBOX case-insensitively for collisions", () => {
|
|
82
|
+
assert.match(
|
|
83
|
+
validateNewFolderName({
|
|
84
|
+
delimiter: "/",
|
|
85
|
+
existingPaths: ["INBOX"],
|
|
86
|
+
name: "inbox",
|
|
87
|
+
}) ?? "",
|
|
88
|
+
/already exists/,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("compares non-INBOX segments case-sensitively", () => {
|
|
93
|
+
assert.equal(
|
|
94
|
+
validateNewFolderName({
|
|
95
|
+
delimiter: "/",
|
|
96
|
+
existingPaths: ["Work"],
|
|
97
|
+
name: "work",
|
|
98
|
+
}),
|
|
99
|
+
undefined,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("accepts a valid new nested folder", () => {
|
|
104
|
+
assert.equal(
|
|
105
|
+
validateNewFolderName({ ...base, name: "Receipts", parent }),
|
|
106
|
+
undefined,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -222,13 +222,13 @@ describe("deriveUpdateSurface — the surface without a run", () => {
|
|
|
222
222
|
);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
test("a configured surface that has not checked yet is
|
|
225
|
+
test("a configured surface that has not checked yet is never-checked, not a spinner", () => {
|
|
226
226
|
const result = deriveUpdateSurface(
|
|
227
227
|
input({ data: response({ check: { status: "disabled" } }) }),
|
|
228
228
|
);
|
|
229
229
|
assert.equal(
|
|
230
230
|
result.surface.status === "ready" && result.surface.section.status,
|
|
231
|
-
"
|
|
231
|
+
"neverChecked",
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
234
|
|
|
@@ -292,10 +292,12 @@ function checkSection(
|
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
// A configured surface that has not run its first check yet
|
|
296
|
-
// not
|
|
295
|
+
// A configured surface that has not run its first check yet is never-checked,
|
|
296
|
+
// not checking: the updater runs the check on a cadence and has not written a
|
|
297
|
+
// result. A spinner here would run forever, since nothing on this poll is in
|
|
298
|
+
// flight. Only a genuine refetch (handled above) shows the spinner.
|
|
297
299
|
if (check.status === "disabled") {
|
|
298
|
-
return { status: "
|
|
300
|
+
return { status: "neverChecked", version: data.currentVersion };
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
const release = releaseFromCheck(data, now);
|
|
@@ -6,22 +6,35 @@ 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";
|
|
19
25
|
import { useState } from "react";
|
|
20
26
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
27
|
+
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
21
28
|
import {
|
|
22
29
|
CANONICAL_TO_NAV_ROLE,
|
|
30
|
+
getMailboxDisplayName,
|
|
23
31
|
NAV_ROLE_TO_CANONICAL,
|
|
24
32
|
} from "@/lib/folder-roles";
|
|
33
|
+
import {
|
|
34
|
+
composeFolderPath,
|
|
35
|
+
type FolderTarget,
|
|
36
|
+
validateNewFolderName,
|
|
37
|
+
} from "@/lib/new-folder";
|
|
25
38
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
26
39
|
|
|
27
40
|
export const Route = createFileRoute("/settings/folders")({
|
|
@@ -48,6 +61,121 @@ const foldersHelp = (
|
|
|
48
61
|
</div>
|
|
49
62
|
);
|
|
50
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Create a folder for one account. A name, an optional parent to nest under,
|
|
66
|
+
* and a create button. The new folder is queued on the server with a pending
|
|
67
|
+
* sync and appears in the list below once it refetches.
|
|
68
|
+
*/
|
|
69
|
+
function NewFolder({
|
|
70
|
+
accountId,
|
|
71
|
+
mailboxes,
|
|
72
|
+
}: {
|
|
73
|
+
accountId: string;
|
|
74
|
+
mailboxes: RemitImapMailboxResponse[];
|
|
75
|
+
}) {
|
|
76
|
+
const { mutation } = useCreateMailbox(accountId);
|
|
77
|
+
const [name, setName] = useState("");
|
|
78
|
+
const [parentId, setParentId] = useState("");
|
|
79
|
+
const [validationError, setValidationError] = useState<string>();
|
|
80
|
+
|
|
81
|
+
const accountDelimiter = mailboxes[0]?.hierarchyDelimiter ?? "/";
|
|
82
|
+
const parentMailbox = mailboxes.find((box) => box.mailboxId === parentId);
|
|
83
|
+
const parent: FolderTarget | undefined = parentMailbox
|
|
84
|
+
? {
|
|
85
|
+
fullPath: parentMailbox.fullPath,
|
|
86
|
+
hierarchyDelimiter: parentMailbox.hierarchyDelimiter,
|
|
87
|
+
}
|
|
88
|
+
: undefined;
|
|
89
|
+
const delimiter = parent?.hierarchyDelimiter ?? accountDelimiter;
|
|
90
|
+
|
|
91
|
+
const handleCreate = () => {
|
|
92
|
+
const problem = validateNewFolderName({
|
|
93
|
+
name,
|
|
94
|
+
delimiter,
|
|
95
|
+
parent,
|
|
96
|
+
existingPaths: mailboxes.map((box) => box.fullPath),
|
|
97
|
+
});
|
|
98
|
+
if (problem) {
|
|
99
|
+
setValidationError(problem);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setValidationError(undefined);
|
|
103
|
+
mutation.mutate(
|
|
104
|
+
{
|
|
105
|
+
path: { accountId },
|
|
106
|
+
body: {
|
|
107
|
+
fullPath: composeFolderPath(name, parent),
|
|
108
|
+
namespaceType: "personal",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
onSuccess: () => {
|
|
113
|
+
setName("");
|
|
114
|
+
setParentId("");
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="space-y-2 rounded-sm border border-line bg-surface p-3">
|
|
122
|
+
<p className="text-sm font-medium text-fg">New folder</p>
|
|
123
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
|
124
|
+
<div className="flex-1 space-y-1">
|
|
125
|
+
<span className="text-xs text-fg-muted">Name</span>
|
|
126
|
+
<Input
|
|
127
|
+
value={name}
|
|
128
|
+
onChange={(event) => {
|
|
129
|
+
setName(event.target.value);
|
|
130
|
+
if (validationError) setValidationError(undefined);
|
|
131
|
+
}}
|
|
132
|
+
placeholder="e.g. Receipts"
|
|
133
|
+
aria-label="Folder name"
|
|
134
|
+
onKeyDown={(event) => {
|
|
135
|
+
if (event.key === "Enter") {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
handleCreate();
|
|
138
|
+
}
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="flex-1 space-y-1">
|
|
143
|
+
<span className="text-xs text-fg-muted">Inside (optional)</span>
|
|
144
|
+
<Select
|
|
145
|
+
value={parentId}
|
|
146
|
+
onChange={(event) => setParentId(event.target.value)}
|
|
147
|
+
aria-label="Parent folder"
|
|
148
|
+
>
|
|
149
|
+
<option value="">No parent — top level</option>
|
|
150
|
+
{mailboxes.map((box) => (
|
|
151
|
+
<option key={box.mailboxId} value={box.mailboxId}>
|
|
152
|
+
{box.fullPath}
|
|
153
|
+
</option>
|
|
154
|
+
))}
|
|
155
|
+
</Select>
|
|
156
|
+
</div>
|
|
157
|
+
<Button
|
|
158
|
+
variant="primary"
|
|
159
|
+
onClick={handleCreate}
|
|
160
|
+
disabled={mutation.isPending || name.trim() === ""}
|
|
161
|
+
>
|
|
162
|
+
{mutation.isPending ? "Creating…" : "Create folder"}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
{validationError && (
|
|
166
|
+
<p className="text-xs text-danger" role="alert">
|
|
167
|
+
{validationError}
|
|
168
|
+
</p>
|
|
169
|
+
)}
|
|
170
|
+
{mutation.isError && (
|
|
171
|
+
<Banner tone="danger" variant="soft">
|
|
172
|
+
Couldn't create that folder. Please try again.
|
|
173
|
+
</Banner>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
51
179
|
/** One account's folder roles, fed to the kit list. Owns its own queries + mutations. */
|
|
52
180
|
function AccountFolderRoles({
|
|
53
181
|
account,
|
|
@@ -154,6 +282,22 @@ function AccountFolderRoles({
|
|
|
154
282
|
onAppoint={handleAppoint}
|
|
155
283
|
onRename={handleRename}
|
|
156
284
|
/>
|
|
285
|
+
<NewFolder accountId={accountId} mailboxes={data.items} />
|
|
286
|
+
<ul className="space-y-1" aria-label={`All folders for ${account.email}`}>
|
|
287
|
+
{data.items.map((mailbox) => (
|
|
288
|
+
<li
|
|
289
|
+
key={mailbox.mailboxId}
|
|
290
|
+
className="flex items-center justify-between rounded-sm px-2 py-1 text-sm text-fg"
|
|
291
|
+
>
|
|
292
|
+
<span className="truncate">
|
|
293
|
+
{getMailboxDisplayName(mailbox.fullPath)}
|
|
294
|
+
</span>
|
|
295
|
+
<span className="shrink-0 text-xs text-fg-muted">
|
|
296
|
+
{mailbox.fullPath}
|
|
297
|
+
</span>
|
|
298
|
+
</li>
|
|
299
|
+
))}
|
|
300
|
+
</ul>
|
|
157
301
|
</div>
|
|
158
302
|
);
|
|
159
303
|
}
|