@remit/web-client 0.0.72 → 0.0.74

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.72",
3
+ "version": "0.0.74",
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": {
@@ -236,9 +236,9 @@ const MessageRowComponent = ({
236
236
  isChecked && "bg-accent-soft",
237
237
  // Long-press enters selection mode; without these, Android Chrome opens
238
238
  // the link context menu / starts text selection and iOS Safari fires the
239
- // callout, racing the app's handler. react-aria suppresses
240
- // contextmenu/text-selection but not iOS's callout it fires no
241
- // cancelable event, so CSS is the only lever.
239
+ // callout, racing the app's handler. useLongPress suppresses the
240
+ // touch-fired contextmenu; the callout fires no cancelable event, so
241
+ // `select-none` / `-webkit-touch-callout: none` are the only lever left.
242
242
  !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
243
243
  );
244
244
 
@@ -18,6 +18,7 @@ import { makeMailbox } from "../../../test-support/fixtures";
18
18
  import {
19
19
  type HttpCall,
20
20
  type HttpMock,
21
+ httpError,
21
22
  mockFetch,
22
23
  } from "../../../test-support/http";
23
24
  import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
@@ -276,7 +277,7 @@ describe("OrganizeRuleEditor — the previewed set equals the applied set", () =
276
277
  });
277
278
 
