@remit/web-client 0.0.258 → 0.0.260

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.258",
3
+ "version": "0.0.260",
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,99 @@
1
+ /**
2
+ * Issue #1014: when compose cannot resolve the sending account, the `<select>`
3
+ * had no option carrying `value=""`, so the browser fell back to showing the
4
+ * first account's address even though nothing was actually selected. The
5
+ * unresolved state now gets its own disabled placeholder option, so no real
6
+ * identity is shown while nothing is chosen.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { afterEach, describe, it } from "node:test";
11
+ import { configOperationsGetConfigQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
12
+ import { createElement } from "react";
13
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
14
+ import { makeAccount, makeConfig } from "../../test-support/fixtures";
15
+ import { FromSelector } from "./FromSelector";
16
+
17
+ let harness: DomHarness | undefined;
18
+
19
+ afterEach(() => {
20
+ harness?.close();
21
+ harness = undefined;
22
+ });
23
+
24
+ const ACCOUNTS = [
25
+ makeAccount({ accountId: "acc-1", email: "alice@example.com" }),
26
+ makeAccount({ accountId: "acc-2", email: "bob@example.com" }),
27
+ ];
28
+
29
+ const mount = (selectedAccountId: string | undefined): DomHarness => {
30
+ harness = createDomHarness();
31
+ harness.queryClient.setQueryData(
32
+ configOperationsGetConfigQueryKey(),
33
+ makeConfig(ACCOUNTS),
34
+ );
35
+ harness.renderApp(
36
+ createElement(FromSelector, {
37
+ selectedAccountId,
38
+ onSelect: () => undefined,
39
+ }),
40
+ );
41
+ return harness;
42
+ };
43
+
44
+ const select = (dom: DomHarness): HTMLSelectElement => {
45
+ const found = dom.query<HTMLSelectElement>("#from-account-selector");
46
+ if (!found) throw new Error("the from-account select is not mounted");
47
+ return found;
48
+ };
49
+
50
+ describe("FromSelector with an unresolved account (#1014)", () => {
51
+ it("shows a disabled placeholder instead of the first account's address", () => {
52
+ const dom = mount(undefined);
53
+ const node = select(dom);
54
+
55
+ assert.equal(node.value, "", "the select should sit on the placeholder");
56
+
57
+ const placeholder =
58
+ node.querySelector<HTMLOptionElement>('option[value=""]');
59
+ assert.ok(placeholder, "no placeholder option was rendered");
60
+ assert.equal(
61
+ placeholder.disabled,
62
+ true,
63
+ "the placeholder option should not be a pickable account",
64
+ );
65
+ assert.notEqual(
66
+ placeholder.textContent,
67
+ ACCOUNTS[0]?.email,
68
+ "the placeholder must not read as the first account's address",
69
+ );
70
+ });
71
+
72
+ it("selects the resolved account and shows its address", () => {
73
+ const dom = mount("acc-2");
74
+ const node = select(dom);
75
+
76
+ assert.equal(node.value, "acc-2");
77
+ assert.equal(
78
+ node.selectedOptions[0]?.textContent,
79
+ "bob@example.com",
80
+ "the selected option should show the resolved account's address",
81
+ );
82
+ });
83
+
84
+ it("shows the placeholder when selectedAccountId names an account no longer in config", () => {
85
+ const dom = mount("acc-missing");
86
+ const node = select(dom);
87
+
88
+ assert.equal(node.value, "", "the select should sit on the placeholder");
89
+
90
+ const placeholder =
91
+ node.querySelector<HTMLOptionElement>('option[value=""]');
92
+ assert.ok(placeholder, "no placeholder option was rendered");
93
+ assert.notEqual(
94
+ node.selectedOptions[0]?.textContent,
95
+ ACCOUNTS[0]?.email,
96
+ "an unresolved selection must not read as the first account's address",
97
+ );
98
+ });
99
+ });
@@ -0,0 +1,61 @@
1
+ import { configOperationsGetConfigQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { makeAccount, makeConfig } from "../../test-support/fixtures";
5
+ import { FromSelector } from "./FromSelector";
6
+
7
+ /**
8
+ * The compose From line (#1014). With more than one configured account it is
9
+ * a real picker; the unresolved state — no account chosen because the source
10
+ * mailbox could not be matched to one — gets a disabled placeholder option
11
+ * rather than silently falling back to the first account's address.
12
+ */
13
+
14
+ const ACCOUNTS = [
15
+ makeAccount({ accountId: "acc-1", email: "alice@example.com" }),
16
+ makeAccount({ accountId: "acc-2", email: "bob@example.com" }),
17
+ ];
18
+
19
+ function seededClient(): QueryClient {
20
+ const client = new QueryClient({
21
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
22
+ });
23
+ client.setQueryData(
24
+ configOperationsGetConfigQueryKey(),
25
+ makeConfig(ACCOUNTS),
26
+ );
27
+ return client;
28
+ }
29
+
30
+ const meta: Meta<typeof FromSelector> = {
31
+ title: "Components/FromSelector",
32
+ component: FromSelector,
33
+ parameters: { layout: "padded" },
34
+ decorators: [
35
+ (Story) => (
36
+ <QueryClientProvider client={seededClient()}>
37
+ <Story />
38
+ </QueryClientProvider>
39
+ ),
40
+ ],
41
+ args: {
42
+ onSelect: () => undefined,
43
+ },
44
+ };
45
+ export default meta;
46
+
47
+ type Story = StoryObj<typeof FromSelector>;
48
+
49
+ /** A resolved account, picked from the configured accounts. */
50
+ export const Resolved: Story = {
51
+ args: { selectedAccountId: "acc-2" },
52
+ };
53
+
54
+ /**
55
+ * No account resolved — the source mailbox could not be matched to a
56
+ * configured account. The select shows a disabled placeholder instead of
57
+ * falling back to the first account's address.
58
+ */
59
+ export const Unresolved: Story = {
60
+ args: { selectedAccountId: undefined },
61
+ };
@@ -39,6 +39,8 @@ export const FromSelector = ({
39
39
  );
40
40
  }
