@remit/web-client 0.0.257 → 0.0.259

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.257",
3
+ "version": "0.0.259",
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": {
@@ -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 () => {
@@ -24,11 +24,14 @@
24
24
  * chips and the account source group, in a panel the list header's caret opens
25
25
  * over the rows. `FilterPanelProvider` shares that panel's open state between
26
26
  * the caret and the sheet, the same shape `MailViewChrome` gives the mailbox and
27
- * Starred views. The category and the chips narrow the grouped sections
28
- * themselves, and the phone search takeover reads the same selection, so a
29
- * filter set on one surface holds on the other. Under a query the chips are
30
- * terms of that query — see `briefChipFilters` — so what narrows the list is
31
- * readable and editable in the search field.
27
+ * Starred views. The list draws those controls but applies none of them: the
28
+ * category and every chip a parameter can express travel with the section
29
+ * requests, and the two that cannot ("From contacts", "Today") are applied here
30
+ * alongside the residual tokens (#314). The phone search takeover reads the same
31
+ * selection and the same rows, so a filter set on one surface holds on the
32
+ * other. Under a query the chips are terms of that query — see
33
+ * `briefChipFilters` — so what narrows the list is readable and editable in the
34
+ * search field.
32
35
  *
33
36
  * Multi-select is the mailbox list's, not a copy of it: the same
34
37
  * `ThreadListInteraction` cursor and `useSelection` state, and the same
@@ -114,7 +117,11 @@ import {
114
117
  matchesSearchTokens,
115
118
  toThreadRowData,
116
119
  } from "@/lib/brief";
117
- import { briefCountsMatchRows, briefCriteria } from "@/lib/brief-criteria";
120
+ import {
121
+ briefClientOnlyFilters,
122
+ briefCountsMatchRows,
123
+ briefCriteria,
124
+ } from "@/lib/brief-criteria";
118
125
  import { isServerError } from "@/lib/error-classifier";
119
126
  import { junkDestination } from "@/lib/junk-destination";
120
127
  import type { ListHeaderChrome } from "@/lib/list-header-chrome";
@@ -659,11 +666,16 @@ export function DailyBrief({
659
666
  refetchSections();
660
667
  }, [underQuery, refetchSearch, refetchSections]);
661
668
 
662
- // What the request could not carry: muted senders, the account pill, and the
669
+ // What the request could not carry: muted senders, the account pill, the two
670
+ // chips no endpoint takes a parameter for ("From contacts", "Today"), and the
663
671
  // residual tokens (`from:`, `subject:`, `before:`, `after:`, `in:`,
664
672
  // `account:`) that `listAllThreads` has no parameter for. Everything else was
665
673
  // answered over the whole scope by the request itself, and the order the rows
666
674
  // arrived in is kept — narrowing never re-sorts.
675
+ const clientOnlyFilters = useMemo(
676
+ () => briefClientOnlyFilters(requestFilters),
677
+ [requestFilters],
678
+ );
667
679
  const narrowRows = useCallback(
668
680
  (rows: RemitImapThreadMessageResponse[]): ThreadRowData[] =>
669
681
  // One row per conversation, because that is what the server counted:
@@ -676,9 +688,10 @@ export function DailyBrief({
676
688
  (t) =>
677
689
  (selectedAccountId === "all" ||
678
690
  t.accountId === selectedAccountId) &&
691
+ matchesBriefFilters(t, clientOnlyFilters) &&
679
692
  matchesSearchTokens(t, residualTokens),
680
693
  ),
681
- [selectedAccountId, residualTokens],
694
+ [selectedAccountId, clientOnlyFilters, residualTokens],
682
695
  );
683
696
 
684
697
  const briefRows = useMemo<BriefCategoryResult[]>(
@@ -826,18 +839,13 @@ export function DailyBrief({
826
839
  [refreshState, onRefreshBrief, refreshError, hasNewMail, refreshAccountIds],
827
840
  );
828
841
 
829
- // The phone search takeover renders the account/free-text-narrowed rows,
830
- // further narrowed by the same category and attribute chips the list applies.
842
+ // The phone search takeover renders the rows the list renders: the same
843
+ // requests answered the category and the chips, so a second pass here would
844
+ // narrow one surface by a criterion the other already applied to the whole
845
+ // scope.
831
846
  const searchResults = useMemo<SearchResult[]>(
832
- () =>
833
- filteredRows
834
- .filter(
835
- (t) =>
836
- (chipCategory === "all" || t.category === chipCategory) &&
837
- matchesBriefFilters(t, chipFilters),
838
- )
839
- .map((row) => rowToSearchResult(row, resultFolderIndex)),
840
- [filteredRows, chipCategory, chipFilters, resultFolderIndex],
847
+ () => filteredRows.map((row) => rowToSearchResult(row, resultFolderIndex)),
848
+ [filteredRows, resultFolderIndex],
841
849
  );
842
850
 
843
851
  // "Related" (semantic) spans every account here — the brief is the
@@ -14,8 +14,8 @@
14
14
  * restores the sheet exactly as it was.
15
15
  *
16
16
  * The daily brief composes the same three parts itself — provider, header,
17
- * sheet — around the kit `BriefSections`, whose sheet narrows the grouped
18
- * sections and flattens them when scoped to one category.
17
+ * sheet — around the kit `BriefSections`, whose sheet reports the selection and
18
+ * flattens the sections when the brief is scoped to one category.
19
19
  */
20
20
  import {
21
21
  FilterPanelProvider,
@@ -18,6 +18,7 @@
18
18
  * the request is wider than the list and the section totals are not the list's
19
19
  * size. `briefCountsMatchRows` is how a caller knows to show no number instead.
20
20
  */
21
+ import type { BriefFilterId } from "@remit/ui";
21
22
  import type { InboxFilterParams } from "./inbox-filters.js";
22
23
  import { inboxFilterParams } from "./inbox-filters.js";
23
24
  import type { SearchToken } from "./search-tokens.js";
@@ -34,12 +35,23 @@ export const BRIEF_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
34
35
  "attachments",
35
36
  ];
36
37
 
37
- /** Chips the request cannot express, so the kit applies them over the rows. */
38
- export const BRIEF_CLIENT_ONLY_FILTERS: readonly string[] = [
38
+ /** Chips the request cannot express, so the caller applies them over the rows. */
39
+ export const BRIEF_CLIENT_ONLY_FILTERS: readonly BriefFilterId[] = [
39
40
  "contacts",
40
41
  "today",
41
42
  ];
42
43
 
44
+ /**
45
+ * The chips on screen that no request carries, as the set to apply over the rows
46
+ * that came back. Every other chip was answered by the server over the whole
47
+ * scope, and applying one of those again here would narrow one page by a
48
+ * criterion the request already applied to everything (#312).
49
+ */
50
+ export const briefClientOnlyFilters = (
51
+ attributes: ReadonlySet<string>,
52
+ ): ReadonlySet<BriefFilterId> =>
53
+ new Set(BRIEF_CLIENT_ONLY_FILTERS.filter((id) => attributes.has(id)));
54
+
43
55
  /** Everything a section request narrows by except its own category. */
44
56
  export type BriefSectionParams = Omit<InboxFilterParams, "category">;
45
57
 
@@ -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
+ });