@remit/web-client 0.0.76 → 0.0.78
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/MailboxPane.tsx +1 -34
- 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/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/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/useLabels.ts +124 -0
- package/src/hooks/useRuleEditorState.ts +8 -0
- package/src/lib/inbox-filters.test.ts +106 -0
- package/src/lib/inbox-filters.ts +44 -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,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delete-confirmation copy for a label (issue #26). Deleting a label
|
|
3
|
+
* cascades in both directions server-side — every filter whose action applies
|
|
4
|
+
* it is deleted outright, never left dangling — so the confirmation names the
|
|
5
|
+
* filter count up front rather than surprising the user after the fact. The
|
|
6
|
+
* API itself never blocks the delete; the confirmation is the only gate.
|
|
7
|
+
*/
|
|
8
|
+
export const deleteLabelConfirmCopy = (
|
|
9
|
+
labelName: string,
|
|
10
|
+
filterCount: number,
|
|
11
|
+
): { title: string; description?: string } => {
|
|
12
|
+
if (filterCount === 0) {
|
|
13
|
+
return { title: `Delete the "${labelName}" label?` };
|
|
14
|
+
}
|
|
15
|
+
const filterNoun = filterCount === 1 ? "filter" : "filters";
|
|
16
|
+
return {
|
|
17
|
+
title: `Delete the "${labelName}" label?`,
|
|
18
|
+
description: `This also deletes ${filterCount} ${filterNoun} that ${
|
|
19
|
+
filterCount === 1 ? "applies" : "apply"
|
|
20
|
+
} it — they can't be recovered.`,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
@@ -15,13 +15,15 @@ const baseDraft = (overrides: Partial<OrganizeDraft> = {}): OrganizeDraft => ({
|
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
describe("hasCommittableAction", () => {
|
|
18
|
-
it("is false when
|
|
18
|
+
it("is false when neither a move nor a label is chosen — a keep-in-place draft has nothing to commit", () => {
|
|
19
19
|
assert.equal(hasCommittableAction(baseDraft()), false);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("is false for the None sentinel", () => {
|
|
22
|
+
it("is false for the None sentinel on both actions", () => {
|
|
23
23
|
assert.equal(
|
|
24
|
-
hasCommittableAction(
|
|
24
|
+
hasCommittableAction(
|
|
25
|
+
baseDraft({ moveMailboxId: NO_ACTION, labelId: NO_ACTION }),
|
|
26
|
+
),
|
|
25
27
|
false,
|
|
26
28
|
);
|
|
27
29
|
});
|
|
@@ -32,10 +34,23 @@ describe("hasCommittableAction", () => {
|
|
|
32
34
|
true,
|
|
33
35
|
);
|
|
34
36
|
});
|
|
37
|
+
|
|
38
|
+
it("is true once a real label is chosen, with no move target (issue #26)", () => {
|
|
39
|
+
assert.equal(hasCommittableAction(baseDraft({ labelId: "lbl-1" })), true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("is true when both a move and a label are chosen", () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
hasCommittableAction(
|
|
45
|
+
baseDraft({ moveMailboxId: "mbx-1", labelId: "lbl-1" }),
|
|
46
|
+
),
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
35
50
|
});
|
|
36
51
|
|
|
37
52
|
describe("buildOrganizeInput", () => {
|
|
38
|
-
it("carries the anchor and defaults
|
|
53
|
+
it("carries the anchor and defaults both actions to None when neither is set", () => {
|
|
39
54
|
const input = buildOrganizeInput(baseDraft({ anchorMessageId: "msg-1" }));
|
|
40
55
|
assert.equal(input.anchorMessageId, "msg-1");
|
|
41
56
|
assert.equal(input.actionMailboxId, NO_ACTION);
|
|
@@ -53,12 +68,16 @@ describe("buildOrganizeInput", () => {
|
|
|
53
68
|
assert.equal("anchorMessageId" in input, false);
|
|
54
69
|
});
|
|
55
70
|
|
|
56
|
-
it("
|
|
71
|
+
it("carries the move and label actions independently (issue #26)", () => {
|
|
57
72
|
const input = buildOrganizeInput(
|
|
58
|
-
baseDraft({
|
|
73
|
+
baseDraft({
|
|
74
|
+
anchorMessageId: "msg-1",
|
|
75
|
+
moveMailboxId: "mbx-9",
|
|
76
|
+
labelId: "lbl-9",
|
|
77
|
+
}),
|
|
59
78
|
);
|
|
60
79
|
assert.equal(input.actionMailboxId, "mbx-9");
|
|
61
|
-
assert.equal(input.actionLabelId,
|
|
80
|
+
assert.equal(input.actionLabelId, "lbl-9");
|
|
62
81
|
});
|
|
63
82
|
});
|
|
64
83
|
|
|
@@ -101,4 +120,13 @@ describe("buildCreateFilterInput", () => {
|
|
|
101
120
|
);
|
|
102
121
|
assert.equal("ttl" in input, false);
|
|
103
122
|
});
|
|
123
|
+
|
|
124
|
+
it("carries a label action (issue #26)", () => {
|
|
125
|
+
const input = buildCreateFilterInput(
|
|
126
|
+
baseDraft({ labelId: "lbl-1" }),
|
|
127
|
+
"standing",
|
|
128
|
+
"Receipts",
|
|
129
|
+
);
|
|
130
|
+
assert.equal(input.actionLabelId, "lbl-1");
|
|
131
|
+
});
|
|
104
132
|
});
|
|
@@ -27,9 +27,10 @@ export const NO_ACTION = "None";
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* The user's in-progress organize decision, independent of which scope they
|
|
30
|
-
* land on. The anchor and predicate drive the match set; `moveMailboxId`
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* land on. The anchor and predicate drive the match set; `moveMailboxId` and
|
|
31
|
+
* `labelId` are the two committable actions (issue #26) — independent of each
|
|
32
|
+
* other, since a move is exclusive and a label is additive (RFC 034 Decision
|
|
33
|
+
* 3.1).
|
|
33
34
|
*/
|
|
34
35
|
export interface OrganizeDraft {
|
|
35
36
|
/** Semantic anchor — "mail like this one". The first selected message. */
|
|
@@ -43,6 +44,11 @@ export interface OrganizeDraft {
|
|
|
43
44
|
* Decision 3.1). Absent means "keep where they are": no move action.
|
|
44
45
|
*/
|
|
45
46
|
moveMailboxId?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Label applied to the match set — the additive action (RFC 034 Decision
|
|
49
|
+
* 3.1, issue #26). Absent applies no label.
|
|
50
|
+
*/
|
|
51
|
+
labelId?: string;
|
|
46
52
|
/**
|
|
47
53
|
* ISO 8601 date-time with zone offset. Present only for the `temporary`
|
|
48
54
|
* scope; a plain picked date (RFC 034 non-goal: no event-based expiry).
|
|
@@ -51,14 +57,13 @@ export interface OrganizeDraft {
|
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
/**
|
|
54
|
-
* Whether the draft carries a committable action
|
|
55
|
-
* (
|
|
56
|
-
*
|
|
57
|
-
* with no move target has nothing to commit; the caller disables the CTA and
|
|
58
|
-
* says why (ux.md).
|
|
60
|
+
* Whether the draft carries a committable action — a move destination and/or
|
|
61
|
+
* a label (issue #26), either satisfies this. A draft with neither has
|
|
62
|
+
* nothing to commit; the caller disables the CTA and says why (ux.md).
|
|
59
63
|
*/
|
|
60
64
|
export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
|
|
61
|
-
draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION
|
|
65
|
+
(draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION) ||
|
|
66
|
+
(draft.labelId !== undefined && draft.labelId !== NO_ACTION);
|
|
62
67
|
|
|
63
68
|
/**
|
|
64
69
|
* Whether the draft's predicate can be back-applied over the existing corpus. A
|
|
@@ -75,7 +80,7 @@ export const canBackApplyDraft = (draft: OrganizeDraft): boolean =>
|
|
|
75
80
|
* Build the read-only preview / back-apply matcher input. The action fields do
|
|
76
81
|
* not affect which messages match — the preview returns exactly the set a job
|
|
77
82
|
* with the same predicate would apply to — so a widen preview can pass this
|
|
78
|
-
* before the user has chosen a folder.
|
|
83
|
+
* before the user has chosen a folder or a label.
|
|
79
84
|
*/
|
|
80
85
|
export const buildOrganizeInput = (
|
|
81
86
|
draft: OrganizeDraft,
|
|
@@ -83,7 +88,7 @@ export const buildOrganizeInput = (
|
|
|
83
88
|
...(draft.anchorMessageId ? { anchorMessageId: draft.anchorMessageId } : {}),
|
|
84
89
|
matchOperator: draft.matchOperator,
|
|
85
90
|
literalClauses: draft.literalClauses,
|
|
86
|
-
actionLabelId: NO_ACTION,
|
|
91
|
+
actionLabelId: draft.labelId ?? NO_ACTION,
|
|
87
92
|
actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
|
|
88
93
|
});
|
|
89
94
|
|
|
@@ -107,7 +112,7 @@ export const buildCreateFilterInput = (
|
|
|
107
112
|
: {}),
|
|
108
113
|
matchOperator: draft.matchOperator,
|
|
109
114
|
literalClauses: draft.literalClauses,
|
|
110
|
-
actionLabelId: NO_ACTION,
|
|
115
|
+
actionLabelId: draft.labelId ?? NO_ACTION,
|
|
111
116
|
actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
|
|
112
117
|
...(draft.anchorMessageId
|
|
113
118
|
? { anchorMessageId: draft.anchorMessageId }
|
|
@@ -247,6 +247,11 @@ describe("the previewed set equals the applied set", () => {
|
|
|
247
247
|
"mbx-archive",
|
|
248
248
|
);
|
|
249
249
|
});
|
|
250
|
+
|
|
251
|
+
it("carries the label target into the draft (issue #26)", () => {
|
|
252
|
+
const labeled = { ...semanticRule, labelId: "lbl-receipts" };
|
|
253
|
+
assert.equal(ruleToDraft(labeled, "msg-1").labelId, "lbl-receipts");
|
|
254
|
+
});
|
|
250
255
|
});
|
|
251
256
|
|
|
252
257
|
describe("derivePreview", () => {
|
|
@@ -164,6 +164,7 @@ export const ruleToDraft = (
|
|
|
164
164
|
matchOperator: predicate.matchOperator,
|
|
165
165
|
literalClauses: predicate.literalClauses,
|
|
166
166
|
moveMailboxId: rule.moveMailboxId,
|
|
167
|
+
labelId: rule.labelId,
|
|
167
168
|
expiresAt:
|
|
168
169
|
rule.scope === "until"
|
|
169
170
|
? pickedDateToExpiresAt(rule.until ?? "")
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced
|
|
|
23
23
|
import { Route as SettingsAppearanceRouteImport } from './routes/settings/appearance'
|
|
24
24
|
import { Route as SettingsFiltersRouteImport } from './routes/settings/filters'
|
|
25
25
|
import { Route as SettingsFoldersRouteImport } from './routes/settings/folders'
|
|
26
|
+
import { Route as SettingsLabelsRouteImport } from './routes/settings/labels'
|
|
26
27
|
import { Route as SettingsSendersRouteImport } from './routes/settings/senders'
|
|
27
28
|
import { Route as SettingsSuggestedVipsRouteImport } from './routes/settings/suggested-vips'
|
|
28
29
|
|
|
@@ -96,6 +97,11 @@ const SettingsFoldersRoute = SettingsFoldersRouteImport.update({
|
|
|
96
97
|
path: '/folders',
|
|
97
98
|
getParentRoute: () => SettingsRoute,
|
|
98
99
|
} as any)
|
|
100
|
+
const SettingsLabelsRoute = SettingsLabelsRouteImport.update({
|
|
101
|
+
id: '/labels',
|
|
102
|
+
path: '/labels',
|
|
103
|
+
getParentRoute: () => SettingsRoute,
|
|
104
|
+
} as any)
|
|
99
105
|
const SettingsSendersRoute = SettingsSendersRouteImport.update({
|
|
100
106
|
id: '/senders',
|
|
101
107
|
path: '/senders',
|
|
@@ -120,6 +126,7 @@ export interface FileRoutesByFullPath {
|
|
|
120
126
|
'/settings/appearance': typeof SettingsAppearanceRoute
|
|
121
127
|
'/settings/filters': typeof SettingsFiltersRoute
|
|
122
128
|
'/settings/folders': typeof SettingsFoldersRoute
|
|
129
|
+
'/settings/labels': typeof SettingsLabelsRoute
|
|
123
130
|
'/settings/senders': typeof SettingsSendersRoute
|
|
124
131
|
'/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
|
|
125
132
|
'/mail/': typeof MailIndexRoute
|
|
@@ -136,6 +143,7 @@ export interface FileRoutesByTo {
|
|
|
136
143
|
'/settings/appearance': typeof SettingsAppearanceRoute
|
|
137
144
|
'/settings/filters': typeof SettingsFiltersRoute
|
|
138
145
|
'/settings/folders': typeof SettingsFoldersRoute
|
|
146
|
+
'/settings/labels': typeof SettingsLabelsRoute
|
|
139
147
|
'/settings/senders': typeof SettingsSendersRoute
|
|
140
148
|
'/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
|
|
141
149
|
'/mail': typeof MailIndexRoute
|
|
@@ -155,6 +163,7 @@ export interface FileRoutesById {
|
|
|
155
163
|
'/settings/appearance': typeof SettingsAppearanceRoute
|
|
156
164
|
'/settings/filters': typeof SettingsFiltersRoute
|
|
157
165
|
'/settings/folders': typeof SettingsFoldersRoute
|
|
166
|
+
'/settings/labels': typeof SettingsLabelsRoute
|
|
158
167
|
'/settings/senders': typeof SettingsSendersRoute
|
|
159
168
|
'/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
|
|
160
169
|
'/mail/': typeof MailIndexRoute
|
|
@@ -175,6 +184,7 @@ export interface FileRouteTypes {
|
|
|
175
184
|
| '/settings/appearance'
|
|
176
185
|
| '/settings/filters'
|
|
177
186
|
| '/settings/folders'
|
|
187
|
+
| '/settings/labels'
|
|
178
188
|
| '/settings/senders'
|
|
179
189
|
| '/settings/suggested-vips'
|
|
180
190
|
| '/mail/'
|
|
@@ -191,6 +201,7 @@ export interface FileRouteTypes {
|
|
|
191
201
|
| '/settings/appearance'
|
|
192
202
|
| '/settings/filters'
|
|
193
203
|
| '/settings/folders'
|
|
204
|
+
| '/settings/labels'
|
|
194
205
|
| '/settings/senders'
|
|
195
206
|
| '/settings/suggested-vips'
|
|
196
207
|
| '/mail'
|
|
@@ -209,6 +220,7 @@ export interface FileRouteTypes {
|
|
|
209
220
|
| '/settings/appearance'
|
|
210
221
|
| '/settings/filters'
|
|
211
222
|
| '/settings/folders'
|
|
223
|
+
| '/settings/labels'
|
|
212
224
|
| '/settings/senders'
|
|
213
225
|
| '/settings/suggested-vips'
|
|
214
226
|
| '/mail/'
|
|
@@ -322,6 +334,13 @@ declare module '@tanstack/react-router' {
|
|
|
322
334
|
preLoaderRoute: typeof SettingsFoldersRouteImport
|
|
323
335
|
parentRoute: typeof SettingsRoute
|
|
324
336
|
}
|
|
337
|
+
'/settings/labels': {
|
|
338
|
+
id: '/settings/labels'
|
|
339
|
+
path: '/labels'
|
|
340
|
+
fullPath: '/settings/labels'
|
|
341
|
+
preLoaderRoute: typeof SettingsLabelsRouteImport
|
|
342
|
+
parentRoute: typeof SettingsRoute
|
|
343
|
+
}
|
|
325
344
|
'/settings/senders': {
|
|
326
345
|
id: '/settings/senders'
|
|
327
346
|
path: '/senders'
|
|
@@ -361,6 +380,7 @@ interface SettingsRouteChildren {
|
|
|
361
380
|
SettingsAppearanceRoute: typeof SettingsAppearanceRoute
|
|
362
381
|
SettingsFiltersRoute: typeof SettingsFiltersRoute
|
|
363
382
|
SettingsFoldersRoute: typeof SettingsFoldersRoute
|
|
383
|
+
SettingsLabelsRoute: typeof SettingsLabelsRoute
|
|
364
384
|
SettingsSendersRoute: typeof SettingsSendersRoute
|
|
365
385
|
SettingsSuggestedVipsRoute: typeof SettingsSuggestedVipsRoute
|
|
366
386
|
SettingsIndexRoute: typeof SettingsIndexRoute
|
|
@@ -372,6 +392,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
|
|
|
372
392
|
SettingsAppearanceRoute: SettingsAppearanceRoute,
|
|
373
393
|
SettingsFiltersRoute: SettingsFiltersRoute,
|
|
374
394
|
SettingsFoldersRoute: SettingsFoldersRoute,
|
|
395
|
+
SettingsLabelsRoute: SettingsLabelsRoute,
|
|
375
396
|
SettingsSendersRoute: SettingsSendersRoute,
|
|
376
397
|
SettingsSuggestedVipsRoute: SettingsSuggestedVipsRoute,
|
|
377
398
|
SettingsIndexRoute: SettingsIndexRoute,
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
mailboxOperationsListMailboxesOptions,
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
5
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
6
|
-
import { SettingsShell } from "@remit/ui";
|
|
6
|
+
import { type LabelOption, SettingsShell } from "@remit/ui";
|
|
7
7
|
import { useQuery } from "@tanstack/react-query";
|
|
8
8
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
9
9
|
import { useCallback, useMemo, useState } from "react";
|
|
@@ -11,6 +11,7 @@ import { FilterEditorSurface } from "@/components/settings/FilterEditorSurface";
|
|
|
11
11
|
import { FiltersList } from "@/components/settings/FiltersList";
|
|
12
12
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
13
13
|
import { useDeleteFilter, useFilterList } from "@/hooks/useFilters";
|
|
14
|
+
import { useLabelList } from "@/hooks/useLabels";
|
|
14
15
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
15
16
|
import { buildMoveTargets } from "@/lib/move-targets";
|
|
16
17
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
@@ -64,6 +65,21 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
|
|
|
64
65
|
[mailboxesData?.items],
|
|
65
66
|
);
|
|
66
67
|
|
|
68
|
+
const { labels: labelItems } = useLabelList(accountId);
|
|
69
|
+
const labels: LabelOption[] = useMemo(
|
|
70
|
+
() =>
|
|
71
|
+
labelItems.map((label) => ({
|
|
72
|
+
id: label.labelId,
|
|
73
|
+
name: label.name,
|
|
74
|
+
color: label.color,
|
|
75
|
+
})),
|
|
76
|
+
[labelItems],
|
|
77
|
+
);
|
|
78
|
+
const labelById = useMemo(
|
|
79
|
+
() => new Map(labels.map((label) => [label.id, label])),
|
|
80
|
+
[labels],
|
|
81
|
+
);
|
|
82
|
+
|
|
67
83
|
const editingFilter = filters.find(
|
|
68
84
|
(filter) => filter.filterId === editingFilterId,
|
|
69
85
|
);
|
|
@@ -76,6 +92,7 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
|
|
|
76
92
|
accountId={accountId}
|
|
77
93
|
filter={editingFilter}
|
|
78
94
|
folders={folders}
|
|
95
|
+
labels={labels}
|
|
79
96
|
onClose={() => setEditingFilterId(undefined)}
|
|
80
97
|
/>
|
|
81
98
|
)}
|
|
@@ -99,6 +116,7 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
|
|
|
99
116
|
<FiltersList
|
|
100
117
|
filters={filters}
|
|
101
118
|
mailboxName={mailboxName}
|
|
119
|
+
labelById={labelById}
|
|
102
120
|
onEdit={setEditingFilterId}
|
|
103
121
|
onDelete={deleteFilter}
|
|
104
122
|
deletingFilterId={deletingFilterId}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapAccountResponse,
|
|
4
|
+
RemitImapLabelColor,
|
|
5
|
+
RemitImapLabelResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import {
|
|
8
|
+
Banner,
|
|
9
|
+
Button,
|
|
10
|
+
Input,
|
|
11
|
+
labelColorOptions,
|
|
12
|
+
Select,
|
|
13
|
+
SettingsShell,
|
|
14
|
+
} from "@remit/ui";
|
|
15
|
+
import { useQuery } from "@tanstack/react-query";
|
|
16
|
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { LabelsList } from "@/components/settings/LabelsList";
|
|
19
|
+
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
20
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
21
|
+
import {
|
|
22
|
+
useCreateLabel,
|
|
23
|
+
useDeleteLabel,
|
|
24
|
+
useLabelList,
|
|
25
|
+
useUpdateLabel,
|
|
26
|
+
} from "@/hooks/useLabels";
|
|
27
|
+
import { deleteLabelConfirmCopy } from "@/lib/organize/label-delete-copy";
|
|
28
|
+
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
29
|
+
|
|
30
|
+
export const Route = createFileRoute("/settings/labels")({
|
|
31
|
+
component: LabelsSettings,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const labelsHelp = (
|
|
35
|
+
<div className="space-y-3">
|
|
36
|
+
<p>
|
|
37
|
+
Labels mark mail without moving it — a message can carry any number of
|
|
38
|
+
them alongside whichever folder it lives in.
|
|
39
|
+
</p>
|
|
40
|
+
<p>
|
|
41
|
+
Use a label in a filter's action to have new (and existing) mail label
|
|
42
|
+
itself automatically, or apply one to a selection directly from the
|
|
43
|
+
mailbox.
|
|
44
|
+
</p>
|
|
45
|
+
<p>
|
|
46
|
+
Deleting a label removes it from every message, and deletes every filter
|
|
47
|
+
that applies it — the confirmation names how many.
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** Create a label for one account. A name, a color, and a create button. */
|
|
53
|
+
function NewLabel({ accountId }: { accountId: string }) {
|
|
54
|
+
const { createLabel, isPending, isError } = useCreateLabel(accountId);
|
|
55
|
+
const [name, setName] = useState("");
|
|
56
|
+
const [color, setColor] = useState<RemitImapLabelColor>("Default");
|
|
57
|
+
|
|
58
|
+
const handleCreate = () => {
|
|
59
|
+
const trimmed = name.trim();
|
|
60
|
+
if (trimmed === "") return;
|
|
61
|
+
createLabel(trimmed, color).then(() => {
|
|
62
|
+
setName("");
|
|
63
|
+
setColor("Default");
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="space-y-2 rounded-sm border border-line bg-surface p-3">
|
|
69
|
+
<p className="text-sm font-medium text-fg">New label</p>
|
|
70
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
|
71
|
+
<div className="flex-1 space-y-1">
|
|
72
|
+
<span className="text-xs text-fg-muted">Name</span>
|
|
73
|
+
<Input
|
|
74
|
+
value={name}
|
|
75
|
+
onChange={(event) => setName(event.target.value)}
|
|
76
|
+
placeholder="e.g. Receipts"
|
|
77
|
+
aria-label="Label name"
|
|
78
|
+
onKeyDown={(event) => {
|
|
79
|
+
if (event.key === "Enter") {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
handleCreate();
|
|
82
|
+
}
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="space-y-1">
|
|
87
|
+
<span className="text-xs text-fg-muted">Color</span>
|
|
88
|
+
<Select
|
|
89
|
+
value={color}
|
|
90
|
+
onChange={(event) =>
|
|
91
|
+
setColor(event.target.value as RemitImapLabelColor)
|
|
92
|
+
}
|
|
93
|
+
aria-label="Label color"
|
|
94
|
+
className="w-28"
|
|
95
|
+
>
|
|
96
|
+
{labelColorOptions.map((option) => (
|
|
97
|
+
<option key={option} value={option}>
|
|
98
|
+
{option}
|
|
99
|
+
</option>
|
|
100
|
+
))}
|
|
101
|
+
</Select>
|
|
102
|
+
</div>
|
|
103
|
+
<Button
|
|
104
|
+
variant="primary"
|
|
105
|
+
onClick={handleCreate}
|
|
106
|
+
disabled={isPending || name.trim() === ""}
|
|
107
|
+
>
|
|
108
|
+
{isPending ? "Creating…" : "Create label"}
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
{isError && (
|
|
112
|
+
<Banner tone="danger" variant="soft">
|
|
113
|
+
Couldn't create that label. Please try again.
|
|
114
|
+
</Banner>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function AccountLabels({ account }: { account: RemitImapAccountResponse }) {
|
|
121
|
+
const accountId = account.accountId;
|
|
122
|
+
const { labels, isPending, isError, error, refetch } =
|
|
123
|
+
useLabelList(accountId);
|
|
124
|
+
const { updateLabel } = useUpdateLabel(accountId);
|
|
125
|
+
const { deleteLabel, deletingLabelId } = useDeleteLabel(accountId);
|
|
126
|
+
const [pendingDelete, setPendingDelete] = useState<RemitImapLabelResponse>();
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<section className="space-y-2">
|
|
130
|
+
<h2 className="text-sm font-semibold text-fg">{account.email}</h2>
|
|
131
|
+
{isPending ? (
|
|
132
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on loading skeleton provides useful context for assistive tech
|
|
133
|
+
<div
|
|
134
|
+
className="h-16 animate-pulse rounded-md border border-line bg-surface"
|
|
135
|
+
aria-busy="true"
|
|
136
|
+
aria-label={`Loading labels for ${account.email}`}
|
|
137
|
+
/>
|
|
138
|
+
) : isError ? (
|
|
139
|
+
<ErrorState
|
|
140
|
+
variant="inline"
|
|
141
|
+
title={`Couldn't load labels for ${account.email}`}
|
|
142
|
+
error={error}
|
|
143
|
+
onRetry={() => {
|
|
144
|
+
refetch();
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
) : (
|
|
148
|
+
<LabelsList
|
|
149
|
+
labels={labels}
|
|
150
|
+
onRename={(labelId, name) => updateLabel(labelId, { name })}
|
|
151
|
+
onRecolor={(labelId, color) =>
|
|
152
|
+
updateLabel(labelId, {
|
|
153
|
+
color: color as RemitImapLabelResponse["color"],
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
onDelete={setPendingDelete}
|
|
157
|
+
deletingLabelId={deletingLabelId}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
<NewLabel accountId={accountId} />
|
|
161
|
+
{pendingDelete &&
|
|
162
|
+
(() => {
|
|
163
|
+
const copy = deleteLabelConfirmCopy(
|
|
164
|
+
pendingDelete.name,
|
|
165
|
+
pendingDelete.filterCount,
|
|
166
|
+
);
|
|
167
|
+
return (
|
|
168
|
+
<ConfirmDialog
|
|
169
|
+
isOpen
|
|
170
|
+
title={copy.title}
|
|
171
|
+
description={copy.description}
|
|
172
|
+
confirmLabel="Delete label"
|
|
173
|
+
destructive
|
|
174
|
+
isBusy={deletingLabelId === pendingDelete.labelId}
|
|
175
|
+
onConfirm={() => {
|
|
176
|
+
deleteLabel(pendingDelete.labelId);
|
|
177
|
+
setPendingDelete(undefined);
|
|
178
|
+
}}
|
|
179
|
+
onCancel={() => setPendingDelete(undefined)}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
})()}
|
|
183
|
+
</section>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function LabelsSettings() {
|
|
188
|
+
const navigate = useNavigate();
|
|
189
|
+
const [helpOpen, setHelpOpen] = useState(true);
|
|
190
|
+
|
|
191
|
+
const {
|
|
192
|
+
data: config,
|
|
193
|
+
isPending,
|
|
194
|
+
isError,
|
|
195
|
+
error,
|
|
196
|
+
refetch,
|
|
197
|
+
} = useQuery(configOperationsGetConfigOptions());
|
|
198
|
+
|
|
199
|
+
const handleSelectNav = (id: string) => {
|
|
200
|
+
const path = SETTINGS_ID_TO_PATH[id];
|
|
201
|
+
if (path) void navigate({ to: path });
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<SettingsShell
|
|
206
|
+
items={SETTINGS_NAV_ITEMS}
|
|
207
|
+
activeId="labels"
|
|
208
|
+
title="Labels"
|
|
209
|
+
description="Mark mail without moving it, per account."
|
|
210
|
+
help={labelsHelp}
|
|
211
|
+
helpOpen={helpOpen}
|
|
212
|
+
onToggleHelp={() => setHelpOpen((v) => !v)}
|
|
213
|
+
onSelect={handleSelectNav}
|
|
214
|
+
onBackToMail={() => void navigate({ to: "/mail" })}
|
|
215
|
+
>
|
|
216
|
+
{isPending ? (
|
|
217
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on loading skeleton provides useful context for assistive tech
|
|
218
|
+
<div
|
|
219
|
+
className="h-24 animate-pulse rounded-sm border border-line bg-surface"
|
|
220
|
+
aria-busy="true"
|
|
221
|
+
aria-label="Loading accounts"
|
|
222
|
+
/>
|
|
223
|
+
) : isError ? (
|
|
224
|
+
<ErrorState
|
|
225
|
+
title="Couldn't load accounts"
|
|
226
|
+
error={error}
|
|
227
|
+
onRetry={() => {
|
|
228
|
+
refetch();
|
|
229
|
+
}}
|
|
230
|
+
/>
|
|
231
|
+
) : config.accounts.length === 0 ? (
|
|
232
|
+
<p className="py-12 text-sm text-fg-muted">No accounts configured.</p>
|
|
233
|
+
) : (
|
|
234
|
+
<div className="space-y-8">
|
|
235
|
+
{config.accounts.map((account) => (
|
|
236
|
+
<AccountLabels key={account.accountId} account={account} />
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</SettingsShell>
|
|
241
|
+
);
|
|
242
|
+
}
|
package/src/routes/settings.tsx
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { SettingsNavItem } from "@remit/ui";
|
|
12
12
|
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
13
|
-
import { Filter, FolderTree, Inbox, Palette, Users } from "lucide-react";
|
|
13
|
+
import { Filter, FolderTree, Inbox, Palette, Tag, Users } from "lucide-react";
|
|
14
14
|
import { AdvancedNavIcon } from "@/components/settings/AdvancedNavIcon";
|
|
15
15
|
|
|
16
16
|
/* ------------------------------------------------------------------ */
|
|
@@ -34,6 +34,11 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
|
|
34
34
|
label: "Filters",
|
|
35
35
|
icon: <Filter className="size-4" />,
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
id: "labels",
|
|
39
|
+
label: "Labels",
|
|
40
|
+
icon: <Tag className="size-4" />,
|
|
41
|
+
},
|
|
37
42
|
{
|
|
38
43
|
id: "appearance",
|
|
39
44
|
label: "Appearance",
|
|
@@ -47,6 +52,7 @@ export const SETTINGS_ID_TO_PATH: Record<string, string> = {
|
|
|
47
52
|
senders: "/settings/senders",
|
|
48
53
|
folders: "/settings/folders",
|
|
49
54
|
filters: "/settings/filters",
|
|
55
|
+
labels: "/settings/labels",
|
|
50
56
|
appearance: "/settings/appearance",
|
|
51
57
|
advanced: "/settings/advanced",
|
|
52
58
|
};
|