@remit/web-client 0.0.64 → 0.0.66

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.64",
3
+ "version": "0.0.66",
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": {
@@ -89,6 +89,7 @@
89
89
  "react-i18next": "^15",
90
90
  "react-resizable-panels": "^2.1.9",
91
91
  "tailwind-merge": "^2",
92
+ "tldts": "^7.4.9",
92
93
  "vaul": "^1.1.2",
93
94
  "zod": "^3",
94
95
  "@types/dompurify": "*"
@@ -1267,7 +1267,6 @@ export const MessageList = ({
1267
1267
  <MobileOrganizeFlow
1268
1268
  entry={mobileOrganizeEntry}
1269
1269
  accountId={accountId}
1270
- mailboxId={mailboxId}
1271
1270
  selectedMessageIds={Array.from(selectedIds)}
1272
1271
  selectedSenders={selectedSenders}
1273
1272
  junkMailboxId={junkMailboxId}
@@ -1509,7 +1508,6 @@ export const MessageList = ({
1509
1508
  <OrganizeDialog
1510
1509
  open={organizeOpen}
1511
1510
  accountId={accountId}
1512
- mailboxId={mailboxId}
1513
1511
  selectedMessageIds={Array.from(selectedIds)}
1514
1512
  selectedSenders={selectedSenders}
1515
1513
  onClose={() => setOrganizeOpen(false)}
@@ -23,7 +23,6 @@ const render = (entry: OrganizeEntry) =>
23
23
  createElement(MobileOrganizeFlow, {
24
24
  entry,
25
25
  accountId: "acc-1",
26
- mailboxId: "mbx-inbox",
27
26
  selectedMessageIds: ["msg-1", "msg-2"],
28
27
  selectedSenders: ["npm@github.com"],
29
28
  onClose: () => undefined,
@@ -1,4 +1,5 @@
1
1
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RuleScope } from "@remit/ui";
2
3
  import { BottomSheet, Button } from "@remit/ui";
3
4
  import { useQuery } from "@tanstack/react-query";
4
5
  import { Loader2 } from "lucide-react";
@@ -12,13 +13,21 @@ import {
12
13
  type PreviewStatus,
13
14
  resolveOrganizeStage,
14
15
  } from "@/lib/organize/mobile-organize-flow";
15
- import { OrganizePanel } from "./OrganizePanel";
16
+ import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
16
17
  import { SomethingElsePanel } from "./SomethingElsePanel";
17
18
 
19
+ /**
20
+ * Map a "Something else" seed's scope onto the rule editor's scope. Only the
21
+ * standing shortcut carries one; everything else lets the editor default.
22
+ */
23
+ const seedRuleScope = (
24
+ seed: OrganizeSeed | undefined,
25
+ ): RuleScope | undefined =>
26
+ seed?.scope === "standing" ? "standing" : undefined;
27
+
18
28
  interface MobileOrganizeFlowProps {
19
29
  entry: OrganizeEntry;
20
30
  accountId: string;
21
- mailboxId: string;
22
31
  selectedMessageIds: string[];
23
32
  /**
24
33
  * Sender addresses of the selected messages, driving the literal fallback on
@@ -53,7 +62,6 @@ const previewStatusOf = (
53
62
  export function MobileOrganizeFlow({
54
63
  entry,
55
64
  accountId,
56
- mailboxId,
57
65
  selectedMessageIds,
58
66
  selectedSenders,
59
67
  junkMailboxId,
@@ -67,7 +75,6 @@ export function MobileOrganizeFlow({
67
75
  matchedCount,
68
76
  semanticUnavailable,
69
77
  senders,
70
- matchPredicate,
71
78
  isPending,
72
79
  isError,
73
80
  error,
@@ -125,15 +132,12 @@ export function MobileOrganizeFlow({
125
132
  )}
126
133
 
127
134
  {stage.kind === "organize" && (
128
- <OrganizePanel
135
+ <OrganizeRuleEditor
129
136
  accountId={accountId}
130
- mailboxId={mailboxId}
131
137
  selectedMessageIds={selectedMessageIds}
132
- matchPredicate={matchPredicate}
133
- matchedCount={stage.matchedCount}
134
- initialScope={seed?.scope}
138
+ seedCount={stage.matchedCount}
139
+ seedScope={seedRuleScope(seed)}
135
140
  seedMailboxId={seed?.moveMailboxId}
136
- fallback={stage.fallback}
137
141
  semanticUnavailable={semanticUnavailable}
138
142
  senders={senders}
139
143
  onClose={onClose}
@@ -18,7 +18,6 @@ const render = (open: boolean) =>
18
18
  createElement(OrganizeDialog, {
19
19
  open,
20
20
  accountId: "acc-1",
21
- mailboxId: "mbx-inbox",
22
21
  selectedMessageIds: ["msg-1", "msg-2"],
23
22
  selectedSenders: ["npm@github.com"],
24
23
  onClose: () => undefined,
@@ -2,12 +2,11 @@ import { Button, Dialog } from "@remit/ui";
2
2
  import { Loader2 } from "lucide-react";
3
3
  import { useEffect } from "react";
4
4
  import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
5
- import { OrganizePanel } from "./OrganizePanel";
5
+ import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
6
6
 
7
7
  interface OrganizeDialogProps {
8
8
  open: boolean;
9
9
  accountId: string;
10
- mailboxId: string;
11
10
  selectedMessageIds: string[];
12
11
  /**
13
12
  * Sender addresses of the selected messages, driving the literal fallback on
@@ -19,14 +18,13 @@ interface OrganizeDialogProps {
19
18
 
20
19
  /**
21
20
  * Smart-organize flow entered from the selection toolbar. Widens the selection
22
- * to similar mail once (POST /organize/preview), then lets the user commit the
23
- * organize sentence at one of four scopes (RFC 034). The widen is the only
24
- * corpus-wide query; everything after acts on that result.
21
+ * once (POST /organize/preview) to seed the rule, then hands off to the chip
22
+ * editor (RFC 038 D1), which counts and commits over the same endpoints. The
23
+ * widen is only the opening count; the editor re-previews every edit.
25
24
  */
26
25
  export function OrganizeDialog({
27
26
  open,
28
27
  accountId,
29
- mailboxId,
30
28
  selectedMessageIds,
31
29
  selectedSenders,
32
30
  onClose,
@@ -38,7 +36,6 @@ export function OrganizeDialog({
38
36
  matchedCount,
39
37
  semanticUnavailable,
40
38
  senders,
41
- matchPredicate,
42
39
  isPending,
43
40
  isError,
44
41
  error,
@@ -57,7 +54,7 @@ export function OrganizeDialog({
57
54
  if (!open) return null;
58
55
 
59
56
  return (
60
- <Dialog open={open} onClose={handleClose} title="Organize similar mail">
57
+ <Dialog open={open} onClose={handleClose} title="Filter rule">
61
58
  {isPending || matchedCount === undefined ? (
62
59
  isError ? (
63
60
  <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
@@ -80,12 +77,10 @@ export function OrganizeDialog({
80
77
  </div>
81
78
  )
82
79
  ) : (
83
- <OrganizePanel
80
+ <OrganizeRuleEditor
84
81
  accountId={accountId}
85
- mailboxId={mailboxId}
86
82
  selectedMessageIds={selectedMessageIds}
87
- matchPredicate={matchPredicate}
88
- matchedCount={matchedCount}
83
+ seedCount={matchedCount}
89
84
  semanticUnavailable={semanticUnavailable}
90
85
  senders={senders}
91
86
  onClose={handleClose}
@@ -0,0 +1,358 @@
1
+ /**
2
+ * The Organize chip editor (RFC 038 D1) over the live preview/apply endpoints.
3
+ *
4
+ * The contract these tests pin: the count on screen is the count that will be
5
+ * applied. A clause change stales the count and blocks the commit until the
6
+ * debounced re-preview settles; the commit then carries exactly the predicate
7
+ * that was previewed. Capability gating (widen chip, clause vocabulary) and the
8
+ * sender-fallback chips (#251) are covered here too, driven through the real
9
+ * component in a DOM so nothing is asserted against a copy.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { afterEach, describe, it } from "node:test";
14
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
15
+ import { createElement } from "react";
16
+ import { createDomHarness, type DomHarness } from "../../../test-support/dom";
17
+ import { makeMailbox } from "../../../test-support/fixtures";
18
+ import {
19
+ type HttpCall,
20
+ type HttpMock,
21
+ mockFetch,
22
+ } from "../../../test-support/http";
23
+ import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
24
+
25
+ let harness: DomHarness | undefined;
26
+ let http: HttpMock | undefined;
27
+
28
+ afterEach(() => {
29
+ harness?.close();
30
+ harness = undefined;
31
+ http?.restore();
32
+ http = undefined;
33
+ });
34
+
35
+ const ACCOUNT_ID = "acc-1";
36
+
37
+ const MAILBOXES = [
38
+ makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
39
+ makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
40
+ ];
41
+
42
+ type Props = Parameters<typeof OrganizeRuleEditor>[0];
43
+ type Responder = (call: HttpCall) => unknown;
44
+
45
+ const previewCounts = (counts: number[]): Responder => {
46
+ let index = 0;
47
+ return (call) => {
48
+ if (call.path.endsWith("/organize/preview")) {
49
+ const count = counts[Math.min(index, counts.length - 1)];
50
+ index += 1;
51
+ return { matchedCount: count, messageIds: [] };
52
+ }
53
+ if (call.path.endsWith("/organize") && call.method === "POST") {
54
+ return { organizeJobId: "job-1", state: "Running" };
55
+ }
56
+ if (call.path.endsWith("/organize/job-1")) {
57
+ return {
58
+ organizeJobId: "job-1",
59
+ state: "Complete",
60
+ matchedCount: 2,
61
+ appliedCount: 2,
62
+ failedCount: 0,
63
+ };
64
+ }
65
+ if (call.path.endsWith("/filters")) {
66
+ return { filterId: "filter-1", name: "R", scope: "Standing" };
67
+ }
68
+ return {};
69
+ };
70
+ };
71
+
72
+ /**
73
+ * A back-apply job that stays in flight, so the in-progress copy is observable
74
+ * before the poll ever reaches a terminal state.
75
+ */
76
+ const runningJob: Responder = (call) => {
77
+ if (call.path.endsWith("/organize/preview")) {
78
+ return { matchedCount: 2, messageIds: [] };
79
+ }
80
+ if (call.path.endsWith("/organize") && call.method === "POST") {
81
+ return { organizeJobId: "job-1", state: "Running" };
82
+ }
83
+ if (call.path.endsWith("/organize/job-1")) {
84
+ return {
85
+ organizeJobId: "job-1",
86
+ state: "Processing",
87
+ matchedCount: 2,
88
+ appliedCount: 0,
89
+ failedCount: 0,
90
+ };
91
+ }
92
+ return {};
93
+ };
94
+
95
+ const mount = (
96
+ props: Partial<Props>,
97
+ responder: Responder = previewCounts([0]),
98
+ ): DomHarness => {
99
+ http = mockFetch(responder);
100
+ harness = createDomHarness();
101
+ harness.queryClient.setQueryData(
102
+ mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
103
+ { items: MAILBOXES },
104
+ );
105
+ harness.renderApp(
106
+ createElement(OrganizeRuleEditor, {
107
+ accountId: ACCOUNT_ID,
108
+ selectedMessageIds: ["msg-1", "msg-2"],
109
+ seedCount: 47,
110
+ onClose: () => undefined,
111
+ ...props,
112
+ }),
113
+ );
114
+ return harness;
115
+ };
116
+
117
+ /** Let the debounced preview timer fire and its response settle. */
118
+ async function settlePreview(dom: DomHarness): Promise<void> {
119
+ await dom.flush();
120
+ await dom.wait(400);
121
+ await dom.flush();
122
+ await dom.flush();
123
+ }
124
+
125
+ const primaryButton = (dom: DomHarness, label: string): HTMLButtonElement =>
126
+ dom.byText("button", label) as HTMLButtonElement;
127
+
128
+ /** Pick a segmented-control option (scope / operator) by its radio value. */
129
+ const pickSegment = (dom: DomHarness, name: string, value: string): void => {
130
+ const radio = dom.query(`input[name="${name}"][value="${value}"]`);
131
+ if (!radio) throw new Error(`no ${name} option "${value}"`);
132
+ dom.click(radio);
133
+ };
134
+
135
+ describe("OrganizeRuleEditor — capability gating", () => {
136
+ it("renders the semantic widen chip on a deployment that can serve it", () => {
137
+ const dom = mount({});
138
+ assert.match(dom.text(), /and anything similar/i);
139
+ assert.match(dom.text(), /Similar to these 2/);
140
+ });
141
+
142
+ it("does not offer the widen when the deployment cannot serve it (D3/D4)", () => {
143
+ const dom = mount({ semanticUnavailable: true, senders: [] });
144
+ assert.doesNotMatch(dom.text(), /similar/i);
145
+ });
146
+
147
+ it("offers every field the backend now matches, ListId and FromDomain included (#262)", () => {
148
+ const dom = mount({});
149
+ dom.click(primaryButton(dom, "Add clause"));
150
+ const options = [...dom.byLabel("Clause field").querySelectorAll("option")];
151
+ const labels = options.map((option) => option.textContent);
152
+ assert.deepEqual(labels, [
153
+ "From",
154
+ "Subject",
155
+ "Has the words",
156
+ "List",
157
+ "Domain",
158
+ ]);
159
+ });
160
+
161
+ it("explains what a ListId clause matches, including the forward-only caveat (#263)", () => {
162
+ const dom = mount({});
163
+ dom.click(primaryButton(dom, "Add clause"));
164
+ dom.select(dom.byLabel("Clause field"), "ListId");
165
+ assert.match(dom.text(), /List-Id/);
166
+ assert.match(dom.text(), /matched as it arrives/i);
167
+ });
168
+
169
+ it("explains a FromDomain clause matches the registrable domain", () => {
170
+ const dom = mount({});
171
+ dom.click(primaryButton(dom, "Add clause"));
172
+ dom.select(dom.byLabel("Clause field"), "FromDomain");
173
+ assert.match(dom.text(), /registrable domain/i);
174
+ });
175
+ });
176
+
177
+ describe("OrganizeRuleEditor — ListId normalization (#262)", () => {
178
+ it("normalizes a ListId clause value to the backend's canonical form on add", async () => {
179
+ const dom = mount({}, previewCounts([9]));
180
+ dom.select(dom.byLabel("Destination folder"), "mbx-archive");
181
+ await dom.flush();
182
+
183
+ dom.click(primaryButton(dom, "Add clause"));
184
+ dom.select(dom.byLabel("Clause field"), "ListId");
185
+ dom.type(dom.byLabel("Clause value"), "<Weekly.News.EXAMPLE.com>");
186
+ dom.click(primaryButton(dom, "Add"));
187
+ await settlePreview(dom);
188
+
189
+ // The chip and the previewed predicate carry the normalized value.
190
+ assert.match(dom.text(), /weekly\.news\.example\.com/);
191
+ assert.doesNotMatch(dom.text(), /Weekly\.News\.EXAMPLE/);
192
+ const preview = http?.to("/organize/preview") ?? [];
193
+ assert.deepEqual(preview[0].body?.literalClauses, [
194
+ { field: "ListId", value: "weekly.news.example.com" },
195
+ ]);
196
+ });
197
+ });
198
+
199
+ describe("OrganizeRuleEditor — sender fallback collapse (#262)", () => {
200
+ it("renders a single derived FromDomain chip when the senders share a domain", () => {
201
+ const dom = mount({
202
+ semanticUnavailable: true,
203
+ senders: [
204
+ "npm@github.com",
205
+ "notifications@github.com",
206
+ "ci@sub.github.com",
207
+ ],
208
+ seedCount: 342,
209
+ });
210
+ assert.match(dom.text(), /github\.com/);
211
+ assert.doesNotMatch(dom.text(), /notifications@github\.com/);
212
+ assert.match(dom.text(), /Domain/);
213
+ });
214
+ });
215
+
216
+ describe("OrganizeRuleEditor — sender fallback (#251)", () => {
217
+ it("renders the derived sender addresses as visible, editable From chips when domains differ", () => {
218
+ const dom = mount({
219
+ semanticUnavailable: true,
220
+ senders: ["npm@github.com", "noreply@medium.com"],
221
+ });
222
+ assert.match(dom.text(), /npm@github\.com/);
223
+ assert.match(dom.text(), /noreply@medium\.com/);
224
+ assert.match(dom.text(), /from sender/i);
225
+ });
226
+ });
227
+
228
+ describe("OrganizeRuleEditor — the previewed set equals the applied set", () => {
229
+ it("blocks the commit while the count is stale, then applies exactly the previewed predicate", async () => {
230
+ const dom = mount({}, previewCounts([12]));
231
+
232
+ dom.select(dom.byLabel("Destination folder"), "mbx-archive");
233
+ await dom.flush();
234
+
235
+ // Seeded count, no clause yet: the commit is actionable.
236
+ assert.equal(primaryButton(dom, "Apply now").disabled, false);
237
+
238
+ // Add a Subject clause — the predicate changes, so the count is stale and
239
+ // the commit must wait for the recount.
240
+ dom.click(primaryButton(dom, "Add clause"));
241
+ dom.select(dom.byLabel("Clause field"), "Subject");
242
+ dom.type(dom.byLabel("Clause value"), "receipt");
243
+ dom.click(primaryButton(dom, "Add"));
244
+ await dom.flush();
245
+
246
+ assert.match(dom.text(), /recounting/i);
247
+ assert.equal(primaryButton(dom, "Apply now").disabled, true);
248
+
249
+ await settlePreview(dom);
250
+
251
+ // The recount landed for the new predicate: the commit unblocks.
252
+ assert.equal(primaryButton(dom, "Apply now").disabled, false);
253
+
254
+ // The debounced preview carried the new predicate.
255
+ const preview = http?.to("/organize/preview") ?? [];
256
+ assert.equal(preview.length, 1);
257
+ assert.equal(preview[0].body?.anchorMessageId, "msg-1");
258
+ assert.deepEqual(preview[0].body?.literalClauses, [
259
+ { field: "Subject", value: "receipt" },
260
+ ]);
261
+
262
+ dom.click(primaryButton(dom, "Apply now"));
263
+ await dom.flush();
264
+
265
+ // Apply carries exactly what was previewed — anchor + the same clause.
266
+ const applied = (http?.calls ?? []).filter(
267
+ (call) => call.path.endsWith("/organize") && call.method === "POST",
268
+ );
269
+ assert.equal(applied.length, 1);
270
+ assert.equal(applied[0].body?.anchorMessageId, "msg-1");
271
+ assert.equal(applied[0].body?.matchOperator, "And");
272
+ assert.deepEqual(applied[0].body?.literalClauses, [
273
+ { field: "Subject", value: "receipt" },
274
+ ]);
275
+ });
276
+ });
277
+
278
+ describe("OrganizeRuleEditor — scope mapping", () => {
279
+ it("saves a standing filter carrying the sender fallback clauses", async () => {
280
+ const dom = mount({
281
+ semanticUnavailable: true,
282
+ senders: ["npm@github.com"],
283
+ seedScope: "standing",
284
+ seedMailboxId: "mbx-archive",
285
+ seedCount: 128,
286
+ });
287
+ await dom.flush();
288
+
289
+ dom.type(dom.byLabel("Rule name"), "GitHub");
290
+ dom.click(primaryButton(dom, "Save rule"));
291
+ await dom.flush();
292
+
293
+ const created = (http?.calls ?? []).filter((call) =>
294
+ call.path.endsWith("/filters"),
295
+ );
296
+ assert.equal(created.length, 1);
297
+ assert.equal(created[0].body?.scope, "Standing");
298
+ assert.equal(created[0].body?.name, "GitHub");
299
+ assert.equal(created[0].body?.matchOperator, "Or");
300
+ assert.deepEqual(created[0].body?.literalClauses, [
301
+ { field: "From", value: "npm@github.com" },
302
+ ]);
303
+ assert.match(dom.text(), /Filter saved/);
304
+ });
305
+
306
+ it("saves an until-a-date filter with the derived expiry", async () => {
307
+ const dom = mount({ seedMailboxId: "mbx-archive" });
308
+ await dom.flush();
309
+
310
+ pickSegment(dom, "rule-scope", "until");
311
+ await dom.flush();
312
+ dom.type(dom.byLabel("Rule name"), "Sale");
313
+ dom.type(dom.byLabel("Expiry date"), "2999-01-02");
314
+ dom.click(primaryButton(dom, "Save until then"));
315
+ await dom.flush();
316
+
317
+ const created = (http?.calls ?? []).filter((call) =>
318
+ call.path.endsWith("/filters"),
319
+ );
320
+ assert.equal(created.length, 1);
321
+ assert.equal(created[0].body?.scope, "Temporary");
322
+ assert.ok(String(created[0].body?.expiresAt).startsWith("2999-01-02"));
323
+ });
324
+ });
325
+
326
+ describe("OrganizeRuleEditor — back-apply progress copy (#250 honesty)", () => {
327
+ async function startJob(dom: DomHarness): Promise<void> {
328
+ dom.select(dom.byLabel("Destination folder"), "mbx-archive");
329
+ await dom.flush();
330
+ dom.click(primaryButton(dom, "Apply now"));
331
+ for (let attempt = 0; attempt < 20; attempt += 1) {
332
+ await dom.flush();
333
+ if (/Organizing/.test(dom.text())) return;
334
+ await dom.wait(1);
335
+ }
336
+ }
337
+
338
+ it("keeps the similar-mail wording on the semantic path", async () => {
339
+ const dom = mount({}, runningJob);
340
+ await startJob(dom);
341
+ assert.match(dom.text(), /Organizing similar mail/);
342
+ assert.doesNotMatch(dom.text(), /from these senders/);
343
+ });
344
+
345
+ it("states the sender semantics in the sender fallback", async () => {
346
+ const dom = mount(
347
+ {
348
+ semanticUnavailable: true,
349
+ senders: ["npm@github.com"],
350
+ seedCount: 128,
351
+ },
352
+ runningJob,
353
+ );
354
+ await startJob(dom);
355
+ assert.match(dom.text(), /Organizing mail from these senders/);
356
+ assert.doesNotMatch(dom.text(), /Organizing similar mail/);
357
+ });
358
+ });