@remit/web-client 0.0.91 → 0.0.93

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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/DailyBrief.selection.test.ts +6 -2
  3. package/src/components/mail/DailyBrief.tsx +47 -110
  4. package/src/components/mail/MessageList.selection.test.ts +18 -6
  5. package/src/components/mail/MessageList.tsx +83 -83
  6. package/src/components/mail/SelectionWizardHost.tsx +779 -0
  7. package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
  8. package/src/components/settings/FilterEditor.tsx +1 -1
  9. package/src/hooks/useCreateMailbox.ts +16 -4
  10. package/src/hooks/useFilters.ts +30 -1
  11. package/src/hooks/useMatchSample.ts +63 -0
  12. package/src/hooks/useRulePreview.ts +23 -3
  13. package/src/lib/organize/organize-model.test.ts +110 -0
  14. package/src/lib/organize/organize-model.ts +69 -0
  15. package/src/lib/organize/rule-model.ts +2 -0
  16. package/src/lib/selection-mode.test.ts +20 -6
  17. package/src/lib/selection-mode.ts +8 -1
  18. package/src/lib/wizard-history.test.ts +176 -0
  19. package/src/lib/wizard-history.ts +131 -0
  20. package/src/routes/mail.tsx +4 -0
  21. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  22. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  23. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  24. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  25. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  26. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  27. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  28. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  29. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  30. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  31. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -1,498 +0,0 @@
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
- httpError,
22
- mockFetch,
23
- } from "../../../test-support/http";
24
- import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
25
-
26
- let harness: DomHarness | undefined;
27
- let http: HttpMock | undefined;
28
-
29
- afterEach(() => {
30
- harness?.close();
31
- harness = undefined;
32
- http?.restore();
33
- http = undefined;
34
- });
35
-
36
- const ACCOUNT_ID = "acc-1";
37
-
38
- const MAILBOXES = [
39
- makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
40
- makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
41
- ];
42
-
43
- type Props = Parameters<typeof OrganizeRuleEditor>[0];
44
- type Responder = (call: HttpCall) => unknown;
45
-
46
- const previewCounts = (counts: number[]): Responder => {
47
- let index = 0;
48
- return (call) => {
49
- if (call.path.endsWith("/organize/preview")) {
50
- const count = counts[Math.min(index, counts.length - 1)];
51
- index += 1;
52
- return { matchedCount: count, messageIds: [] };
53
- }
54
- if (call.path.endsWith("/organize") && call.method === "POST") {
55
- return { organizeJobId: "job-1", state: "Running" };
56
- }
57
- if (call.path.endsWith("/organize/job-1")) {
58
- return {
59
- organizeJobId: "job-1",
60
- state: "Complete",
61
- matchedCount: 2,
62
- appliedCount: 2,
63
- failedCount: 0,
64
- };
65
- }
66
- if (call.path.endsWith("/filters")) {
67
- return { filterId: "filter-1", name: "R", scope: "Standing" };
68
- }
69
- return {};
70
- };
71
- };
72
-
73
- /**
74
- * A back-apply job that stays in flight, so the in-progress copy is observable
75
- * before the poll ever reaches a terminal state.
76
- */
77
- const runningJob: Responder = (call) => {
78
- if (call.path.endsWith("/organize/preview")) {
79
- return { matchedCount: 2, messageIds: [] };
80
- }
81
- if (call.path.endsWith("/organize") && call.method === "POST") {
82
- return { organizeJobId: "job-1", state: "Running" };
83
- }
84
- if (call.path.endsWith("/organize/job-1")) {
85
- return {
86
- organizeJobId: "job-1",
87
- state: "Processing",
88
- matchedCount: 2,
89
- appliedCount: 0,
90
- failedCount: 0,
91
- };
92
- }
93
- return {};
94
- };
95
-
96
- const mount = (
97
- props: Partial<Props>,
98
- responder: Responder = previewCounts([0]),
99
- ): DomHarness => {
100
- http = mockFetch(responder);
101
- harness = createDomHarness();
102
- harness.queryClient.setQueryData(
103
- mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
104
- { items: MAILBOXES },
105
- );
106
- harness.renderApp(
107
- createElement(OrganizeRuleEditor, {
108
- accountId: ACCOUNT_ID,
109
- selectedMessageIds: ["msg-1", "msg-2"],
110
- seedCount: 47,
111
- onClose: () => undefined,
112
- ...props,
113
- }),
114
- );
115
- return harness;
116
- };
117
-
118
- /** Let the debounced preview timer fire and its response settle. */
119
- async function settlePreview(dom: DomHarness): Promise<void> {
120
- await dom.flush();
121
- await dom.wait(400);
122
- await dom.flush();
123
- await dom.flush();
124
- }
125
-
126
- const primaryButton = (dom: DomHarness, label: string): HTMLButtonElement =>
127
- dom.byText("button", label) as HTMLButtonElement;
128
-
129
- /** Pick a segmented-control option (scope / operator) by its radio value. */
130
- const pickSegment = (dom: DomHarness, name: string, value: string): void => {
131
- const radio = dom.query(`input[name="${name}"][value="${value}"]`);
132
- if (!radio) throw new Error(`no ${name} option "${value}"`);
133
- dom.click(radio);
134
- };
135
-
136
- describe("OrganizeRuleEditor — capability gating", () => {
137
- it("renders the semantic widen chip on a deployment that can serve it", () => {
138
- const dom = mount({});
139
- assert.match(dom.text(), /and anything similar/i);
140
- assert.match(dom.text(), /Similar to these 2/);
141
- });
142
-
143
- it("does not offer the widen when the deployment cannot serve it (D3/D4)", () => {
144
- const dom = mount({ semanticUnavailable: true, senders: [] });
145
- assert.doesNotMatch(dom.text(), /similar/i);
146
- });
147
-
148
- it("offers every field the backend now matches, ListId and FromDomain included (#262)", () => {
149
- const dom = mount({});
150
- dom.click(primaryButton(dom, "Add clause"));
151
- const options = [...dom.byLabel("Clause field").querySelectorAll("option")];
152
- const labels = options.map((option) => option.textContent);
153
- assert.deepEqual(labels, [
154
- "From",
155
- "Subject",
156
- "Has the words",
157
- "List",
158
- "Domain",
159
- ]);
160
- });
161
-
162
- it("explains what a ListId clause matches, including the forward-only caveat (#263)", () => {
163
- const dom = mount({});
164
- dom.click(primaryButton(dom, "Add clause"));
165
- dom.select(dom.byLabel("Clause field"), "ListId");
166
- assert.match(dom.text(), /List-Id/);
167
- assert.match(dom.text(), /matched as it arrives/i);
168
- });
169
-
170
- it("explains a FromDomain clause matches the registrable domain", () => {
171
- const dom = mount({});
172
- dom.click(primaryButton(dom, "Add clause"));
173
- dom.select(dom.byLabel("Clause field"), "FromDomain");
174
- assert.match(dom.text(), /registrable domain/i);
175
- });
176
- });
177
-
178
- describe("OrganizeRuleEditor — ListId normalization (#262)", () => {
179
- it("normalizes a ListId clause value to the backend's canonical form on add", async () => {
180
- const dom = mount({}, previewCounts([9]));
181
- dom.select(dom.byLabel("Destination folder"), "mbx-archive");
182
- await dom.flush();
183
-
184
- dom.click(primaryButton(dom, "Add clause"));
185
- dom.select(dom.byLabel("Clause field"), "ListId");
186
- dom.type(dom.byLabel("Clause value"), "<Weekly.News.EXAMPLE.com>");
187
- dom.click(primaryButton(dom, "Add"));
188
- await settlePreview(dom);
189
-
190
- // The chip and the previewed predicate carry the normalized value.
191
- assert.match(dom.text(), /weekly\.news\.example\.com/);
192
- assert.doesNotMatch(dom.text(), /Weekly\.News\.EXAMPLE/);
193
- const preview = http?.to("/organize/preview") ?? [];
194
- assert.deepEqual(preview[0].body?.literalClauses, [
195
- { field: "ListId", value: "weekly.news.example.com" },
196
- ]);
197
- });
198
- });
199
-
200
- describe("OrganizeRuleEditor — sender fallback collapse (#262)", () => {
201
- it("renders a single derived FromDomain chip when the senders share a domain", () => {
202
- const dom = mount({
203
- semanticUnavailable: true,
204
- senders: [
205
- "npm@github.com",
206
- "notifications@github.com",
207
- "ci@sub.github.com",
208
- ],
209
- seedCount: 342,
210
- });
211
- assert.match(dom.text(), /github\.com/);
212
- assert.doesNotMatch(dom.text(), /notifications@github\.com/);
213
- assert.match(dom.text(), /Domain/);
214
- });
215
- });
216
-
217
- describe("OrganizeRuleEditor — sender fallback (#251)", () => {
218
- it("renders the derived sender addresses as visible, editable From chips when domains differ", () => {
219
- const dom = mount({
220
- semanticUnavailable: true,
221
- senders: ["npm@github.com", "noreply@medium.com"],
222
- });
223
- assert.match(dom.text(), /npm@github\.com/);
224
- assert.match(dom.text(), /noreply@medium\.com/);
225
- assert.match(dom.text(), /from sender/i);
226
- });
227
- });
228
-
229
- describe("OrganizeRuleEditor — the previewed set equals the applied set", () => {
230
- it("blocks the commit while the count is stale, then applies exactly the previewed predicate", async () => {
231
- const dom = mount({}, previewCounts([12]));
232
-
233
- dom.select(dom.byLabel("Destination folder"), "mbx-archive");
234
- await dom.flush();
235
-
236
- // Seeded count, no clause yet: the commit is actionable.
237
- assert.equal(primaryButton(dom, "Apply now").disabled, false);
238
-
239
- // Add a Subject clause — the predicate changes, so the count is stale and
240
- // the commit must wait for the recount.
241
- dom.click(primaryButton(dom, "Add clause"));
242
- dom.select(dom.byLabel("Clause field"), "Subject");
243
- dom.type(dom.byLabel("Clause value"), "receipt");
244
- dom.click(primaryButton(dom, "Add"));
245
- await dom.flush();
246
-
247
- assert.match(dom.text(), /recounting/i);
248
- assert.equal(primaryButton(dom, "Apply now").disabled, true);
249
-
250
- await settlePreview(dom);
251
-
252
- // The recount landed for the new predicate: the commit unblocks.
253
- assert.equal(primaryButton(dom, "Apply now").disabled, false);
254
-
255
- // The debounced preview carried the new predicate.
256
- const preview = http?.to("/organize/preview") ?? [];
257
- assert.equal(preview.length, 1);
258
- assert.equal(preview[0].body?.anchorMessageId, "msg-1");
259
- assert.deepEqual(preview[0].body?.literalClauses, [
260
- { field: "Subject", value: "receipt" },
261
- ]);
262
-
263
- dom.click(primaryButton(dom, "Apply now"));
264
- await dom.flush();
265
-
266
- // Apply carries exactly what was previewed — anchor + the same clause.
267
- const applied = (http?.calls ?? []).filter(
268
- (call) => call.path.endsWith("/organize") && call.method === "POST",
269
- );
270
- assert.equal(applied.length, 1);
271
- assert.equal(applied[0].body?.anchorMessageId, "msg-1");
272
- assert.equal(applied[0].body?.matchOperator, "And");
273
- assert.deepEqual(applied[0].body?.literalClauses, [
274
- { field: "Subject", value: "receipt" },
275
- ]);
276
- });
277
- });
278
-
279
- describe("OrganizeRuleEditor — scope mapping", () => {
280
- it("saves a standing filter, then back-applies it over the existing mail", async () => {
281
- const dom = mount({
282
- semanticUnavailable: true,
283
- senders: ["npm@github.com"],
284
- seedScope: "standing",
285
- seedMailboxId: "mbx-archive",
286
- seedCount: 128,
287
- });
288
- await dom.flush();
289
-
290
- dom.type(dom.byLabel("Rule name"), "GitHub");
291
- dom.click(primaryButton(dom, "Save rule"));
292
-
293
- // The filter is created, then the same predicate is back-applied over the
294
- // mail already in the mailbox — not only the mail that arrives next.
295
- for (let attempt = 0; attempt < 40; attempt += 1) {
296
- await dom.flush();
297
- if (/moved/.test(dom.text())) break;
298
- await dom.wait(5);
299
- }
300
-
301
- const created = (http?.calls ?? []).filter((call) =>
302
- call.path.endsWith("/filters"),
303
- );
304
- assert.equal(created.length, 1);
305
- assert.equal(created[0].body?.scope, "Standing");
306
- assert.equal(created[0].body?.name, "GitHub");
307
- assert.equal(created[0].body?.matchOperator, "Or");
308
- assert.deepEqual(created[0].body?.literalClauses, [
309
- { field: "From", value: "npm@github.com" },
310
- ]);
311
-
312
- // The back-apply carries exactly the filter's predicate and runs after it.
313
- const backApply = (http?.calls ?? []).filter(
314
- (call) => call.path.endsWith("/organize") && call.method === "POST",
315
- );
316
- assert.equal(backApply.length, 1);
317
- assert.equal(backApply[0].body?.matchOperator, "Or");
318
- assert.deepEqual(backApply[0].body?.literalClauses, [
319
- { field: "From", value: "npm@github.com" },
320
- ]);
321
- assert.ok(
322
- (http?.calls ?? []).indexOf(created[0]) <
323
- (http?.calls ?? []).indexOf(backApply[0]),
324
- "the filter is created before the back-apply runs",
325
- );
326
-
327
- // The flow lands on the back-apply's done summary, not a bare saved screen.
328
- assert.match(dom.text(), /Done/);
329
- assert.doesNotMatch(dom.text(), /Filter saved/);
330
- });
331
-
332
- it("saves an until-a-date filter with the derived expiry", async () => {
333
- const dom = mount({ seedMailboxId: "mbx-archive" });
334
- await dom.flush();
335
-
336
- pickSegment(dom, "rule-scope", "until");
337
- await dom.flush();
338
- dom.type(dom.byLabel("Rule name"), "Sale");
339
- dom.type(dom.byLabel("Expiry date"), "2999-01-02");
340
- dom.click(primaryButton(dom, "Save until then"));
341
- await dom.flush();
342
-
343
- const created = (http?.calls ?? []).filter((call) =>
344
- call.path.endsWith("/filters"),
345
- );
346
- assert.equal(created.length, 1);
347
- assert.equal(created[0].body?.scope, "Temporary");
348
- assert.ok(String(created[0].body?.expiresAt).startsWith("2999-01-02"));
349
- });
350
- });
351
-
352
- describe("OrganizeRuleEditor — back-apply gating and failure", () => {
353
- async function flushUntil(
354
- dom: DomHarness,
355
- holds: () => boolean,
356
- ): Promise<void> {
357
- for (let attempt = 0; attempt < 40; attempt += 1) {
358
- await dom.flush();
359
- if (holds()) return;
360
- await dom.wait(5);
361
- }
362
- }
363
-
364
- it("skips the back-apply for a HasWords rule, saving it for incoming mail only", async () => {
365
- const dom = mount(
366
- {
367
- semanticUnavailable: true,
368
- senders: [],
369
- seedScope: "standing",
370
- seedMailboxId: "mbx-archive",
371
- },
372
- previewCounts([5]),
373
- );
374
- await dom.flush();
375
-
376
- // A body-content clause: the vector-free back-apply can't evaluate it.
377
- dom.click(primaryButton(dom, "Add clause"));
378
- dom.select(dom.byLabel("Clause field"), "HasWords");
379
- dom.type(dom.byLabel("Clause value"), "invoice");
380
- dom.click(primaryButton(dom, "Add"));
381
- await settlePreview(dom);
382
-
383
- dom.type(dom.byLabel("Rule name"), "Invoices");
384
- dom.click(primaryButton(dom, "Save rule"));
385
- await dom.flush();
386
- await dom.flush();
387
-
388
- // The filter is created and carries the clause.
389
- const created = (http?.calls ?? []).filter((call) =>
390
- call.path.endsWith("/filters"),
391
- );
392
- assert.equal(created.length, 1);
393
- assert.deepEqual(created[0].body?.literalClauses, [
394
- { field: "HasWords", value: "invoice" },
395
- ]);
396
-
397
- // No back-apply is attempted — the guard is never reached.
398
- const backApply = (http?.calls ?? []).filter(
399
- (call) => call.path.endsWith("/organize") && call.method === "POST",
400
- );
401
- assert.equal(backApply.length, 0);
402
- assert.match(dom.text(), /Filter saved/);
403
- });
404
-
405
- it("surfaces a failed back-apply start distinctly, and retries it", async () => {
406
- const failStart: Responder = (call) => {
407
- if (call.path.endsWith("/organize/preview")) {
408
- return { matchedCount: 2, messageIds: [] };
409
- }
410
- if (call.path.endsWith("/filters")) {
411
- return { filterId: "filter-1", name: "R", scope: "Standing" };
412
- }
413
- if (call.path.endsWith("/organize") && call.method === "POST") {
414
- return httpError(500);
415
- }
416
- return {};
417
- };
418
- const dom = mount(
419
- {
420
- semanticUnavailable: true,
421
- senders: ["npm@github.com"],
422
- seedScope: "standing",
423
- seedMailboxId: "mbx-archive",
424
- seedCount: 128,
425
- },
426
- failStart,
427
- );
428
- await dom.flush();
429
-
430
- dom.type(dom.byLabel("Rule name"), "GitHub");
431
- dom.click(primaryButton(dom, "Save rule"));
432
-
433
- await flushUntil(dom, () => /Move existing mail/.test(dom.text()));
434
-
435
- // The filter saved and the start was attempted once, then failed.
436
- const created = (http?.calls ?? []).filter((call) =>
437
- call.path.endsWith("/filters"),
438
- );
439
- assert.equal(created.length, 1);
440
- const firstStart = (http?.calls ?? []).filter(
441
- (call) => call.path.endsWith("/organize") && call.method === "POST",
442
- );
443
- assert.equal(firstStart.length, 1);
444
-
445
- // The failure is honest: the rule saved, the move is what to retry — never
446
- // a bare "Filter saved" that hides it.
447
- assert.match(dom.text(), /Rule saved/);
448
- assert.doesNotMatch(dom.text(), /Filter saved/);
449
-
450
- // Retrying re-issues the back-apply start.
451
- dom.click(primaryButton(dom, "Move existing mail"));
452
- await flushUntil(
453
- dom,
454
- () =>
455
- (http?.calls ?? []).filter(
456
- (call) => call.path.endsWith("/organize") && call.method === "POST",
457
- ).length >= 2,
458
- );
459
- const afterRetry = (http?.calls ?? []).filter(
460
- (call) => call.path.endsWith("/organize") && call.method === "POST",
461
- );
462
- assert.ok(afterRetry.length >= 2);
463
- });
464
- });
465
-
466
- describe("OrganizeRuleEditor — back-apply progress copy (#250 honesty)", () => {
467
- async function startJob(dom: DomHarness): Promise<void> {
468
- dom.select(dom.byLabel("Destination folder"), "mbx-archive");
469
- await dom.flush();
470
- dom.click(primaryButton(dom, "Apply now"));
471
- for (let attempt = 0; attempt < 20; attempt += 1) {
472
- await dom.flush();
473
- if (/Organizing/.test(dom.text())) return;
474
- await dom.wait(1);
475
- }
476
- }
477
-
478
- it("keeps the similar-mail wording on the semantic path", async () => {
479
- const dom = mount({}, runningJob);
480
- await startJob(dom);
481
- assert.match(dom.text(), /Organizing similar mail/);
482
- assert.doesNotMatch(dom.text(), /from these senders/);
483
- });
484
-
485
- it("states the sender semantics in the sender fallback", async () => {
486
- const dom = mount(
487
- {
488
- semanticUnavailable: true,
489
- senders: ["npm@github.com"],
490
- seedCount: 128,
491
- },
492
- runningJob,
493
- );
494
- await startJob(dom);
495
- assert.match(dom.text(), /Organizing mail from these senders/);
496
- assert.doesNotMatch(dom.text(), /Organizing similar mail/);
497
- });
498
- });