41
41
 
42
+ const resolved = accounts.some((a) => a.accountId === selectedAccountId);
43
+
42
44
  return (
43
45
  <div className="flex items-start gap-2">
44
46
  <label
@@ -49,13 +51,18 @@ export const FromSelector = ({
49
51
  </label>
50
52
  <select
51
53
  id="from-account-selector"
52
- value={selectedAccountId ?? ""}
54
+ value={resolved ? selectedAccountId : ""}
53
55
  onChange={(e) => {
54
56
  const account = accounts.find((a) => a.accountId === e.target.value);
55
57
  if (account) onSelect(account);
56
58
  }}
57
59
  className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
58
60
  >
61
+ {!resolved && (
62
+ <option value="" disabled>
63
+ Choose an account
64
+ </option>
65
+ )}
59
66
  {accounts.map((account) => (
60
67
  <option key={account.accountId} value={account.accountId}>
61
68
  {account.email}
@@ -19,9 +19,7 @@ import assert from "node:assert/strict";
19
19
  import { afterEach, describe, it } from "node:test";
20
20
  import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
21
21
  import { createElement } from "react";
22
- import { ApiError } from "../../lib/api";
23
22
  import "../../lib/client";
24
- import { shouldEscalate, softErrorStatuses } from "../../lib/error-classifier";
25
23
  import { __resetFatalError, getCurrentFatalError } from "../../lib/fatal-error";
26
24
  import {
27
25
  handleMutationCacheError,
@@ -196,28 +194,18 @@ describe("ConfigImportWizard — an instance that is not empty", () => {
196
194
 
197
195
  /**
198
196
  * The other half of reaching that screen: the refusal must not also take the
199
- * app to the fatal page over it. That decision is `shouldEscalate` reading
200
- * the mutation's meta, and it is pinned here rather than through the wizard
201
- * because under this loader `lib/client.ts` and the generated SDK resolve
202
- * `client.gen` through two specifiers (`…/client.gen.ts` and `./client.gen.js`)
203
- * and so hold two client instances — the error interceptor registers on one
204
- * and the request goes through the other, which never happens under Vite,
205
- * where both specifiers resolve to the same module.
197
+ * app to the fatal page over it. Driven through the wizard, so it holds the
198
+ * whole chain — the error interceptor stamping the status on the thrown
199
+ * `ApiError`, and the mutation's meta telling `shouldEscalate` to leave 409
200
+ * to the caller.
206
201
  */
207
- it("keeps the 409 off the fatal page, and still escalates a 401", () => {
208
- const meta = softErrorStatuses(409);
209
- assert.equal(
210
- shouldEscalate(new ApiError("not empty", 409, {}), meta, "user"),
211
- false,
212
- );
213
- assert.equal(
214
- shouldEscalate(new ApiError("signed out", 401, {}), meta, "user"),
215
- true,
216
- );
217
- assert.equal(
218
- shouldEscalate(new ApiError("broken", 500, {}), meta, "user"),
219
- true,
220
- );
202
+ it("keeps the 409 off the fatal page", async () => {
203
+ const dom = start({ onImport: conflicted });
204
+ await pickFile(dom, "config.json", CONFIG);
205
+ dom.click(checkButton(dom));
206
+ await dom.flush();
207
+
208
+ assert.equal(getCurrentFatalError(), null);
221
209
  });
222
210
 
223
211
  it("does not record an import when the reader aborts the 409", async () => {
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, describe, it } from "node:test";
3
+ import { trashOperationsEmptyTrashMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
3
4
  import { trashOperationsEmptyTrash } from "@remit/api-http-client/sdk.gen.ts";
5
+ import { QueryClient } from "@tanstack/react-query";
6
+ import { getRuntimeConfig } from "../runtime-config";
4
7
  import { type HttpMock, mockFetch } from "../test-support/http";
5
8
  import { ApiError } from "./api";
6
9
  import "./client";
@@ -66,3 +69,46 @@ describe("the error interceptor", () => {
66
69
  assert.equal(error.message, "Request failed with status 503");
67
70
  });
68
71
  });
72
+
73
+ /**
74
+ * `client.ts` reaches the generated client as `@remit/api-http-client/client.gen.ts`
75
+ * while the generated code reaches it as `./client.gen.js`. Both must land on
76
+ * one module: two would leave every generated call on the generated default
77
+ * base URL and outside the error interceptor, so a 409 would arrive with no
78
+ * status and the fatal-error classifier would escalate it. Vite dedupes; this
79
+ * pins the node test loader, where the flow tests run.
80
+ */
81
+ describe("the client the generated code calls through", () => {
82
+ const emptyViaQueryLayer = (): Promise<unknown> => {
83
+ const { mutationFn } = trashOperationsEmptyTrashMutation();
84
+ assert.ok(mutationFn);
85
+ return mutationFn(
86
+ { path: { accountId: "acc-1" } },
87
+ { client: new QueryClient(), meta: undefined },
88
+ );
89
+ };
90
+
91
+ it("is the instance client.ts configured, for the SDK and the query layer", async () => {
92
+ answer(200, {});
93
+
94
+ await empty();
95
+ await emptyViaQueryLayer();
96
+
97
+ const sent = http?.calls.map((call) => call.url) ?? [];
98
+ assert.equal(sent.length, 2);
99
+ const base = `${getRuntimeConfig().apiUrl}/accounts/acc-1/trash/empty`;
100
+ assert.deepEqual(sent, [base, base]);
101
+ });
102
+
103
+ it("carries the status on a query-layer failure too", async () => {
104
+ answer(409, { code: "config_not_empty", message: "already configured" });
105
+
106
+ const error = await emptyViaQueryLayer().then(
107
+ () => undefined,
108
+ (reason: unknown) => reason,
109
+ );
110
+
111
+ assert.ok(error instanceof ApiError);
112
+ assert.equal(error.status, 409);
113
+ });
114
+ });