@remit/web-client 0.0.75 → 0.0.77
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/LabelApplyTrigger.tsx +61 -0
- package/src/components/mail/MessageList.tsx +1 -0
- package/src/components/mail/MessageListItem.test.ts +43 -0
- package/src/components/mail/MessageListItem.tsx +1 -0
- package/src/components/mail/MoveToTrigger.tsx +2 -2
- package/src/components/mail/SelectionToolbar.render.test.ts +51 -1
- package/src/components/mail/SelectionToolbar.stories.tsx +5 -0
- package/src/components/mail/SelectionToolbar.tsx +21 -0
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +20 -0
- package/src/components/settings/DeleteFolderDialog.tsx +2 -1
- package/src/components/settings/FilterEditor.render.test.ts +1 -0
- package/src/components/settings/FilterEditor.tsx +18 -0
- package/src/components/settings/FilterEditorSurface.tsx +9 -1
- package/src/components/settings/FiltersList.render.test.ts +16 -0
- package/src/components/settings/FiltersList.tsx +32 -8
- package/src/components/settings/LabelsList.tsx +112 -0
- package/src/components/settings/settings-filter.stories.tsx +10 -1
- package/src/hooks/useApplyLabel.ts +68 -0
- package/src/hooks/useCreateMailbox.render.test.ts +109 -9
- package/src/hooks/useCreateMailbox.ts +64 -23
- package/src/hooks/useLabels.ts +124 -0
- package/src/hooks/useRuleEditorState.ts +8 -0
- package/src/lib/mailbox-sync-wait.test.ts +186 -0
- package/src/lib/mailbox-sync-wait.ts +98 -0
- package/src/lib/organize/filter-edit-model.test.ts +38 -0
- package/src/lib/organize/filter-edit-model.ts +15 -10
- package/src/lib/organize/label-delete-copy.test.ts +21 -0
- package/src/lib/organize/label-delete-copy.ts +22 -0
- package/src/lib/organize/organize-model.test.ts +35 -7
- package/src/lib/organize/organize-model.ts +17 -12
- package/src/lib/organize/rule-model.test.ts +5 -0
- package/src/lib/organize/rule-model.ts +1 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/settings/filters.tsx +19 -1
- package/src/routes/settings/labels.tsx +242 -0
- package/src/routes/settings.tsx +7 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { RemitImapLabelResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { Button, Input, LabelChip, labelColorOptions, Select } from "@remit/ui";
|
|
3
|
+
import { Trash2 } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
interface LabelsListProps {
|
|
7
|
+
labels: RemitImapLabelResponse[];
|
|
8
|
+
onRename: (labelId: string, name: string) => void;
|
|
9
|
+
onRecolor: (labelId: string, color: string) => void;
|
|
10
|
+
onDelete: (label: RemitImapLabelResponse) => void;
|
|
11
|
+
deletingLabelId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The account's labels, each renamable and recolorable inline and deletable
|
|
16
|
+
* with the cascade-confirming dialog the caller opens (issue #26). Mirrors
|
|
17
|
+
* `FiltersList`'s row shape: a name, a color chip, and a destructive action.
|
|
18
|
+
*/
|
|
19
|
+
export function LabelsList({
|
|
20
|
+
labels,
|
|
21
|
+
onRename,
|
|
22
|
+
onRecolor,
|
|
23
|
+
onDelete,
|
|
24
|
+
deletingLabelId,
|
|
25
|
+
}: LabelsListProps) {
|
|
26
|
+
const [editingId, setEditingId] = useState<string>();
|
|
27
|
+
const [draftName, setDraftName] = useState("");
|
|
28
|
+
|
|
29
|
+
if (labels.length === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<p className="py-6 text-sm text-fg-muted">
|
|
32
|
+
No labels yet. Create one below, then use it in a filter or apply it to
|
|
33
|
+
mail directly.
|
|
34
|
+
</p>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const commitRename = (labelId: string) => {
|
|
39
|
+
const trimmed = draftName.trim();
|
|
40
|
+
if (trimmed !== "") onRename(labelId, trimmed);
|
|
41
|
+
setEditingId(undefined);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<ul className="divide-y divide-line rounded-md border border-line">
|
|
46
|
+
{labels.map((label) => (
|
|
47
|
+
<li key={label.labelId} className="flex items-center gap-3 px-3 py-2.5">
|
|
48
|
+
<div className="min-w-0 flex-1">
|
|
49
|
+
{editingId === label.labelId ? (
|
|
50
|
+
<Input
|
|
51
|
+
autoFocus
|
|
52
|
+
value={draftName}
|
|
53
|
+
onChange={(event) => setDraftName(event.target.value)}
|
|
54
|
+
onBlur={() => commitRename(label.labelId)}
|
|
55
|
+
onKeyDown={(event) => {
|
|
56
|
+
if (event.key === "Enter") {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
commitRename(label.labelId);
|
|
59
|
+
}
|
|
60
|
+
if (event.key === "Escape") {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
setEditingId(undefined);
|
|
63
|
+
}
|
|
64
|
+
}}
|
|
65
|
+
aria-label={`Rename label ${label.name}`}
|
|
66
|
+
/>
|
|
67
|
+
) : (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => {
|
|
71
|
+
setEditingId(label.labelId);
|
|
72
|
+
setDraftName(label.name);
|
|
73
|
+
}}
|
|
74
|
+
aria-label={`Rename label ${label.name}`}
|
|
75
|
+
className="flex items-center gap-2 rounded-sm text-left hover:opacity-80"
|
|
76
|
+
>
|
|
77
|
+
<LabelChip label={label} />
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
80
|
+
<p className="mt-0.5 text-xs text-fg-subtle">
|
|
81
|
+
{label.filterCount === 0
|
|
82
|
+
? "Not used in any filter"
|
|
83
|
+
: `Used in ${label.filterCount} ${
|
|
84
|
+
label.filterCount === 1 ? "filter" : "filters"
|
|
85
|
+
}`}
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<Select
|
|
89
|
+
aria-label={`Color for ${label.name}`}
|
|
90
|
+
value={label.color}
|
|
91
|
+
onChange={(event) => onRecolor(label.labelId, event.target.value)}
|
|
92
|
+
className="w-28 shrink-0"
|
|
93
|
+
>
|
|
94
|
+
{labelColorOptions.map((color) => (
|
|
95
|
+
<option key={color} value={color}>
|
|
96
|
+
{color}
|
|
97
|
+
</option>
|
|
98
|
+
))}
|
|
99
|
+
</Select>
|
|
100
|
+
<Button
|
|
101
|
+
variant="ghost"
|
|
102
|
+
size="sm"
|
|
103
|
+
icon={<Trash2 className="size-4 text-danger" />}
|
|
104
|
+
onClick={() => onDelete(label)}
|
|
105
|
+
disabled={deletingLabelId === label.labelId}
|
|
106
|
+
aria-label={`Delete label ${label.name}`}
|
|
107
|
+
/>
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import { BottomSheet, type FolderOption } from "@remit/ui";
|
|
2
|
+
import { BottomSheet, type FolderOption, type LabelOption } from "@remit/ui";
|
|
3
3
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
4
4
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
5
|
import type { ReactNode } from "react";
|
|
@@ -24,6 +24,11 @@ const FOLDERS: FolderOption[] = [
|
|
|
24
24
|
{ id: "mbx-travel", label: "Travel" },
|
|
25
25
|
];
|
|
26
26
|
|
|
27
|
+
const LABELS: LabelOption[] = [
|
|
28
|
+
{ id: "lbl-receipts", name: "Receipts", color: "Blue" },
|
|
29
|
+
{ id: "lbl-travel", name: "Travel", color: "Green" },
|
|
30
|
+
];
|
|
31
|
+
|
|
27
32
|
const makeFilter = (
|
|
28
33
|
overrides: Partial<RemitImapFilterResponse> = {},
|
|
29
34
|
): RemitImapFilterResponse => ({
|
|
@@ -104,6 +109,7 @@ export const EditStandingRule: Story = {
|
|
|
104
109
|
accountId={ACCOUNT_ID}
|
|
105
110
|
filter={makeFilter()}
|
|
106
111
|
folders={FOLDERS}
|
|
112
|
+
labels={LABELS}
|
|
107
113
|
onClose={() => undefined}
|
|
108
114
|
/>
|
|
109
115
|
</SheetStage>
|
|
@@ -119,6 +125,7 @@ export const EditAnchoredRule: Story = {
|
|
|
119
125
|
accountId={ACCOUNT_ID}
|
|
120
126
|
filter={makeFilter({ hasAnchor: true, name: "GitHub" })}
|
|
121
127
|
folders={FOLDERS}
|
|
128
|
+
labels={LABELS}
|
|
122
129
|
onClose={() => undefined}
|
|
123
130
|
/>
|
|
124
131
|
</SheetStage>
|
|
@@ -138,6 +145,7 @@ export const EditUntilADate: Story = {
|
|
|
138
145
|
expiresAt: "2027-09-01T23:59:59+00:00",
|
|
139
146
|
})}
|
|
140
147
|
folders={FOLDERS}
|
|
148
|
+
labels={LABELS}
|
|
141
149
|
onClose={() => undefined}
|
|
142
150
|
/>
|
|
143
151
|
</SheetStage>
|
|
@@ -157,6 +165,7 @@ export const DegradedAnchor: Story = {
|
|
|
157
165
|
accountId={ACCOUNT_ID}
|
|
158
166
|
filter={makeFilter({ hasAnchor: true, name: "Newsletters" })}
|
|
159
167
|
folders={FOLDERS}
|
|
168
|
+
labels={LABELS}
|
|
160
169
|
semanticUnavailable
|
|
161
170
|
onClose={() => undefined}
|
|
162
171
|
/>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { messageBulkOperationsUpdateMessageLabelsMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapLabelAction,
|
|
4
|
+
RemitImapThreadMessageResponse,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import { useCallback } from "react";
|
|
8
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
9
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
10
|
+
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
11
|
+
import {
|
|
12
|
+
invalidateThreadListQueries,
|
|
13
|
+
threadListCacheKeys,
|
|
14
|
+
} from "@/lib/thread-list-cache";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply or remove a label on a "just these" selection (issue #26, RFC 034
|
|
18
|
+
* recap). No optimistic patch: a label is metadata on the row, not something
|
|
19
|
+
* that removes it from the current view, so settling on the server response
|
|
20
|
+
* and invalidating is enough — unlike a move or delete, which the row leaves
|
|
21
|
+
* immediately.
|
|
22
|
+
*/
|
|
23
|
+
export const useApplyLabel = (options: {
|
|
24
|
+
mailboxId: string;
|
|
25
|
+
accountId?: string;
|
|
26
|
+
/** The threads the selection may span, for resolving which mailbox lists to invalidate. */
|
|
27
|
+
messages?: RemitImapThreadMessageResponse[];
|
|
28
|
+
}) => {
|
|
29
|
+
const { mailboxId, messages } = options;
|
|
30
|
+
const queryClient = useQueryClient();
|
|
31
|
+
const { pushError } = useErrorBanners();
|
|
32
|
+
|
|
33
|
+
const { mutate, isPending } = useMutation({
|
|
34
|
+
...messageBulkOperationsUpdateMessageLabelsMutation(),
|
|
35
|
+
onError: (error, variables) => {
|
|
36
|
+
pushError({
|
|
37
|
+
title:
|
|
38
|
+
variables.body.action === "Remove"
|
|
39
|
+
? "Couldn't remove label"
|
|
40
|
+
: "Couldn't apply label",
|
|
41
|
+
detail: formatErrorDetail(error),
|
|
42
|
+
error,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
onSettled: (_data, _error, variables) => {
|
|
46
|
+
invalidateThreadListQueries(
|
|
47
|
+
queryClient,
|
|
48
|
+
threadListCacheKeys(
|
|
49
|
+
resolveMailboxesForMessages(
|
|
50
|
+
variables.body.messageIds ?? [],
|
|
51
|
+
messages ?? [],
|
|
52
|
+
mailboxId,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const applyLabel = useCallback(
|
|
60
|
+
(messageIds: string[], labelId: string, action: RemitImapLabelAction) => {
|
|
61
|
+
if (messageIds.length === 0) return;
|
|
62
|
+
mutate({ body: { messageIds, labelId, action } });
|
|
63
|
+
},
|
|
64
|
+
[mutate],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return { applyLabel, isPending };
|
|
68
|
+
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useCreateMailbox.createFolder — the shared create seam the kit surfaces call
|
|
3
|
-
* It validates the typed name against the account's
|
|
4
|
-
* same IMAP-aware rules the settings form uses,
|
|
5
|
-
* human-readable reason before any request
|
|
6
|
-
*
|
|
2
|
+
* useCreateMailbox.createFolder — the shared create seam the kit surfaces call
|
|
3
|
+
* for a dependent write. It validates the typed name against the account's
|
|
4
|
+
* current folders with the same IMAP-aware rules the settings form uses, rejects
|
|
5
|
+
* with the human-readable reason before any request, then waits for the mail
|
|
6
|
+
* server to confirm the folder before resolving — so a filter or a move never
|
|
7
|
+
* binds to a still-pending row. The mailbox list is seeded into the query cache
|
|
8
|
+
* the hook reads, so validation runs against real paths.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import assert from "node:assert/strict";
|
|
@@ -13,8 +15,10 @@ import type {
|
|
|
13
15
|
MailboxOperationsListMailboxesResponse,
|
|
14
16
|
RemitImapMailboxResponse,
|
|
15
17
|
} from "@remit/api-http-client/types.gen.ts";
|
|
18
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
16
19
|
import type { FolderOption } from "@remit/ui";
|
|
17
20
|
import { act, createElement } from "react";
|
|
21
|
+
import { MAILBOX_SYNC_FAILED_MESSAGE } from "../lib/mailbox-sync-wait";
|
|
18
22
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
19
23
|
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
20
24
|
import { useCreateMailbox } from "./useCreateMailbox";
|
|
@@ -23,7 +27,9 @@ const ACCOUNT = "acc-1";
|
|
|
23
27
|
|
|
24
28
|
let harness: DomHarness | undefined;
|
|
25
29
|
let http: HttpMock | undefined;
|
|
26
|
-
let createFolder:
|
|
30
|
+
let createFolder:
|
|
31
|
+
| ((name: string, signal?: AbortSignal) => Promise<FolderOption>)
|
|
32
|
+
| undefined;
|
|
27
33
|
|
|
28
34
|
afterEach(() => {
|
|
29
35
|
harness?.close();
|
|
@@ -49,13 +55,23 @@ function Probe() {
|
|
|
49
55
|
return null;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
const mount = (
|
|
58
|
+
const mount = (
|
|
59
|
+
items: RemitImapMailboxResponse[],
|
|
60
|
+
createdSyncStatus: RemitImapMailboxResponse["syncStatus"] = MailboxSyncStatus.synced,
|
|
61
|
+
) => {
|
|
62
|
+
const created: RemitImapMailboxResponse[] = [];
|
|
53
63
|
http = mockFetch((call) => {
|
|
54
64
|
if (call.method === "POST") {
|
|
55
65
|
const body = call.body as { fullPath: string };
|
|
66
|
+
created.push({
|
|
67
|
+
mailboxId: `mbx-${body.fullPath}`,
|
|
68
|
+
accountId: ACCOUNT,
|
|
69
|
+
fullPath: body.fullPath,
|
|
70
|
+
syncStatus: createdSyncStatus,
|
|
71
|
+
} as RemitImapMailboxResponse);
|
|
56
72
|
return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
|
|
57
73
|
}
|
|
58
|
-
return { items };
|
|
74
|
+
return { items: [...items, ...created] };
|
|
59
75
|
});
|
|
60
76
|
harness = createDomHarness();
|
|
61
77
|
harness.queryClient.setQueryData<MailboxOperationsListMailboxesResponse>(
|
|
@@ -114,7 +130,7 @@ describe("useCreateMailbox.createFolder validation", () => {
|
|
|
114
130
|
assert.equal(postCount(), 0);
|
|
115
131
|
});
|
|
116
132
|
|
|
117
|
-
it("passes a valid name through
|
|
133
|
+
it("passes a valid name through and resolves once the folder is confirmed synced", async () => {
|
|
118
134
|
mount([mailbox("INBOX", "/")]);
|
|
119
135
|
let result: FolderOption | undefined;
|
|
120
136
|
await act(async () => {
|
|
@@ -126,6 +142,90 @@ describe("useCreateMailbox.createFolder validation", () => {
|
|
|
126
142
|
fullPath: "Taxes",
|
|
127
143
|
namespaceType: "personal",
|
|
128
144
|
});
|
|
145
|
+
// It polled the list after the create to confirm the folder before resolving.
|
|
146
|
+
const gets = (http?.calls ?? []).filter((call) => call.method === "GET");
|
|
147
|
+
assert.ok(gets.length >= 1, "polls the mailbox list for confirmation");
|
|
129
148
|
assert.equal(result?.label, "Taxes");
|
|
130
149
|
});
|
|
150
|
+
|
|
151
|
+
it("rejects — no folder to bind a dependent write to — when the create is reported failed", async () => {
|
|
152
|
+
mount([mailbox("INBOX", "/")], MailboxSyncStatus.failed);
|
|
153
|
+
let caught: unknown;
|
|
154
|
+
await act(async () => {
|
|
155
|
+
caught = await createFolder?.("Taxes").then(
|
|
156
|
+
() => undefined,
|
|
157
|
+
(error: unknown) => error,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
assert.ok(caught instanceof Error);
|
|
161
|
+
assert.equal(caught.message, MAILBOX_SYNC_FAILED_MESSAGE);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("retry resumes the wait on the folder it already made — no second create, no 'already exists'", async () => {
|
|
165
|
+
// The created folder is reported failed on the first attempt, then synced.
|
|
166
|
+
let status: RemitImapMailboxResponse["syncStatus"] =
|
|
167
|
+
MailboxSyncStatus.failed;
|
|
168
|
+
const created: RemitImapMailboxResponse[] = [];
|
|
169
|
+
http = mockFetch((call) => {
|
|
170
|
+
if (call.method === "POST") {
|
|
171
|
+
const body = call.body as { fullPath: string };
|
|
172
|
+
created.push({
|
|
173
|
+
mailboxId: `mbx-${body.fullPath}`,
|
|
174
|
+
accountId: ACCOUNT,
|
|
175
|
+
fullPath: body.fullPath,
|
|
176
|
+
} as RemitImapMailboxResponse);
|
|
177
|
+
return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
items: [
|
|
181
|
+
mailbox("INBOX", "/"),
|
|
182
|
+
...created.map((entry) => ({ ...entry, syncStatus: status })),
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
harness = createDomHarness();
|
|
187
|
+
harness.queryClient.setQueryData<MailboxOperationsListMailboxesResponse>(
|
|
188
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT } }),
|
|
189
|
+
{ items: [mailbox("INBOX", "/")] },
|
|
190
|
+
);
|
|
191
|
+
harness.renderApp(createElement(Probe));
|
|
192
|
+
|
|
193
|
+
let first: unknown;
|
|
194
|
+
await act(async () => {
|
|
195
|
+
first = await createFolder?.("Taxes").then(
|
|
196
|
+
() => undefined,
|
|
197
|
+
(error: unknown) => error,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
assert.ok(first instanceof Error);
|
|
201
|
+
assert.equal(first.message, MAILBOX_SYNC_FAILED_MESSAGE);
|
|
202
|
+
|
|
203
|
+
// The server confirms; the user presses "Create folder" again, same name.
|
|
204
|
+
status = MailboxSyncStatus.synced;
|
|
205
|
+
let result: FolderOption | undefined;
|
|
206
|
+
await act(async () => {
|
|
207
|
+
result = await createFolder?.("Taxes");
|
|
208
|
+
});
|
|
209
|
+
assert.equal(result?.label, "Taxes");
|
|
210
|
+
|
|
211
|
+
// Exactly one create across both attempts — the retry resumed, it did not
|
|
212
|
+
// re-validate (which would throw "already exists") or re-POST.
|
|
213
|
+
const posts = (http?.calls ?? []).filter((call) => call.method === "POST");
|
|
214
|
+
assert.equal(posts.length, 1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("abort stops the wait so a folder that confirms later never resolves", async () => {
|
|
218
|
+
mount([mailbox("INBOX", "/")], MailboxSyncStatus.pending);
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
let caught: unknown;
|
|
221
|
+
await act(async () => {
|
|
222
|
+
const promise = createFolder?.("Taxes", controller.signal);
|
|
223
|
+
controller.abort();
|
|
224
|
+
caught = await promise?.then(
|
|
225
|
+
() => undefined,
|
|
226
|
+
(error: unknown) => error,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
assert.equal((caught as { name?: string })?.name, "AbortError");
|
|
230
|
+
});
|
|
131
231
|
});
|
|
@@ -5,21 +5,40 @@ import {
|
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
6
|
import type { FolderOption } from "@remit/ui";
|
|
7
7
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
-
import { useCallback } from "react";
|
|
8
|
+
import { useCallback, useRef } from "react";
|
|
9
9
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
10
|
+
import { waitForMailboxSynced } from "@/lib/mailbox-sync-wait";
|
|
10
11
|
import { composeFolderPath, validateNewFolderName } from "@/lib/new-folder";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Creates a mailbox for an account and refreshes the folder list on success.
|
|
14
15
|
* The backend creates the row with a pending sync status and queues the IMAP
|
|
15
|
-
* create
|
|
16
|
+
* create.
|
|
16
17
|
*
|
|
17
|
-
* `createFolder`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* `createFolder` is the seam for dependent writes: a folder created so a filter
|
|
19
|
+
* can move mail into it, or so a move can land mail there. It takes a leaf name,
|
|
20
|
+
* validates it against the account's current folders with the same IMAP-aware
|
|
21
|
+
* rules the settings form uses (non-empty, no hierarchy delimiter, no collision —
|
|
22
|
+
* INBOX case-insensitive), and rejects with the human-readable reason before any
|
|
23
|
+
* request. It then WAITS for the mail server to confirm the folder before
|
|
24
|
+
* resolving — a folder is not a valid target until it exists on the server, and
|
|
25
|
+
* binding a filter or a move to a still-pending row races the folder into
|
|
26
|
+
* existence and cannot report a create that fails. It resolves with the confirmed
|
|
27
|
+
* folder (carrying the path the server normalized to), rejects with a distinct
|
|
28
|
+
* message when the create fails or never confirms, and the kit surfaces that
|
|
29
|
+
* render either the "Creating folder…" wait or the failure inline.
|
|
30
|
+
*
|
|
31
|
+
* Retry is a resume, not a re-create: a create that timed out or failed leaves
|
|
32
|
+
* the row already made, so pressing "Create folder" again calls `createFolder`
|
|
33
|
+
* with the same name — which resumes the wait on the mailboxId it already made
|
|
34
|
+
* rather than re-validating (the pending row would collide as "already exists")
|
|
35
|
+
* and re-POSTing. The mailboxId is carried per-name until the folder confirms.
|
|
36
|
+
*
|
|
37
|
+
* `createFolder` takes an `AbortSignal` the surface aborts on unmount/cancel/
|
|
38
|
+
* close, so a folder that confirms after the surface is gone resolves nothing.
|
|
39
|
+
*
|
|
40
|
+
* `mutation` is exposed for callers that drive their own form state and want the
|
|
41
|
+
* optimistic, non-waiting create (the standalone settings create).
|
|
23
42
|
*/
|
|
24
43
|
export function useCreateMailbox(accountId: string) {
|
|
25
44
|
const queryClient = useQueryClient();
|
|
@@ -39,26 +58,48 @@ export function useCreateMailbox(accountId: string) {
|
|
|
39
58
|
},
|
|
40
59
|
});
|
|
41
60
|
|
|
61
|
+
// fullPath -> mailboxId for a folder created but not yet confirmed, so a retry
|
|
62
|
+
// resumes the wait on it instead of re-creating. Cleared once it confirms.
|
|
63
|
+
const pendingByPath = useRef(new Map<string, string>());
|
|
64
|
+
|
|
42
65
|
const createFolder = useCallback(
|
|
43
|
-
async (name: string): Promise<FolderOption> => {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
delimiter
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
async (name: string, signal?: AbortSignal): Promise<FolderOption> => {
|
|
67
|
+
const fullPath = composeFolderPath(name);
|
|
68
|
+
let mailboxId = pendingByPath.current.get(fullPath);
|
|
69
|
+
if (!mailboxId) {
|
|
70
|
+
const items = data?.items ?? [];
|
|
71
|
+
const delimiter = items[0]?.hierarchyDelimiter ?? "/";
|
|
72
|
+
const problem = validateNewFolderName({
|
|
73
|
+
name,
|
|
74
|
+
delimiter,
|
|
75
|
+
existingPaths: items.map((item) => item.fullPath),
|
|
76
|
+
});
|
|
77
|
+
if (problem) throw new Error(problem);
|
|
78
|
+
const mailbox = await mutation.mutateAsync({
|
|
79
|
+
path: { accountId },
|
|
80
|
+
body: { fullPath, namespaceType: "personal" },
|
|
81
|
+
});
|
|
82
|
+
mailboxId = mailbox.mailboxId;
|
|
83
|
+
pendingByPath.current.set(fullPath, mailboxId);
|
|
84
|
+
}
|
|
85
|
+
const confirmed = await waitForMailboxSynced({
|
|
86
|
+
mailboxId,
|
|
87
|
+
signal,
|
|
88
|
+
fetchMailboxes: async () => {
|
|
89
|
+
const response = await queryClient.fetchQuery({
|
|
90
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
91
|
+
staleTime: 0,
|
|
92
|
+
});
|
|
93
|
+
return response.items ?? [];
|
|
94
|
+
},
|
|
55
95
|
});
|
|
96
|
+
pendingByPath.current.delete(fullPath);
|
|
56
97
|
return {
|
|
57
|
-
id:
|
|
58
|
-
label: getMailboxDisplayName(
|
|
98
|
+
id: confirmed.mailboxId,
|
|
99
|
+
label: getMailboxDisplayName(confirmed.fullPath),
|
|
59
100
|
};
|
|
60
101
|
},
|
|
61
|
-
[mutation, accountId, data],
|
|
102
|
+
[mutation, accountId, data, queryClient],
|
|
62
103
|
);
|
|
63
104
|
|
|
64
105
|
return { createFolder, mutation };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
labelDetailOperationsDeleteLabelMutation,
|
|
3
|
+
labelDetailOperationsUpdateLabelMutation,
|
|
4
|
+
labelOperationsCreateLabelMutation,
|
|
5
|
+
labelOperationsListLabelsOptions,
|
|
6
|
+
labelOperationsListLabelsQueryKey,
|
|
7
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
|
+
import type {
|
|
9
|
+
RemitImapLabelColor,
|
|
10
|
+
RemitImapUpdateLabelInput,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
import { useCallback } from "react";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The query key the label list reads and every mutation invalidates on
|
|
17
|
+
* success — the same contract `buildFilterListKey` pins for filters, so the
|
|
18
|
+
* settings list and the rule editor's label picker never go stale after a
|
|
19
|
+
* label is created, renamed, recolored, or deleted (issue #26).
|
|
20
|
+
*/
|
|
21
|
+
export const buildLabelListKey = (accountId: string) =>
|
|
22
|
+
labelOperationsListLabelsQueryKey({ path: { accountId } });
|
|
23
|
+
|
|
24
|
+
/** List the account's labels. */
|
|
25
|
+
export const useLabelList = (accountId: string | undefined) => {
|
|
26
|
+
const query = useQuery({
|
|
27
|
+
...labelOperationsListLabelsOptions({
|
|
28
|
+
path: { accountId: accountId ?? "" },
|
|
29
|
+
}),
|
|
30
|
+
enabled: !!accountId,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
labels: query.data?.items ?? [],
|
|
35
|
+
isPending: query.isPending,
|
|
36
|
+
isError: query.isError,
|
|
37
|
+
error: query.error,
|
|
38
|
+
refetch: query.refetch,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const useCreateLabel = (accountId: string | undefined) => {
|
|
43
|
+
const queryClient = useQueryClient();
|
|
44
|
+
const mutation = useMutation({
|
|
45
|
+
...labelOperationsCreateLabelMutation(),
|
|
46
|
+
onSuccess: () => {
|
|
47
|
+
if (!accountId) return;
|
|
48
|
+
queryClient.invalidateQueries({ queryKey: buildLabelListKey(accountId) });
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const { mutateAsync } = mutation;
|
|
52
|
+
|
|
53
|
+
const createLabel = useCallback(
|
|
54
|
+
(name: string, color: RemitImapLabelColor = "Default") => {
|
|
55
|
+
if (!accountId) return Promise.reject(new Error("No account"));
|
|
56
|
+
return mutateAsync({ path: { accountId }, body: { name, color } });
|
|
57
|
+
},
|
|
58
|
+
[accountId, mutateAsync],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
createLabel,
|
|
63
|
+
isPending: mutation.isPending,
|
|
64
|
+
isError: mutation.isError,
|
|
65
|
+
error: mutation.error,
|
|
66
|
+
reset: mutation.reset,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const useUpdateLabel = (accountId: string | undefined) => {
|
|
71
|
+
const queryClient = useQueryClient();
|
|
72
|
+
const mutation = useMutation({
|
|
73
|
+
...labelDetailOperationsUpdateLabelMutation(),
|
|
74
|
+
onSuccess: () => {
|
|
75
|
+
if (!accountId) return;
|
|
76
|
+
queryClient.invalidateQueries({ queryKey: buildLabelListKey(accountId) });
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const { mutate } = mutation;
|
|
80
|
+
|
|
81
|
+
const updateLabel = useCallback(
|
|
82
|
+
(labelId: string, input: RemitImapUpdateLabelInput) => {
|
|
83
|
+
if (!accountId) return;
|
|
84
|
+
mutate({ path: { accountId, labelId }, body: input });
|
|
85
|
+
},
|
|
86
|
+
[accountId, mutate],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
updateLabel,
|
|
91
|
+
isPending: mutation.isPending,
|
|
92
|
+
updatingLabelId: mutation.isPending
|
|
93
|
+
? mutation.variables?.path.labelId
|
|
94
|
+
: undefined,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const useDeleteLabel = (accountId: string | undefined) => {
|
|
99
|
+
const queryClient = useQueryClient();
|
|
100
|
+
const mutation = useMutation({
|
|
101
|
+
...labelDetailOperationsDeleteLabelMutation(),
|
|
102
|
+
onSuccess: () => {
|
|
103
|
+
if (!accountId) return;
|
|
104
|
+
queryClient.invalidateQueries({ queryKey: buildLabelListKey(accountId) });
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const { mutate } = mutation;
|
|
108
|
+
|
|
109
|
+
const deleteLabel = useCallback(
|
|
110
|
+
(labelId: string) => {
|
|
111
|
+
if (!accountId) return;
|
|
112
|
+
mutate({ path: { accountId, labelId } });
|
|
113
|
+
},
|
|
114
|
+
[accountId, mutate],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
deleteLabel,
|
|
119
|
+
isPending: mutation.isPending,
|
|
120
|
+
deletingLabelId: mutation.isPending
|
|
121
|
+
? mutation.variables?.path.labelId
|
|
122
|
+
: undefined,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
@@ -28,6 +28,7 @@ export interface RuleEditorState {
|
|
|
28
28
|
onRemoveWiden: () => void;
|
|
29
29
|
onChangeMatchOperator: (matchOperator: MatchOperator) => void;
|
|
30
30
|
onChangeMove: (mailboxId: string) => void;
|
|
31
|
+
onChangeLabel: (labelId: string) => void;
|
|
31
32
|
onChangeScope: (scope: RuleScope) => void;
|
|
32
33
|
onChangeName: (name: string) => void;
|
|
33
34
|
onChangeUntil: (until: string) => void;
|
|
@@ -141,6 +142,12 @@ export const useRuleEditorState = ({
|
|
|
141
142
|
moveMailboxId: mailboxId || undefined,
|
|
142
143
|
}));
|
|
143
144
|
|
|
145
|
+
const changeLabel = (labelId: string) =>
|
|
146
|
+
setRuleState((current) => ({
|
|
147
|
+
...current,
|
|
148
|
+
labelId: labelId || undefined,
|
|
149
|
+
}));
|
|
150
|
+
|
|
144
151
|
const changeScope = (scope: RuleScope) =>
|
|
145
152
|
setRuleState((current) => ({ ...current, scope }));
|
|
146
153
|
|
|
@@ -166,6 +173,7 @@ export const useRuleEditorState = ({
|
|
|
166
173
|
onRemoveWiden: removeWiden,
|
|
167
174
|
onChangeMatchOperator: changeMatchOperator,
|
|
168
175
|
onChangeMove: changeMove,
|
|
176
|
+
onChangeLabel: changeLabel,
|
|
169
177
|
onChangeScope: changeScope,
|
|
170
178
|
onChangeName: changeName,
|
|
171
179
|
onChangeUntil: changeUntil,
|