@remit/web-client 0.0.102 → 0.0.103

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.103",
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,
@@ -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;