@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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/LabelApplyTrigger.tsx +61 -0
  3. package/src/components/mail/MailboxPane.tsx +1 -34
  4. package/src/components/mail/MessageList.tsx +1 -0
  5. package/src/components/mail/MessageListItem.test.ts +43 -0
  6. package/src/components/mail/MessageListItem.tsx +1 -0
  7. package/src/components/mail/SelectionToolbar.render.test.ts +51 -1
  8. package/src/components/mail/SelectionToolbar.stories.tsx +5 -0
  9. package/src/components/mail/SelectionToolbar.tsx +21 -0
  10. package/src/components/mail/organize/OrganizeRuleEditor.tsx +20 -0
  11. package/src/components/settings/FilterEditor.render.test.ts +1 -0
  12. package/src/components/settings/FilterEditor.tsx +18 -0
  13. package/src/components/settings/FilterEditorSurface.tsx +9 -1
  14. package/src/components/settings/FiltersList.render.test.ts +16 -0
  15. package/src/components/settings/FiltersList.tsx +32 -8
  16. package/src/components/settings/LabelsList.tsx +112 -0
  17. package/src/components/settings/settings-filter.stories.tsx +10 -1
  18. package/src/hooks/useApplyLabel.ts +68 -0
  19. package/src/hooks/useLabels.ts +124 -0
  20. package/src/hooks/useRuleEditorState.ts +8 -0
  21. package/src/lib/inbox-filters.test.ts +106 -0
  22. package/src/lib/inbox-filters.ts +44 -0
  23. package/src/lib/organize/filter-edit-model.test.ts +38 -0
  24. package/src/lib/organize/filter-edit-model.ts +15 -10
  25. package/src/lib/organize/label-delete-copy.test.ts +21 -0
  26. package/src/lib/organize/label-delete-copy.ts +22 -0
  27. package/src/lib/organize/organize-model.test.ts +35 -7
  28. package/src/lib/organize/organize-model.ts +17 -12
  29. package/src/lib/organize/rule-model.test.ts +5 -0
  30. package/src/lib/organize/rule-model.ts +1 -0
  31. package/src/routeTree.gen.ts +21 -0
  32. package/src/routes/settings/filters.tsx +19 -1
  33. package/src/routes/settings/labels.tsx +242 -0
  34. 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.76",
3
+ "version": "0.0.78",
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
+ }
@@ -100,6 +100,7 @@ import {
100
100
  type ConversationTarget,
101
101
  } from "@/lib/conversation-target";
102
102
  import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
103
+ import { applyInboxFilters } from "@/lib/inbox-filters";
103
104
  import { readIntelligencePref } from "@/lib/intelligence-pref";
104
105
  import { useMailContext } from "@/lib/mail-context";
105
106
  import { isRescueCandidate } from "@/lib/rescue-candidates";
@@ -117,40 +118,6 @@ import { parseSearchTokens } from "@/lib/search-tokens";
117
118
  import { useTelemetry } from "@/lib/telemetry-context";
118
119
  import { MailViewChrome } from "./MailViewChrome";
119
120
 
120
- /* ------------------------------------------------------------------ */
121
- /* Inbox filter predicates — the inbox preset offers Unread / Starred /
122
- Has attachment (never accounts; an inbox is one account already).
123
- The `flagged` id is the wire name for IMAP \Flagged; the label is
124
- "Starred". */
125
- /* ------------------------------------------------------------------ */
126
-
127
- const INBOX_FILTER_PREDICATES: Record<
128
- string,
129
- (t: RemitImapThreadMessageResponse) => boolean
130
- > = {
131
- unread: (t) => !t.isRead,
132
- flagged: (t) => t.hasStars === true,
133
- attachment: (t) => Boolean(t.hasAttachment),
134
- };
135
-
136
- function applyInboxFilters(
137
- threads: RemitImapThreadMessageResponse[],
138
- category: string,
139
- attributes: ReadonlySet<string>,
140
- ): RemitImapThreadMessageResponse[] {
141
- const predicates = Array.from(attributes)
142
- .map((id) => INBOX_FILTER_PREDICATES[id])
143
- .filter(
144
- (p): p is (t: RemitImapThreadMessageResponse) => boolean => p != null,
145
- );
146
- if (category === "all" && predicates.length === 0) return threads;
147
- return threads.filter(
148
- (t) =>
149
- (category === "all" || t.category === category) &&
150
- predicates.every((p) => p(t)),
151
- );
152
- }
153
-
154
121
  /* ------------------------------------------------------------------ */
155
122
  /* Context */
156
123
  /* ------------------------------------------------------------------ */
@@ -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
+ });
@@ -60,6 +60,7 @@ export const threadToRowData = (
60
60
  // for the multi-signature / alignment semantics a single string compare
61
61
  // misses.
62
62
  suspicious: thread.authenticity?.dkimMismatch === true,
63
+ labels: thread.labels,
63
64
  });
64
65
 
65
66
  const MessageListItemComponent = ({
@@ -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 = (props: Partial<ToolbarProps> = {}): DomHarness => {
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
  />
@@ -125,6 +125,7 @@ const mount = (
125
125
  accountId: ACCOUNT_ID,
126
126
  filter,
127
127
  folders: FOLDERS,
128
+ labels: [],
128
129
  semanticUnavailable,
129
130
  onClose: () => undefined,
130
131
  }),
@@ -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 { BottomSheet, Dialog, type FolderOption } from "@remit/ui";
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 { Badge, Button, WidenChip } from "@remit/ui";
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
- <WidenChip
100
- widen={{
101
- anchorCount: 1,
102
- ...(semanticUnavailable ? { inactive: true } : {}),
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>