@remit/web-client 0.0.103 → 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.103",
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": {
@@ -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) => {
@@ -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);