@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 +1 -1
- package/src/components/mail/MessageRow.tsx +3 -3
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +143 -3
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +49 -2
- package/src/components/mail/organize/rule-editor-states.stories.tsx +147 -0
- package/src/components/mail/organize/rule-editor-states.tsx +27 -0
- package/src/lib/organize/organize-model.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|