@remit/web-client 0.0.76 → 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/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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.77",
|
|
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": {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isLabelColorValue,
|
|
3
|
+
labelDotClass,
|
|
4
|
+
PopoverMenu,
|
|
5
|
+
type PopoverMenuItem,
|
|
6
|
+
} from "@remit/ui";
|
|
7
|
+
import { Tag } from "lucide-react";
|
|
8
|
+
import { useMemo } from "react";
|
|
9
|
+
import { useApplyLabel } from "@/hooks/useApplyLabel";
|
|
10
|
+
import { useLabelList } from "@/hooks/useLabels";
|
|
11
|
+
|
|
12
|
+
interface LabelApplyTriggerProps {
|
|
13
|
+
accountId: string;
|
|
14
|
+
mailboxId: string;
|
|
15
|
+
messageIds: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* "Apply label" for a selection — the manual "just these" scope (issue #26,
|
|
20
|
+
* RFC 034 recap). A kebab-style menu of the account's labels; picking one
|
|
21
|
+
* applies it to every selected message. Renders nothing when the account has
|
|
22
|
+
* no labels yet — an empty menu offering only "create one in Settings" is
|
|
23
|
+
* dead weight in the toolbar; Settings › Labels is where creation lives.
|
|
24
|
+
*/
|
|
25
|
+
export function LabelApplyTrigger({
|
|
26
|
+
accountId,
|
|
27
|
+
mailboxId,
|
|
28
|
+
messageIds,
|
|
29
|
+
}: LabelApplyTriggerProps) {
|
|
30
|
+
const { labels } = useLabelList(accountId);
|
|
31
|
+
const { applyLabel } = useApplyLabel({ accountId, mailboxId });
|
|
32
|
+
|
|
33
|
+
const items = useMemo<PopoverMenuItem[]>(
|
|
34
|
+
() =>
|
|
35
|
+
labels.map((label) => ({
|
|
36
|
+
key: label.labelId,
|
|
37
|
+
label: label.name,
|
|
38
|
+
icon: (
|
|
39
|
+
<span
|
|
40
|
+
className={`size-2.5 rounded-full ${
|
|
41
|
+
isLabelColorValue(label.color)
|
|
42
|
+
? labelDotClass[label.color]
|
|
43
|
+
: labelDotClass.Default
|
|
44
|
+
}`}
|
|
45
|
+
/>
|
|
46
|
+
),
|
|
47
|
+
onSelect: () => applyLabel(messageIds, label.labelId, "Apply"),
|
|
48
|
+
})),
|
|
49
|
+
[labels, messageIds, applyLabel],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (items.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<PopoverMenu
|
|
56
|
+
triggerLabel="Apply label to selected messages"
|
|
57
|
+
triggerIcon={<Tag className="size-4" />}
|
|
58
|
+
items={items}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1332,6 +1332,7 @@ export const MessageList = ({
|
|
|
1332
1332
|
isMoving={isMoving}
|
|
1333
1333
|
accountId={accountId}
|
|
1334
1334
|
currentMailboxId={mailboxId}
|
|
1335
|
+
selectedMessageIds={Array.from(selectedIds)}
|
|
1335
1336
|
moveDisabledHint={moveDisabledHint}
|
|
1336
1337
|
selectAll={escalationEnabled ? selectionSelectAll : undefined}
|
|
1337
1338
|
statusLabel={selectionStatusLabel}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { threadToRowData } from "./MessageListItem";
|
|
5
|
+
|
|
6
|
+
const baseThread = (
|
|
7
|
+
overrides: Partial<RemitImapThreadMessageResponse> = {},
|
|
8
|
+
): RemitImapThreadMessageResponse =>
|
|
9
|
+
({
|
|
10
|
+
threadMessageId: "tm-1",
|
|
11
|
+
threadId: "th-1",
|
|
12
|
+
messageId: "msg-1",
|
|
13
|
+
accountConfigId: "acc-1",
|
|
14
|
+
mailboxId: "mbx-1",
|
|
15
|
+
sentDate: 0,
|
|
16
|
+
isRead: true,
|
|
17
|
+
hasAttachment: false,
|
|
18
|
+
hasStars: false,
|
|
19
|
+
star: "None",
|
|
20
|
+
isDeleted: false,
|
|
21
|
+
senderTrust: "unknown",
|
|
22
|
+
createdAt: 0,
|
|
23
|
+
updatedAt: 0,
|
|
24
|
+
...overrides,
|
|
25
|
+
}) as RemitImapThreadMessageResponse;
|
|
26
|
+
|
|
27
|
+
describe("threadToRowData — labels", () => {
|
|
28
|
+
it("carries the message's applied labels through to the row (issue #26)", () => {
|
|
29
|
+
const row = threadToRowData(
|
|
30
|
+
baseThread({
|
|
31
|
+
labels: [{ labelId: "l1", name: "Receipts", color: "Blue" }],
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
assert.deepEqual(row.labels, [
|
|
35
|
+
{ labelId: "l1", name: "Receipts", color: "Blue" },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("carries no labels when the message has none", () => {
|
|
40
|
+
const row = threadToRowData(baseThread());
|
|
41
|
+
assert.equal(row.labels, undefined);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { labelOperationsListLabelsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
11
|
import { createElement } from "react";
|
|
11
12
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
12
13
|
import { SelectionToolbar } from "./SelectionToolbar";
|
|
@@ -20,8 +21,19 @@ afterEach(() => {
|
|
|
20
21
|
|
|
21
22
|
type ToolbarProps = Parameters<typeof SelectionToolbar>[0];
|
|
22
23
|
|
|
23
|
-
const mount = (
|
|
24
|
+
const mount = (
|
|
25
|
+
props: Partial<ToolbarProps> = {},
|
|
26
|
+
options: { labels?: { labelId: string; name: string; color: string }[] } = {},
|
|
27
|
+
): DomHarness => {
|
|
24
28
|
harness = createDomHarness();
|
|
29
|
+
if (props.accountId) {
|
|
30
|
+
harness.queryClient.setQueryData(
|
|
31
|
+
labelOperationsListLabelsQueryKey({
|
|
32
|
+
path: { accountId: props.accountId },
|
|
33
|
+
}),
|
|
34
|
+
{ items: options.labels ?? [] },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
25
37
|
harness.renderApp(
|
|
26
38
|
createElement(SelectionToolbar, {
|
|
27
39
|
selectedCount: 2,
|
|
@@ -62,6 +74,44 @@ describe("SelectionToolbar", () => {
|
|
|
62
74
|
assert.ok(dom.query('[aria-label="Organize similar messages"]'));
|
|
63
75
|
});
|
|
64
76
|
|
|
77
|
+
it("hides the label trigger without a materialized selection (issue #26)", () => {
|
|
78
|
+
const dom = mount({
|
|
79
|
+
accountId: "acc-1",
|
|
80
|
+
currentMailboxId: "mbx-inbox",
|
|
81
|
+
});
|
|
82
|
+
assert.equal(
|
|
83
|
+
dom.query('[aria-label="Apply label to selected messages"]'),
|
|
84
|
+
null,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("hides the label trigger when the account has no labels yet", () => {
|
|
89
|
+
const dom = mount(
|
|
90
|
+
{
|
|
91
|
+
accountId: "acc-1",
|
|
92
|
+
currentMailboxId: "mbx-inbox",
|
|
93
|
+
selectedMessageIds: ["m1", "m2"],
|
|
94
|
+
},
|
|
95
|
+
{ labels: [] },
|
|
96
|
+
);
|
|
97
|
+
assert.equal(
|
|
98
|
+
dom.query('[aria-label="Apply label to selected messages"]'),
|
|
99
|
+
null,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("offers the label trigger once a selection, account, and mailbox are all known", () => {
|
|
104
|
+
const dom = mount(
|
|
105
|
+
{
|
|
106
|
+
accountId: "acc-1",
|
|
107
|
+
currentMailboxId: "mbx-inbox",
|
|
108
|
+
selectedMessageIds: ["m1", "m2"],
|
|
109
|
+
},
|
|
110
|
+
{ labels: [{ labelId: "l1", name: "Receipts", color: "Blue" }] },
|
|
111
|
+
);
|
|
112
|
+
assert.ok(dom.query('[aria-label="Apply label to selected messages"]'));
|
|
113
|
+
});
|
|
114
|
+
|
|
65
115
|
it("withdraws Organize and explains why when the selection spans accounts", () => {
|
|
66
116
|
const dom = mount({
|
|
67
117
|
onMove: () => undefined,
|
|
@@ -15,6 +15,11 @@ import { SelectionToolbar } from "./SelectionToolbar";
|
|
|
15
15
|
* its visual is covered by the kit `SelectionTopBar` stories' move slot. The
|
|
16
16
|
* escalated stories below still assert verb parity through the mark-read and
|
|
17
17
|
* delete verbs remaining available over the predicate.
|
|
18
|
+
*
|
|
19
|
+
* The apply-label verb (issue #26) needs the same account-scoped query
|
|
20
|
+
* (`LabelApplyTrigger` lists the account's labels), so it is withheld here for
|
|
21
|
+
* the identical reason and omitted from `args` — none of these stories pass
|
|
22
|
+
* `selectedMessageIds`, which keeps the trigger from rendering.
|
|
18
23
|
*/
|
|
19
24
|
const meta: Meta<typeof SelectionToolbar> = {
|
|
20
25
|
title: "Screens/WebClient/SelectionToolbar",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Banner, type BannerTone, Checkbox, ProgressBar } from "@remit/ui";
|
|
2
2
|
import { Loader2, MailOpen, Sparkles, Trash2, X } from "lucide-react";
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
|
+
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
4
5
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
5
6
|
|
|
6
7
|
export interface SelectionToolbarNotice {
|
|
@@ -34,6 +35,12 @@ interface SelectionToolbarProps {
|
|
|
34
35
|
*/
|
|
35
36
|
accountId?: string;
|
|
36
37
|
currentMailboxId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The materialized selection, for the apply-label action (issue #26). Only
|
|
40
|
+
* `Apply` is offered from here — "just these" — never `appliedByFilterId`.
|
|
41
|
+
* Absent (or accountId/currentMailboxId absent) hides the label trigger.
|
|
42
|
+
*/
|
|
43
|
+
selectedMessageIds?: string[];
|
|
37
44
|
/**
|
|
38
45
|
* When the user's selection spans multiple accounts the toolbar
|
|
39
46
|
* disables Move and surfaces this hint inline next to the button.
|
|
@@ -89,6 +96,7 @@ export const SelectionToolbar = ({
|
|
|
89
96
|
isMoving = false,
|
|
90
97
|
accountId,
|
|
91
98
|
currentMailboxId,
|
|
99
|
+
selectedMessageIds,
|
|
92
100
|
moveDisabledHint,
|
|
93
101
|
selectAll,
|
|
94
102
|
statusLabel,
|
|
@@ -107,6 +115,8 @@ export const SelectionToolbar = ({
|
|
|
107
115
|
const showVerbs = !isRunning && !isCounting;
|
|
108
116
|
|
|
109
117
|
const canShowMove = !!onMove && !!accountId && !!currentMailboxId;
|
|
118
|
+
const canShowLabel =
|
|
119
|
+
!!accountId && !!currentMailboxId && !!selectedMessageIds?.length;
|
|
110
120
|
// Organize has no escalated-predicate path — it acts on the materialized
|
|
111
121
|
// selection — so it's withdrawn the moment the selection escalates or a run
|
|
112
122
|
// takes over (any state that names itself through `statusLabel`).
|
|
@@ -194,6 +204,17 @@ export const SelectionToolbar = ({
|
|
|
194
204
|
label="Move selected messages"
|
|
195
205
|
/>
|
|
196
206
|
)}
|
|
207
|
+
{showVerbs &&
|
|
208
|
+
canShowLabel &&
|
|
209
|
+
accountId &&
|
|
210
|
+
currentMailboxId &&
|
|
211
|
+
selectedMessageIds && (
|
|
212
|
+
<LabelApplyTrigger
|
|
213
|
+
accountId={accountId}
|
|
214
|
+
mailboxId={currentMailboxId}
|
|
215
|
+
messageIds={selectedMessageIds}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
197
218
|
{showVerbs && (
|
|
198
219
|
<button
|
|
199
220
|
type="button"
|
|
@@ -3,12 +3,14 @@ import {
|
|
|
3
3
|
type FilterRule,
|
|
4
4
|
FilterRuleEditor,
|
|
5
5
|
type FolderOption,
|
|
6
|
+
type LabelOption,
|
|
6
7
|
type RuleScope,
|
|
7
8
|
} from "@remit/ui";
|
|
8
9
|
import { useQuery } from "@tanstack/react-query";
|
|
9
10
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
10
11
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
11
12
|
import { useCreateFilter } from "@/hooks/useFilters";
|
|
13
|
+
import { useCreateLabel, useLabelList } from "@/hooks/useLabels";
|
|
12
14
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
13
15
|
import { useRuleEditorState } from "@/hooks/useRuleEditorState";
|
|
14
16
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
@@ -104,6 +106,22 @@ export function OrganizeRuleEditor({
|
|
|
104
106
|
[mailboxesData?.items],
|
|
105
107
|
);
|
|
106
108
|
|
|
109
|
+
const { labels: labelItems } = useLabelList(accountId);
|
|
110
|
+
const labels: LabelOption[] = useMemo(
|
|
111
|
+
() =>
|
|
112
|
+
labelItems.map((label) => ({
|
|
113
|
+
id: label.labelId,
|
|
114
|
+
name: label.name,
|
|
115
|
+
color: label.color,
|
|
116
|
+
})),
|
|
117
|
+
[labelItems],
|
|
118
|
+
);
|
|
119
|
+
const { createLabel } = useCreateLabel(accountId);
|
|
120
|
+
const onCreateLabel = async (name: string): Promise<LabelOption> => {
|
|
121
|
+
const label = await createLabel(name);
|
|
122
|
+
return { id: label.labelId, name: label.name, color: label.color };
|
|
123
|
+
};
|
|
124
|
+
|
|
107
125
|
const preview = useRulePreview(
|
|
108
126
|
accountId,
|
|
109
127
|
rulePredicate(rule, anchorMessageId),
|
|
@@ -198,11 +216,13 @@ export function OrganizeRuleEditor({
|
|
|
198
216
|
<FilterRuleEditor
|
|
199
217
|
rule={rule}
|
|
200
218
|
folders={folders}
|
|
219
|
+
labels={labels}
|
|
201
220
|
preview={preview}
|
|
202
221
|
semanticAvailable={!semanticUnavailable}
|
|
203
222
|
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
204
223
|
{...handlers}
|
|
205
224
|
onCreateFolder={createFolder}
|
|
225
|
+
onCreateLabel={onCreateLabel}
|
|
206
226
|
onCommit={commit}
|
|
207
227
|
onCancel={onClose}
|
|
208
228
|
/>
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type FilterRule,
|
|
7
7
|
FilterRuleEditor,
|
|
8
8
|
type FolderOption,
|
|
9
|
+
type LabelOption,
|
|
9
10
|
type MatchOperator,
|
|
10
11
|
previewCountSummary,
|
|
11
12
|
type RuleScope,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
14
15
|
import { useMemo, useRef, useState } from "react";
|
|
15
16
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
17
|
+
import { useCreateLabel } from "@/hooks/useLabels";
|
|
16
18
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
17
19
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
18
20
|
import { useUpdateFilter } from "@/hooks/useUpdateFilter";
|
|
@@ -33,6 +35,7 @@ interface FilterEditorProps {
|
|
|
33
35
|
accountId: string;
|
|
34
36
|
filter: RemitImapFilterResponse;
|
|
35
37
|
folders: FolderOption[];
|
|
38
|
+
labels: LabelOption[];
|
|
36
39
|
/**
|
|
37
40
|
* This deployment ships no vector pipeline, so a semantic anchor cannot be
|
|
38
41
|
* evaluated (RFC 038 D4). The filter's widen chip lists inactive and the rule
|
|
@@ -58,6 +61,7 @@ export function FilterEditor({
|
|
|
58
61
|
accountId,
|
|
59
62
|
filter,
|
|
60
63
|
folders,
|
|
64
|
+
labels,
|
|
61
65
|
semanticUnavailable = false,
|
|
62
66
|
onClose,
|
|
63
67
|
}: FilterEditorProps) {
|
|
@@ -74,6 +78,11 @@ export function FilterEditor({
|
|
|
74
78
|
const update = useUpdateFilter(accountId, filter.filterId);
|
|
75
79
|
const organizeJob = useOrganizeJob(accountId);
|
|
76
80
|
const { createFolder } = useCreateMailbox(accountId);
|
|
81
|
+
const { createLabel } = useCreateLabel(accountId);
|
|
82
|
+
const onCreateLabel = async (name: string): Promise<LabelOption> => {
|
|
83
|
+
const label = await createLabel(name);
|
|
84
|
+
return { id: label.labelId, name: label.name, color: label.color };
|
|
85
|
+
};
|
|
77
86
|
|
|
78
87
|
const startAddClause = () =>
|
|
79
88
|
setClauseEdit({
|
|
@@ -144,6 +153,12 @@ export function FilterEditor({
|
|
|
144
153
|
moveMailboxId: mailboxId || undefined,
|
|
145
154
|
}));
|
|
146
155
|
|
|
156
|
+
const changeLabel = (labelId: string) =>
|
|
157
|
+
setRule((current) => ({
|
|
158
|
+
...current,
|
|
159
|
+
labelId: labelId || undefined,
|
|
160
|
+
}));
|
|
161
|
+
|
|
147
162
|
const changeName = (name: string) =>
|
|
148
163
|
setRule((current) => ({ ...current, name }));
|
|
149
164
|
|
|
@@ -204,6 +219,7 @@ export function FilterEditor({
|
|
|
204
219
|
<FilterRuleEditor
|
|
205
220
|
rule={rule}
|
|
206
221
|
folders={folders}
|
|
222
|
+
labels={labels}
|
|
207
223
|
preview={preview}
|
|
208
224
|
// The update endpoint carries no anchor field at all (reader #266), so a
|
|
209
225
|
// widen can be neither added nor removed here: the "…and similar" add is
|
|
@@ -225,6 +241,8 @@ export function FilterEditor({
|
|
|
225
241
|
onChangeMatchOperator={changeMatchOperator}
|
|
226
242
|
onChangeMove={changeMove}
|
|
227
243
|
onCreateFolder={createFolder}
|
|
244
|
+
onChangeLabel={changeLabel}
|
|
245
|
+
onCreateLabel={onCreateLabel}
|
|
228
246
|
onChangeName={changeName}
|
|
229
247
|
onChangeScope={changeScope}
|
|
230
248
|
onChangeUntil={changeUntil}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BottomSheet,
|
|
4
|
+
Dialog,
|
|
5
|
+
type FolderOption,
|
|
6
|
+
type LabelOption,
|
|
7
|
+
} from "@remit/ui";
|
|
3
8
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
4
9
|
import { FilterEditor } from "./FilterEditor";
|
|
5
10
|
|
|
@@ -7,6 +12,7 @@ interface FilterEditorSurfaceProps {
|
|
|
7
12
|
accountId: string;
|
|
8
13
|
filter: RemitImapFilterResponse;
|
|
9
14
|
folders: FolderOption[];
|
|
15
|
+
labels: LabelOption[];
|
|
10
16
|
semanticUnavailable?: boolean;
|
|
11
17
|
onClose: () => void;
|
|
12
18
|
}
|
|
@@ -21,6 +27,7 @@ export function FilterEditorSurface({
|
|
|
21
27
|
accountId,
|
|
22
28
|
filter,
|
|
23
29
|
folders,
|
|
30
|
+
labels,
|
|
24
31
|
semanticUnavailable,
|
|
25
32
|
onClose,
|
|
26
33
|
}: FilterEditorSurfaceProps) {
|
|
@@ -31,6 +38,7 @@ export function FilterEditorSurface({
|
|
|
31
38
|
accountId={accountId}
|
|
32
39
|
filter={filter}
|
|
33
40
|
folders={folders}
|
|
41
|
+
labels={labels}
|
|
34
42
|
semanticUnavailable={semanticUnavailable}
|
|
35
43
|
onClose={onClose}
|
|
36
44
|
/>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import type { LabelOption } from "@remit/ui";
|
|
4
5
|
import React, { createElement } from "react";
|
|
5
6
|
import { renderToString } from "react-dom/server";
|
|
6
7
|
import { FiltersList } from "./FiltersList";
|
|
@@ -34,11 +35,13 @@ const filter = (
|
|
|
34
35
|
const render = (
|
|
35
36
|
filters: RemitImapFilterResponse[],
|
|
36
37
|
semanticUnavailable = false,
|
|
38
|
+
labelById: Map<string, LabelOption> = new Map(),
|
|
37
39
|
) =>
|
|
38
40
|
renderToString(
|
|
39
41
|
createElement(FiltersList, {
|
|
40
42
|
filters,
|
|
41
43
|
mailboxName: (id: string) => (id === "mbx-travel" ? "Travel" : undefined),
|
|
44
|
+
labelById,
|
|
42
45
|
onEdit: () => undefined,
|
|
43
46
|
onDelete: () => undefined,
|
|
44
47
|
semanticUnavailable,
|
|
@@ -80,6 +83,19 @@ describe("FiltersList", () => {
|
|
|
80
83
|
assert.doesNotMatch(html, /similar/i);
|
|
81
84
|
});
|
|
82
85
|
|
|
86
|
+
it("shows the applied label's chip when the filter has a label action (issue #26)", () => {
|
|
87
|
+
const labelById = new Map<string, LabelOption>([
|
|
88
|
+
["lbl-1", { id: "lbl-1", name: "Receipts", color: "Blue" }],
|
|
89
|
+
]);
|
|
90
|
+
const html = render([filter({ actionLabelId: "lbl-1" })], false, labelById);
|
|
91
|
+
assert.match(html, /Receipts/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("shows no label chip when the filter has no label action", () => {
|
|
95
|
+
const html = render([filter({ actionLabelId: "None" })]);
|
|
96
|
+
assert.doesNotMatch(html, /bg-blue-500|bg-red-500|bg-green-500/);
|
|
97
|
+
});
|
|
98
|
+
|
|
83
99
|
it("keeps an expired temporary filter visible and marks it Expired (RFC 034 Decision 1.2)", () => {
|
|
84
100
|
const html = render([
|
|
85
101
|
filter({
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
LabelChip,
|
|
6
|
+
type LabelOption,
|
|
7
|
+
WidenChip,
|
|
8
|
+
} from "@remit/ui";
|
|
3
9
|
import { Trash2 } from "lucide-react";
|
|
4
10
|
import {
|
|
5
11
|
filterDisplayStatus,
|
|
@@ -11,6 +17,8 @@ interface FiltersListProps {
|
|
|
11
17
|
filters: RemitImapFilterResponse[];
|
|
12
18
|
/** Resolve a destination mailbox id to a folder name for display. */
|
|
13
19
|
mailboxName: (mailboxId: string) => string | undefined;
|
|
20
|
+
/** Resolve a label id to its name/color for the applied-label chip (issue #26). */
|
|
21
|
+
labelById: Map<string, LabelOption>;
|
|
14
22
|
/** Open the row's rule in the editor (RFC 038 D6). */
|
|
15
23
|
onEdit: (filterId: string) => void;
|
|
16
24
|
onDelete: (filterId: string) => void;
|
|
@@ -35,6 +43,7 @@ interface FiltersListProps {
|
|
|
35
43
|
export function FiltersList({
|
|
36
44
|
filters,
|
|
37
45
|
mailboxName,
|
|
46
|
+
labelById,
|
|
38
47
|
onEdit,
|
|
39
48
|
onDelete,
|
|
40
49
|
deletingFilterId,
|
|
@@ -59,6 +68,10 @@ export function FiltersList({
|
|
|
59
68
|
filter.actionMailboxId !== NO_ACTION
|
|
60
69
|
? mailboxName(filter.actionMailboxId)
|
|
61
70
|
: undefined;
|
|
71
|
+
const label =
|
|
72
|
+
filter.actionLabelId !== NO_ACTION
|
|
73
|
+
? labelById.get(filter.actionLabelId)
|
|
74
|
+
: undefined;
|
|
62
75
|
const expiresLabel = formatExpiresAt(filter.expiresAt);
|
|
63
76
|
|
|
64
77
|
return (
|
|
@@ -94,14 +107,25 @@ export function FiltersList({
|
|
|
94
107
|
? " · always"
|
|
95
108
|
: ""}
|
|
96
109
|
</p>
|
|
97
|
-
{filter.hasAnchor && (
|
|
110
|
+
{(filter.hasAnchor || label) && (
|
|
98
111
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
{filter.hasAnchor && (
|
|
113
|
+
<WidenChip
|
|
114
|
+
widen={{
|
|
115
|
+
anchorCount: 1,
|
|
116
|
+
...(semanticUnavailable ? { inactive: true } : {}),
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
{label && (
|
|
121
|
+
<LabelChip
|
|
122
|
+
label={{
|
|
123
|
+
labelId: label.id,
|
|
124
|
+
name: label.name,
|
|
125
|
+
color: label.color,
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
105
129
|
</div>
|
|
106
130
|
)}
|
|
107
131
|
</button>
|
|
@@ -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
|
+
}
|