@remit/web-client 0.0.102 → 0.0.104

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.102",
3
+ "version": "0.0.104",
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,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * A filter made from a search belongs to the account the search ran over (#524).
9
+ * The surface hands its account to the host on the props; a search entry that
10
+ * reads past it writes the rule against whichever account happens to be first in
11
+ * the list, and the mail the user was looking at is never filtered.
12
+ *
13
+ * Which account wins is decided — and tested — in
14
+ * `../../lib/organize/search-to-rule.test.ts`. What is read off the source here
15
+ * is that the host asks that question with the surface's account in hand, which
16
+ * is the wiring the bug lived in and which no unit of the resolver can prove.
17
+ * The host wires routing, history and several data hooks together, so it is read
18
+ * rather than rendered, as this package's other component-level rules are (see
19
+ * `SelectionWizardHost.run-exit.test.ts`).
20
+ */
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
24
+
25
+ const host = source.match(
26
+ /export function SelectionWizardHost\(([\s\S]*)$/,
27
+ )?.[1];
28
+
29
+ describe("the search entry's account", () => {
30
+ it("is resolved with the account the surface handed over", () => {
31
+ assert.ok(host, "the host is gone");
32
+ const call = host.match(/searchRuleAccountId\(([\s\S]*?)\)/)?.[1];
33
+ assert.ok(call, "the search entry resolves no account of its own");
34
+ assert.match(call, /\bprops\.accountId\b/);
35
+ });
36
+
37
+ it("never reaches past it for the first configured account", () => {
38
+ assert.ok(host, "the host is gone");
39
+ assert.doesNotMatch(host, /accounts\[0\]/);
40
+ });
41
+ });
@@ -62,6 +62,7 @@ import {
62
62
  normalizeClauseValue,
63
63
  SUPPORTED_CLAUSE_FIELDS,
64
64
  } from "@/lib/organize/rule-model";
65
+ import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
65
66
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
66
67
  import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
67
68
 
@@ -1003,9 +1004,9 @@ function SelectionWizardSession({
1003
1004
  *
1004
1005
  * The URL says which affordance opened the wizard, and that decides two things
1005
1006
  * and no more: which step it opens on, and whether the query seeds the clauses.
1006
- * A search entry ticks nothing and its rule belongs to the account the query
1007
- * names, so the selection the bar would have handed over is not the one it
1008
- * walks.
1007
+ * A search entry ticks nothing, so the selection the bar would have handed over
1008
+ * is not the one it walks; its rule belongs to the account the surface handed
1009
+ * over, and to the one the query names when it names one (#524).
1009
1010
  */
1010
1011
  export function SelectionWizardHost(props: SelectionWizardHostProps) {
1011
1012
  const fromSearch = useWizardEntryValue() === "search";
@@ -1024,7 +1025,11 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
1024
1025
  {...(conversion
1025
1026
  ? {
1026
1027
  verb: "organize" as const,
1027
- accountId: conversion.targetAccountId ?? accounts[0]?.accountId,
1028
+ accountId: searchRuleAccountId(
1029
+ conversion,
1030
+ props.accountId,
1031
+ accounts,
1032
+ ),
1028
1033
  selection: EMPTY_SELECTION,
1029
1034
  crossAccount: false,
1030
1035
  escalated: undefined,
@@ -61,12 +61,15 @@ describe("buildMoveTargets — excluded destinations (#236, #976)", () => {
61
61
  });
62
62
 
63
63
  test("with no appointments at all, nothing is excluded by role (only Outbox by name)", () => {
64
- const result = buildMoveTargets([
65
- make({ mailboxId: "m1", fullPath: "INBOX" }),
66
- make({ mailboxId: "m2", fullPath: "Drafts" }),
67
- make({ mailboxId: "m3", fullPath: "Outbox" }),
68
- make({ mailboxId: "m4", fullPath: "Sent Mail" }),
69
- ]);
64
+ const result = buildMoveTargets(
65
+ [
66
+ make({ mailboxId: "m1", fullPath: "INBOX" }),
67
+ make({ mailboxId: "m2", fullPath: "Drafts" }),
68
+ make({ mailboxId: "m3", fullPath: "Outbox" }),
69
+ make({ mailboxId: "m4", fullPath: "Sent Mail" }),
70
+ ],
71
+ [],
72
+ );
70
73
  const ids = result.map((mailbox) => mailbox.mailboxId).sort();
71
74
  assert.deepStrictEqual(ids, ["m1", "m2", "m4"]);
72
75
  });
@@ -89,11 +92,14 @@ describe("buildMoveTargets — excluded destinations (#236, #976)", () => {
89
92
  });
90
93
 
91
94
  test("keeps user-defined folders", () => {
92
- const result = buildMoveTargets([
93
- make({ mailboxId: "m1", fullPath: "INBOX" }),
94
- make({ mailboxId: "m2", fullPath: "Project Alpha" }),
95
- make({ mailboxId: "m3", fullPath: "Receipts/2025" }),
96
- ]);
95
+ const result = buildMoveTargets(
96
+ [
97
+ make({ mailboxId: "m1", fullPath: "INBOX" }),
98
+ make({ mailboxId: "m2", fullPath: "Project Alpha" }),
99
+ make({ mailboxId: "m3", fullPath: "Receipts/2025" }),
100
+ ],
101
+ [],
102
+ );
97
103
  const ids = result.map((mailbox) => mailbox.mailboxId);
98
104
  assert.deepStrictEqual(ids.sort(), ["m1", "m2", "m3"]);
99
105
  });
@@ -121,10 +127,13 @@ describe("buildMoveTargets — sort order", () => {
121
127
  });
122
128
 
123
129
  test("plain folders sort alphabetically, case-insensitively", () => {
124
- const result = buildMoveTargets([
125
- make({ mailboxId: "b", fullPath: "banana" }),
126
- make({ mailboxId: "a", fullPath: "Apple" }),
127
- ]);
130
+ const result = buildMoveTargets(
131
+ [
132
+ make({ mailboxId: "b", fullPath: "banana" }),
133
+ make({ mailboxId: "a", fullPath: "Apple" }),
134
+ ],
135
+ [],
136
+ );
128
137
  assert.deepStrictEqual(
129
138
  result.map((m) => m.mailboxId),
130
139
  ["a", "b"],
@@ -150,45 +159,57 @@ describe("buildMoveTargets — Outbox locale exclusion (#290)", () => {
150
159
 
151
160
  for (const name of localizedOutboxNames) {
152
161
  test(`drops localized Outbox "${name}"`, () => {
153
- const result = buildMoveTargets([
154
- make({ mailboxId: "inbox", fullPath: "INBOX" }),
155
- make({ mailboxId: "outbox", fullPath: name }),
156
- ]);
162
+ const result = buildMoveTargets(
163
+ [
164
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
165
+ make({ mailboxId: "outbox", fullPath: name }),
166
+ ],
167
+ [],
168
+ );
157
169
  const ids = result.map((mailbox) => mailbox.mailboxId);
158
170
  assert.deepStrictEqual(ids, ["inbox"]);
159
171
  });
160
172
  }
161
173
 
162
174
  test("matches Outbox locale names case-insensitively", () => {
163
- const result = buildMoveTargets([
164
- make({ mailboxId: "inbox", fullPath: "INBOX" }),
165
- make({ mailboxId: "m1", fullPath: "POSTVAK UIT" }),
166
- make({ mailboxId: "m2", fullPath: "postausgang" }),
167
- make({ mailboxId: "m3", fullPath: "BOÎTE D'ENVOI" }),
168
- ]);
175
+ const result = buildMoveTargets(
176
+ [
177
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
178
+ make({ mailboxId: "m1", fullPath: "POSTVAK UIT" }),
179
+ make({ mailboxId: "m2", fullPath: "postausgang" }),
180
+ make({ mailboxId: "m3", fullPath: "BOÎTE D'ENVOI" }),
181
+ ],
182
+ [],
183
+ );
169
184
  const ids = result.map((mailbox) => mailbox.mailboxId);
170
185
  assert.deepStrictEqual(ids, ["inbox"]);
171
186
  });
172
187
 
173
188
  test("matches Outbox locale names with surrounding whitespace", () => {
174
- const result = buildMoveTargets([
175
- make({ mailboxId: "inbox", fullPath: "INBOX" }),
176
- make({ mailboxId: "m1", fullPath: " Outbox " }),
177
- make({ mailboxId: "m2", fullPath: " Postvak UIT " }),
178
- ]);
189
+ const result = buildMoveTargets(
190
+ [
191
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
192
+ make({ mailboxId: "m1", fullPath: " Outbox " }),
193
+ make({ mailboxId: "m2", fullPath: " Postvak UIT " }),
194
+ ],
195
+ [],
196
+ );
179
197
  const ids = result.map((mailbox) => mailbox.mailboxId);
180
198
  assert.deepStrictEqual(ids, ["inbox"]);
181
199
  });
182
200
 
183
201
  test("does not over-match unrelated mailbox names", () => {
184
- const result = buildMoveTargets([
185
- make({ mailboxId: "inbox", fullPath: "INBOX" }),
186
- make({ mailboxId: "trash", fullPath: "Trash" }),
187
- make({ mailboxId: "custom", fullPath: "Custom Folder" }),
188
- // Substring of a locale name must not trigger the filter — only
189
- // the exact normalized leaf matches.
190
- make({ mailboxId: "outboxy", fullPath: "Outbox Archive" }),
191
- ]);
202
+ const result = buildMoveTargets(
203
+ [
204
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
205
+ make({ mailboxId: "trash", fullPath: "Trash" }),
206
+ make({ mailboxId: "custom", fullPath: "Custom Folder" }),
207
+ // Substring of a locale name must not trigger the filter only
208
+ // the exact normalized leaf matches.
209
+ make({ mailboxId: "outboxy", fullPath: "Outbox Archive" }),
210
+ ],
211
+ [],
212
+ );
192
213
  const ids = result.map((mailbox) => mailbox.mailboxId).sort();
193
214
  assert.deepStrictEqual(ids, ["custom", "inbox", "outboxy", "trash"]);
194
215
  });
@@ -49,7 +49,7 @@ const NON_SYSTEM_PRIORITY = ROLE_PRIORITY.length;
49
49
  */
50
50
  export const buildMoveTargets = (
51
51
  mailboxes: readonly RemitImapMailboxResponse[],
52
- folderAppointments: readonly RemitImapFolderAppointment[] = [],
52
+ folderAppointments: readonly RemitImapFolderAppointment[],
53
53
  ): RemitImapMailboxResponse[] => {
54
54
  const roleMap = buildMailboxRoleMap(folderAppointments);
55
55
  const filtered = mailboxes.filter((mailbox) => {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { isConvertible } from "@remit/ui";
4
4
  import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
5
- import { convertSearchToRule } from "./search-to-rule";
5
+ import { convertSearchToRule, searchRuleAccountId } from "./search-to-rule";
6
6
 
7
7
  const CONTEXT: SearchTokenContext = {
8
8
  mailboxesByName: new Map([["archive", "mbx-archive"]]),
@@ -124,3 +124,39 @@ describe("convertSearchToRule — nothing left to make a filter from", () => {
124
124
  assert.equal(isConvertible(convert("in:archive receipts")), true);
125
125
  });
126
126
  });
127
+
128
+ describe("searchRuleAccountId — whose account the filter belongs to", () => {
129
+ const ACCOUNTS = [{ accountId: "acc-first" }, { accountId: "acc-work" }];
130
+
131
+ it("uses the account the surface is showing for an ordinary search", () => {
132
+ assert.equal(
133
+ searchRuleAccountId(convert("invoice"), "acc-work", ACCOUNTS),
134
+ "acc-work",
135
+ );
136
+ });
137
+
138
+ it("lets an account: facet name a different account than the surface", () => {
139
+ assert.equal(
140
+ searchRuleAccountId(
141
+ convert("account:work invoice"),
142
+ "acc-other",
143
+ ACCOUNTS,
144
+ ),
145
+ "acc-work",
146
+ );
147
+ });
148
+
149
+ it("falls back to the first configured account only when nothing names one", () => {
150
+ assert.equal(
151
+ searchRuleAccountId(convert("invoice"), undefined, ACCOUNTS),
152
+ "acc-first",
153
+ );
154
+ });
155
+
156
+ it("has no account to give when none is configured", () => {
157
+ assert.equal(
158
+ searchRuleAccountId(convert("invoice"), undefined, []),
159
+ undefined,
160
+ );
161
+ });
162
+ });
@@ -1,3 +1,4 @@
1
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
1
2
  import type {
2
3
  DroppedFacet,
3
4
  DroppedFacetType,
@@ -103,3 +104,17 @@ export const convertSearchToRule = (
103
104
  droppedSemantic: keptTerms && searchHadSemanticReach,
104
105
  };
105
106
  };
107
+
108
+ /**
109
+ * Which account a rule converted from a search belongs to (#524). A query names
110
+ * one only through an explicit `account:` facet, which an ordinary search does
111
+ * not carry; without it the rule belongs to the account whose mail the search
112
+ * ran over, which is the account the surface handed over. Only a surface that
113
+ * has no single account of its own leaves the first configured one to answer.
114
+ */
115
+ export const searchRuleAccountId = (
116
+ conversion: SearchConversion,
117
+ surfaceAccountId: string | undefined,
118
+ accounts: readonly Pick<RemitImapAccountResponse, "accountId">[],
119
+ ): string | undefined =>
120
+ conversion.targetAccountId ?? surfaceAccountId ?? accounts[0]?.accountId;
@@ -0,0 +1,114 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ import assert from "node:assert/strict";
3
+ import { afterEach, describe, it } from "node:test";
4
+ import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
5
+ import { createElement } from "react";
6
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
7
+ import { makeAccount, makeMailbox } from "../../test-support/fixtures";
8
+ import { type HttpMock, mockFetch } from "../../test-support/http";
9
+ import { AccountFilters } from "./filters.tsx";
10
+
11
+ let harness: DomHarness | undefined;
12
+ let http: HttpMock | undefined;
13
+
14
+ afterEach(() => {
15
+ harness?.close();
16
+ harness = undefined;
17
+ http?.restore();
18
+ http = undefined;
19
+ });
20
+
21
+ const ACCOUNT_ID = "acc-1";
22
+
23
+ const account = makeAccount({
24
+ accountId: ACCOUNT_ID,
25
+ folderAppointments: [
26
+ { role: "Inbox", mailboxId: "mbx-inbox" },
27
+ { role: "Drafts", mailboxId: "mbx-concepten" },
28
+ { role: "Sent", mailboxId: "mbx-verzonden" },
29
+ { role: "Archive", mailboxId: "mbx-archief" },
30
+ ],
31
+ });
32
+
33
+ // Dutch leaf names for the appointed Drafts and Sent: only the appointment can
34
+ // exclude them, so a name-based filter would not pass this.
35
+ const mailboxes = [
36
+ makeMailbox({ mailboxId: "mbx-receipts", fullPath: "INBOX/Receipts" }),
37
+ makeMailbox({ mailboxId: "mbx-concepten", fullPath: "INBOX/Concepten" }),
38
+ makeMailbox({ mailboxId: "mbx-verzonden", fullPath: "INBOX/Verzonden" }),
39
+ makeMailbox({ mailboxId: "mbx-archief", fullPath: "INBOX/Archief" }),
40
+ makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
41
+ ];
42
+
43
+ const filter: RemitImapFilterResponse = {
44
+ filterId: "f-1",
45
+ accountConfigId: ACCOUNT_ID,
46
+ name: "Receipts",
47
+ scope: "Standing",
48
+ state: "Active",
49
+ hasAnchor: false,
50
+ ruleChangedAt: 0,
51
+ actionChangedAt: 0,
52
+ matchOperator: "And",
53
+ literalClauses: [{ field: "From", value: "receipts@stripe.com" }],
54
+ actionLabelId: "None",
55
+ actionMailboxId: "mbx-receipts",
56
+ createdAt: 0,
57
+ updatedAt: 0,
58
+ };
59
+
60
+ /**
61
+ * Wait on the DOM answering rather than on a turn count: how much React work
62
+ * lands per turn varies with the machine, so a fixed flush is a flake and a
63
+ * ceiling is what keeps a surface that never renders a failure, not a hang.
64
+ */
65
+ const settleUntil = async (
66
+ dom: DomHarness,
67
+ done: () => boolean,
68
+ ): Promise<void> => {
69
+ const deadline = Date.now() + 5000;
70
+ for (;;) {
71
+ await dom.flush();
72
+ if (done() || Date.now() >= deadline) return;
73
+ await dom.wait(10);
74
+ }
75
+ };
76
+
77
+ const offeredDestinations = async (): Promise<string[]> => {
78
+ http = mockFetch((call) => {
79
+ if (call.path.endsWith("/mailboxes")) return { items: mailboxes };
80
+ if (call.path.endsWith("/filters")) return { items: [filter] };
81
+ if (call.path.endsWith("/labels")) return { items: [] };
82
+ if (call.path.endsWith("/organize/preview"))
83
+ return { matchedCount: 0, messageIds: [] };
84
+ return {};
85
+ });
86
+ const dom = createDomHarness();
87
+ harness = dom;
88
+ dom.renderApp(createElement(AccountFilters, { account }));
89
+ await settleUntil(
90
+ dom,
91
+ () => !!dom.query('[aria-label="Edit filter Receipts"]'),
92
+ );
93
+ dom.click(dom.byLabel("Edit filter Receipts"));
94
+ await settleUntil(
95
+ dom,
96
+ () => !!dom.query('select[aria-label="Destination folder"]'),
97
+ );
98
+ return dom
99
+ .queryAll<HTMLOptionElement>(
100
+ 'select[aria-label="Destination folder"] option',
101
+ )
102
+ .map((option) => option.value)
103
+ .filter((value) => value.startsWith("mbx-"));
104
+ };
105
+
106
+ describe("Settings › Filters — move destinations (#236, #540)", () => {
107
+ it("offers every folder but the appointed Drafts and Sent, in role order", async () => {
108
+ assert.deepEqual(await offeredDestinations(), [
109
+ "mbx-inbox",
110
+ "mbx-archief",
111
+ "mbx-receipts",
112
+ ]);
113
+ });
114
+ });
@@ -34,7 +34,11 @@ const filtersHelp = (
34
34
  </div>
35
35
  );
36
36
 
37
- function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
37
+ export function AccountFilters({
38
+ account,
39
+ }: {
40
+ account: RemitImapAccountResponse;
41
+ }) {
38
42
  const accountId = account.accountId;
39
43
  const { filters, isPending, isError, error, refetch } =
40
44
  useFilterList(accountId);
@@ -58,11 +62,14 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
58
62
 
59
63
  const folders = useMemo(
60
64
  () =>
61
- buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
65
+ buildMoveTargets(
66
+ mailboxesData?.items ?? [],
67
+ account.folderAppointments,
68
+ ).map((mailbox) => ({
62
69
  id: mailbox.mailboxId,
63
70
  label: getMailboxDisplayName(mailbox.fullPath),
64
71
  })),
65
- [mailboxesData?.items],
72
+ [mailboxesData?.items, account.folderAppointments],
66
73
  );
67
74
 
68
75
  const { labels: labelItems } = useLabelList(accountId);