278
279
  describe("OrganizeRuleEditor — scope mapping", () => {
279
- it("saves a standing filter carrying the sender fallback clauses", async () => {
280
+ it("saves a standing filter, then back-applies it over the existing mail", async () => {
280
281
  const dom = mount({
281
282
  semanticUnavailable: true,
282
283
  senders: ["npm@github.com"],
@@ -288,7 +289,14 @@ describe("OrganizeRuleEditor — scope mapping", () => {
288
289
 
289
290
  dom.type(dom.byLabel("Rule name"), "GitHub");
290
291
  dom.click(primaryButton(dom, "Save rule"));
291
- await dom.flush();
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
+ }
292
300
 
293
301
  const created = (http?.calls ?? []).filter((call) =>
294
302
  call.path.endsWith("/filters"),
@@ -300,7 +308,25 @@ describe("OrganizeRuleEditor — scope mapping", () => {
300
308
  assert.deepEqual(created[0].body?.literalClauses, [
301
309
  { field: "From", value: "npm@github.com" },
302
310
  ]);
303
- assert.match(dom.text(), /Filter saved/);
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/);
304
330
  });
305
331
 
306
332
  it("saves an until-a-date filter with the derived expiry", async () => {
@@ -323,6 +349,120 @@ describe("OrganizeRuleEditor — scope mapping", () => {
323
349
  });
324
350
  });
325
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
+
326
466
  describe("OrganizeRuleEditor — back-apply progress copy (#250 honesty)", () => {
327
467
  async function startJob(dom: DomHarness): Promise<void> {
328
468
  dom.select(dom.byLabel("Destination folder"), "mbx-archive");
@@ -6,7 +6,7 @@ import {
6
6
  type RuleScope,
7
7
  } from "@remit/ui";
8
8
  import { useQuery } from "@tanstack/react-query";
9
- import { useMemo, useState } from "react";
9
+ import { useEffect, useMemo, useRef, useState } from "react";
10
10
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
11
11
  import { useCreateFilter } from "@/hooks/useFilters";
12
12
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
@@ -14,6 +14,10 @@ import { useRuleEditorState } from "@/hooks/useRuleEditorState";
14
14
  import { useRulePreview } from "@/hooks/useRulePreview";
15
15
  import { getMailboxDisplayName } from "@/lib/folder-roles";
16
16
  import { buildMoveTargets } from "@/lib/move-targets";
17
+ import {
18
+ canBackApplyDraft,
19
+ type OrganizeDraft,
20
+ } from "@/lib/organize/organize-model";
17
21
  import {
18
22
  buildInitialRule,
19
23
  rulePredicate,
@@ -21,6 +25,7 @@ import {
21
25
  SUPPORTED_CLAUSE_FIELDS,
22
26
  } from "@/lib/organize/rule-model";
23
27
  import {
28
+ BackApplyError,
24
29
  CommitError,
25
30
  FilterSaved,
26
31
  JobProgress,
@@ -50,7 +55,9 @@ interface OrganizeRuleEditorProps {
50
55
  * The Organize surface as the chip editor (RFC 038 D1). The rule is rendered and
51
56
  * edited over the existing preview/apply endpoints: clause chips, a
52
57
  * match-operator toggle, a move action, and a scope that maps one-time apply to
53
- * a back-apply job and standing/until to a `Filter`. The count is live and the
58
+ * a back-apply job and standing/until to a `Filter`. Creating a filter also runs
59
+ * the back-apply once, so the rule reaches the mail already in the mailbox and
60
+ * not only the mail that arrives next. The count is live and the
54
61
  * commit gate holds apply until it settles, so the set the editor shows is the
55
62
  * set a commit acts on. Rendered inside the desktop dialog and the mobile sheet
56
63
  * alike, so the two cannot drift.
@@ -107,12 +114,26 @@ export function OrganizeRuleEditor({
107
114
  const createFilter = useCreateFilter(accountId);
108
115
  const { createFolder } = useCreateMailbox(accountId);
109
116
 
117
+ // Creating a filter also moves the mail that already matches, not only the
118
+ // mail that arrives next: the same retroactive back-apply the one-time scope
119
+ // runs. The filter is created first so the rule is live before the pass, then
120
+ // the pass runs over the existing corpus. `backApplyDraft` is the predicate
121
+ // to run once the create succeeds, and is undefined when the rule cannot be
122
+ // back-applied (a `HasWords` clause the vector-free pass can't evaluate — the
123
+ // filter still saves and applies to incoming mail). It is kept, not cleared,
124
+ // so a failed start can be retried; `backApplyStarted` guards the one-shot
125
+ // auto-start against re-firing on re-render.
126
+ const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
127
+ const backApplyStarted = useRef(false);
128
+
110
129
  const commit = () => {
111
130
  const draft = ruleToDraft(rule, anchorMessageId);
112
131
  if (rule.scope === "once") {
113
132
  organizeJob.start(draft);
114
133
  return;
115
134
  }
135
+ backApplyStarted.current = false;
136
+ setBackApplyDraft(canBackApplyDraft(draft) ? draft : undefined);
116
137
  createFilter.createFilter(
117
138
  draft,
118
139
  rule.scope === "standing" ? "standing" : "temporary",
@@ -120,6 +141,17 @@ export function OrganizeRuleEditor({
120
141
  );
121
142
  };
122
143
 
144
+ useEffect(() => {
145
+ if (!backApplyDraft || backApplyStarted.current || !createFilter.isSuccess)
146
+ return;
147
+ backApplyStarted.current = true;
148
+ organizeJob.start(backApplyDraft);
149
+ }, [backApplyDraft, createFilter.isSuccess, organizeJob.start]);
150
+
151
+ const retryBackApply = () => {
152
+ if (backApplyDraft) organizeJob.start(backApplyDraft);
153
+ };
154
+
123
155
  if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
124
156
  return (
125
157
  <JobProgress
@@ -135,11 +167,26 @@ export function OrganizeRuleEditor({
135
167
  );
136
168
  }
137
169
 
170
+ // The filter saved, but the back-apply's own request failed (a 5xx or dropped
171
+ // connection on start, or a failed retry) — no job id was ever assigned, so no
172
+ // JobProgress state holds. Surface it distinctly instead of falling through to
173
+ // "Filter saved" and swallowing it: the rule is live, the move is what to
174
+ // retry.
175
+ if (createFilter.isSuccess && organizeJob.isError) {
176
+ return <BackApplyError onRetry={retryBackApply} onClose={onClose} />;
177
+ }
178
+
138
179
  if (createFilter.isPending) {
139
180
  return <SavingState />;
140
181
  }
141
182
 
142
183
  if (createFilter.isSuccess) {
184
+ // The filter is saved; the back-apply is about to start (the effect hands
185
+ // off to the job on the next tick). Keep the saving state until it takes
186
+ // over so the success screen never flashes between them. When the rule
187
+ // can't be back-applied, `backApplyDraft` is undefined and this is the
188
+ // terminal state — saved, applying to incoming mail only.
189
+ if (backApplyDraft) return <SavingState />;
143
190
  return <FilterSaved onClose={onClose} />;
144
191
  }
145
192
 
@@ -0,0 +1,147 @@
1
+ import { BottomSheet } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import type { ReactNode } from "react";
4
+ import {
5
+ BackApplyError,
6
+ CommitError,
7
+ FilterSaved,
8
+ JobProgress,
9
+ SavingState,
10
+ } from "./rule-editor-states";
11
+
12
+ /**
13
+ * `Flows/Smart Organize/Rule editor states` — the non-editing states the rule
14
+ * editor lands in after a commit, rendered from the real components the Organize
15
+ * surface mounts. Creating a standing filter now runs the retroactive back-apply,
16
+ * so a standing save flows Saving → back-apply in flight → done, the same job
17
+ * states the one-time apply reaches. Every state here is what ships, so the flow
18
+ * cannot drift from the story.
19
+ */
20
+
21
+ function SheetStage({ children }: { children: ReactNode }) {
22
+ return (
23
+ <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[520px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
24
+ <BottomSheet open onClose={() => undefined}>
25
+ {children}
26
+ </BottomSheet>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ const meta: Meta = {
32
+ title: "Flows/Smart Organize/Rule editor states",
33
+ parameters: { layout: "fullscreen" },
34
+ };
35
+ export default meta;
36
+
37
+ type Story = StoryObj;
38
+
39
+ /** Saving the filter — held while the create request is in flight. */
40
+ export const Saving: Story = {
41
+ render: () => (
42
+ <SheetStage>
43
+ <SavingState />
44
+ </SheetStage>
45
+ ),
46
+ };
47
+
48
+ /**
49
+ * The back-apply running after a standing filter is created — the pass that
50
+ * reaches the mail already in the mailbox, not only the mail that arrives next.
51
+ */
52
+ export const BackApplyInFlight: Story = {
53
+ name: "Back-apply in flight",
54
+ render: () => (
55
+ <SheetStage>
56
+ <JobProgress
57
+ progress={{
58
+ state: "Running",
59
+ matchedCount: 24,
60
+ appliedCount: 0,
61
+ failedCount: 0,
62
+ errorMessage: "",
63
+ }}
64
+ isDone={false}
65
+ runningLabel="Organizing mail from these senders…"
66
+ onClose={() => undefined}
67
+ />
68
+ </SheetStage>
69
+ ),
70
+ };
71
+
72
+ /** The back-apply finished — the count moved is stated plainly. */
73
+ export const BackApplyDone: Story = {
74
+ name: "Back-apply done",
75
+ render: () => (
76
+ <SheetStage>
77
+ <JobProgress
78
+ progress={{
79
+ state: "Complete",
80
+ matchedCount: 24,
81
+ appliedCount: 24,
82
+ failedCount: 0,
83
+ errorMessage: "",
84
+ }}
85
+ isDone
86
+ runningLabel="Organizing mail from these senders…"
87
+ onClose={() => undefined}
88
+ />
89
+ </SheetStage>
90
+ ),
91
+ };
92
+
93
+ /** The back-apply failed — the rule is still saved, the move is what did not run. */
94
+ export const BackApplyFailed: Story = {
95
+ name: "Back-apply failed",
96
+ render: () => (
97
+ <SheetStage>
98
+ <JobProgress
99
+ progress={{
100
+ state: "Failed",
101
+ matchedCount: 24,
102
+ appliedCount: 6,
103
+ failedCount: 18,
104
+ errorMessage: "Could not reach the mail server. Please try again.",
105
+ }}
106
+ isDone
107
+ runningLabel="Organizing mail from these senders…"
108
+ onClose={() => undefined}
109
+ />
110
+ </SheetStage>
111
+ ),
112
+ };
113
+
114
+ /**
115
+ * The filter saved, but the back-apply's own request never got off the ground
116
+ * (a 5xx or dropped connection on start). The rule is live for new mail; moving
117
+ * the existing mail is what failed, and it is offered again — never swallowed
118
+ * behind a plain "saved".
119
+ */
120
+ export const BackApplyStartFailed: Story = {
121
+ name: "Back-apply start failed",
122
+ render: () => (
123
+ <SheetStage>
124
+ <BackApplyError onRetry={() => undefined} onClose={() => undefined} />
125
+ </SheetStage>
126
+ ),
127
+ };
128
+
129
+ /** The filter saved with nothing to back-apply — the plain confirmation. */
130
+ export const FilterSavedState: Story = {
131
+ name: "Filter saved",
132
+ render: () => (
133
+ <SheetStage>
134
+ <FilterSaved onClose={() => undefined} />
135
+ </SheetStage>
136
+ ),
137
+ };
138
+
139
+ /** The create failed — retry or dismiss. */
140
+ export const CommitFailed: Story = {
141
+ name: "Commit failed",
142
+ render: () => (
143
+ <SheetStage>
144
+ <CommitError onRetry={() => undefined} onClose={() => undefined} />
145
+ </SheetStage>
146
+ ),
147
+ };
@@ -88,6 +88,33 @@ export function FilterSaved({ onClose }: { onClose: () => void }) {
88
88
  );
89
89
  }
90
90
 
91
+ export function BackApplyError({
92
+ onRetry,
93
+ onClose,
94
+ }: {
95
+ onRetry: () => void;
96
+ onClose: () => void;
97
+ }) {
98
+ return (
99
+ <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
100
+ <CheckCircle2 className="size-8 text-positive" />
101
+ <p className="text-sm font-medium text-fg">Rule saved</p>
102
+ <p className="max-w-xs text-xs text-fg-muted">
103
+ New mail follows it automatically. Moving the mail already in your
104
+ mailbox didn't run — try again?
105
+ </p>
106
+ <div className="mt-2 flex gap-2">
107
+ <Button variant="primary" onClick={onRetry}>
108
+ Move existing mail
109
+ </Button>
110
+ <Button variant="ghost" onClick={onClose}>
111
+ Not now
112
+ </Button>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
91
118
  export function CommitError({
92
119
  onRetry,
93
120
  onClose,
@@ -60,6 +60,17 @@ export interface OrganizeDraft {
60
60
  export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
61
61
  draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION;
62
62
 
63
+ /**
64
+ * Whether the draft's predicate can be back-applied over the existing corpus. A
65
+ * `HasWords` clause matches the full message body, which only the live
66
+ * index-time filter and the vector widen evaluate — the vector-free back-apply
67
+ * projection carries no body and rejects it (`assertNoBodyContentClause`,
68
+ * organize.ts). A rule that carries one is still a valid standing filter for
69
+ * incoming mail, so the back-apply is skipped, not run into that guard.
70
+ */
71
+ export const canBackApplyDraft = (draft: OrganizeDraft): boolean =>
72
+ !draft.literalClauses.some((clause) => clause.field === "HasWords");
73
+
63
74
  /**
64
75
  * Build the read-only preview / back-apply matcher input. The action fields do
65
76
  * not affect which messages match — the preview returns exactly the set a job