@remit/web-client 0.0.73 → 0.0.75
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/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/components/settings/FilterEditor.render.test.ts +60 -13
- package/src/components/settings/FilterEditor.tsx +33 -12
- package/src/lib/organize/filter-edit-model.test.ts +86 -0
- package/src/lib/organize/filter-edit-model.ts +32 -4
- 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.75",
|
|
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": {
|
|
@@ -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,12 +60,17 @@ const filterFixture = (
|
|
|
60
60
|
|
|
61
61
|
type Responder = (call: HttpCall) => unknown;
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Fields whose presence in a patch bumps `ruleChangedAt` (RFC 034 Decision
|
|
65
|
+
* 3.2, reader #266).
|
|
66
|
+
*/
|
|
67
|
+
const RULE_ASSERTION_FIELDS = [
|
|
65
68
|
"matchOperator",
|
|
66
69
|
"literalClauses",
|
|
67
70
|
"actionLabelId",
|
|
68
71
|
"actionMailboxId",
|
|
72
|
+
"scope",
|
|
73
|
+
"expiresAt",
|
|
69
74
|
];
|
|
70
75
|
|
|
71
76
|
/**
|
|
@@ -84,7 +89,7 @@ const backend = (
|
|
|
84
89
|
}
|
|
85
90
|
if (call.path.endsWith("/filters/f-1") && call.method === "PATCH") {
|
|
86
91
|
const body = call.body ?? {};
|
|
87
|
-
const bumped =
|
|
92
|
+
const bumped = RULE_ASSERTION_FIELDS.some((field) => field in body);
|
|
88
93
|
return {
|
|
89
94
|
...filter,
|
|
90
95
|
...body,
|
|
@@ -254,12 +259,12 @@ describe("FilterEditor — degraded semantic filter (RFC 038 D4)", () => {
|
|
|
254
259
|
dom.query('[aria-label="Remove the similar-mail widen"]'),
|
|
255
260
|
null,
|
|
256
261
|
);
|
|
257
|
-
assert.match(dom.text(), /
|
|
262
|
+
assert.match(dom.text(), /similar-mail match is fixed to the message/i);
|
|
258
263
|
});
|
|
259
264
|
});
|
|
260
265
|
|
|
261
|
-
describe("FilterEditor — scope and expiry are
|
|
262
|
-
it("
|
|
266
|
+
describe("FilterEditor — scope and expiry are editable (reader #266)", () => {
|
|
267
|
+
it("keeps the scope toggle and date input live, minus the once option", async () => {
|
|
263
268
|
const dom = mount(
|
|
264
269
|
filterFixture({
|
|
265
270
|
scope: "Temporary",
|
|
@@ -268,14 +273,56 @@ describe("FilterEditor — scope and expiry are read-only (reader #266)", () =>
|
|
|
268
273
|
);
|
|
269
274
|
await settlePreview(dom);
|
|
270
275
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
assert.
|
|
274
|
-
assert.equal(dom.query('[aria-label="Expiry date"]'), null);
|
|
275
|
-
assert.match(dom.text(), /Until 2027-09-01/);
|
|
276
|
-
assert.match(dom.text(), /set when a filter is created/i);
|
|
276
|
+
assert.ok(dom.query('input[name="rule-scope"]'));
|
|
277
|
+
assert.ok(dom.byLabel("Expiry date"));
|
|
278
|
+
assert.doesNotMatch(dom.text(), /Just once/);
|
|
277
279
|
|
|
278
|
-
// The name stays editable.
|
|
280
|
+
// The name stays editable too.
|
|
279
281
|
assert.ok(dom.byLabel("Rule name"));
|
|
280
282
|
});
|
|
283
|
+
|
|
284
|
+
it("moves a standing filter to until-a-date, patches scope and expiresAt, and offers the re-apply", async () => {
|
|
285
|
+
const dom = mount(filterFixture());
|
|
286
|
+
await settlePreview(dom);
|
|
287
|
+
|
|
288
|
+
dom.click(dom.byText("label", "Until a date"));
|
|
289
|
+
dom.type(dom.byLabel("Expiry date"), "2027-09-01");
|
|
290
|
+
await dom.flush();
|
|
291
|
+
|
|
292
|
+
dom.click(primaryButton(dom, "Save until then"));
|
|
293
|
+
await dom.flush();
|
|
294
|
+
|
|
295
|
+
const patch = patchCalls();
|
|
296
|
+
assert.equal(patch.length, 1);
|
|
297
|
+
assert.equal(patch[0].body?.scope, "Temporary");
|
|
298
|
+
assert.match(String(patch[0].body?.expiresAt ?? ""), /^2027-09-01T/);
|
|
299
|
+
// The predicate/action and the name are untouched, so neither travels.
|
|
300
|
+
assert.equal("matchOperator" in (patch[0].body ?? {}), false);
|
|
301
|
+
assert.equal("name" in (patch[0].body ?? {}), false);
|
|
302
|
+
|
|
303
|
+
// A scope/expiry change is a rule reassertion too — it offers the
|
|
304
|
+
// re-apply exactly like a predicate/action change does.
|
|
305
|
+
assert.match(dom.text(), /Move existing mail/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("moves an until-a-date filter back to standing and clears the expiry", async () => {
|
|
309
|
+
const dom = mount(
|
|
310
|
+
filterFixture({
|
|
311
|
+
scope: "Temporary",
|
|
312
|
+
expiresAt: "2027-09-01T23:59:59+00:00",
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
await settlePreview(dom);
|
|
316
|
+
|
|
317
|
+
dom.click(dom.byText("label", "Keep doing this"));
|
|
318
|
+
await dom.flush();
|
|
319
|
+
|
|
320
|
+
dom.click(primaryButton(dom, "Save rule"));
|
|
321
|
+
await dom.flush();
|
|
322
|
+
|
|
323
|
+
const patch = patchCalls();
|
|
324
|
+
assert.equal(patch.length, 1);
|
|
325
|
+
assert.equal(patch[0].body?.scope, "Standing");
|
|
326
|
+
assert.equal("expiresAt" in (patch[0].body ?? {}), false);
|
|
327
|
+
});
|
|
281
328
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type FolderOption,
|
|
9
9
|
type MatchOperator,
|
|
10
10
|
previewCountSummary,
|
|
11
|
+
type RuleScope,
|
|
11
12
|
} from "@remit/ui";
|
|
12
13
|
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
13
14
|
import { useMemo, useRef, useState } from "react";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
buildUpdateFilterInput,
|
|
20
21
|
filterToRule,
|
|
21
22
|
ruleChangesPredicateOrAction,
|
|
23
|
+
ruleChangesScopeOrExpiry,
|
|
22
24
|
} from "@/lib/organize/filter-edit-model";
|
|
23
25
|
import {
|
|
24
26
|
normalizeClauseValue,
|
|
@@ -43,11 +45,14 @@ interface FilterEditorProps {
|
|
|
43
45
|
/**
|
|
44
46
|
* Editing a standing filter in the same chip editor the Organize surface uses
|
|
45
47
|
* (RFC 038 D6). The row's persisted rule opens in the editor — clauses, match
|
|
46
|
-
* operator, move action, scope, and the semantic anchor as a widen
|
|
47
|
-
* a predicate or
|
|
48
|
-
* re-back-apply over existing mail;
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* operator, move action, scope, expiry, and the semantic anchor as a widen
|
|
49
|
+
* chip. Saving a predicate, action, scope, or expiry change bumps
|
|
50
|
+
* `ruleChangedAt` and offers, never runs, a re-back-apply over existing mail;
|
|
51
|
+
* a cosmetic rename does neither (RFC 034 Decision 3.2, reader #266). The
|
|
52
|
+
* re-apply carries exactly the previewed predicate and is held behind the
|
|
53
|
+
* same settled-count commit gate as creation. The anchor stays fixed at
|
|
54
|
+
* creation regardless — repointing it would silently change what the filter
|
|
55
|
+
* matches, which deserves a new filter instead.
|
|
51
56
|
*/
|
|
52
57
|
export function FilterEditor({
|
|
53
58
|
accountId,
|
|
@@ -142,14 +147,26 @@ export function FilterEditor({
|
|
|
142
147
|
const changeName = (name: string) =>
|
|
143
148
|
setRule((current) => ({ ...current, name }));
|
|
144
149
|
|
|
150
|
+
const changeScope = (scope: RuleScope) =>
|
|
151
|
+
setRule((current) => ({
|
|
152
|
+
...current,
|
|
153
|
+
scope,
|
|
154
|
+
until: scope === "until" ? current.until : undefined,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const changeUntil = (until: string) =>
|
|
158
|
+
setRule((current) => ({ ...current, until }));
|
|
159
|
+
|
|
145
160
|
const commit = () => {
|
|
146
|
-
const
|
|
161
|
+
const rulesChanged =
|
|
162
|
+
ruleChangesPredicateOrAction(rule, original) ||
|
|
163
|
+
ruleChangesScopeOrExpiry(rule, original);
|
|
147
164
|
const body = buildUpdateFilterInput(rule, original);
|
|
148
165
|
if (Object.keys(body).length === 0) {
|
|
149
166
|
onClose();
|
|
150
167
|
return;
|
|
151
168
|
}
|
|
152
|
-
setOfferReapply(
|
|
169
|
+
setOfferReapply(rulesChanged);
|
|
153
170
|
update.updateFilter(body);
|
|
154
171
|
};
|
|
155
172
|
|
|
@@ -188,13 +205,15 @@ export function FilterEditor({
|
|
|
188
205
|
rule={rule}
|
|
189
206
|
folders={folders}
|
|
190
207
|
preview={preview}
|
|
191
|
-
// The update endpoint carries no anchor
|
|
192
|
-
// nor removed here: the "…and similar" add is
|
|
193
|
-
// existing chip is display-only (no
|
|
194
|
-
//
|
|
208
|
+
// The update endpoint carries no anchor field at all (reader #266), so a
|
|
209
|
+
// widen can be neither added nor removed here: the "…and similar" add is
|
|
210
|
+
// never offered, and the existing chip is display-only (no
|
|
211
|
+
// onRemoveWiden — anchorLocked enforces that regardless).
|
|
212
|
+
// `semanticUnavailable` only drives the chip's inactive styling via
|
|
213
|
+
// `filterToRule`.
|
|
195
214
|
semanticAvailable={false}
|
|
196
215
|
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
197
|
-
|
|
216
|
+
anchorLocked
|
|
198
217
|
clauseEdit={clauseEdit}
|
|
199
218
|
onStartAddClause={startAddClause}
|
|
200
219
|
onStartEditClause={startEditClause}
|
|
@@ -207,6 +226,8 @@ export function FilterEditor({
|
|
|
207
226
|
onChangeMove={changeMove}
|
|
208
227
|
onCreateFolder={createFolder}
|
|
209
228
|
onChangeName={changeName}
|
|
229
|
+
onChangeScope={changeScope}
|
|
230
|
+
onChangeUntil={changeUntil}
|
|
210
231
|
onCommit={commit}
|
|
211
232
|
onCancel={onClose}
|
|
212
233
|
/>
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
expiresAtToPickedDate,
|
|
8
8
|
filterToRule,
|
|
9
9
|
ruleChangesPredicateOrAction,
|
|
10
|
+
ruleChangesScopeOrExpiry,
|
|
10
11
|
} from "./filter-edit-model";
|
|
11
12
|
|
|
12
13
|
const filter = (
|
|
@@ -132,6 +133,48 @@ describe("ruleChangesPredicateOrAction", () => {
|
|
|
132
133
|
});
|
|
133
134
|
});
|
|
134
135
|
|
|
136
|
+
describe("ruleChangesScopeOrExpiry (reader #266)", () => {
|
|
137
|
+
const base = filterToRule(filter());
|
|
138
|
+
|
|
139
|
+
it("is false for an identical rule and for a rename only", () => {
|
|
140
|
+
assert.equal(ruleChangesScopeOrExpiry(base, base), false);
|
|
141
|
+
assert.equal(
|
|
142
|
+
ruleChangesScopeOrExpiry({ ...base, name: "New name" }, base),
|
|
143
|
+
false,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("is true when scope moves to until-a-date", () => {
|
|
148
|
+
assert.equal(
|
|
149
|
+
ruleChangesScopeOrExpiry(
|
|
150
|
+
{ ...base, scope: "until", until: "2027-01-01" },
|
|
151
|
+
base,
|
|
152
|
+
),
|
|
153
|
+
true,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("is true when only the until date changes", () => {
|
|
158
|
+
const untilBase = filterToRule(
|
|
159
|
+
filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
|
|
160
|
+
);
|
|
161
|
+
assert.equal(
|
|
162
|
+
ruleChangesScopeOrExpiry(
|
|
163
|
+
{ ...untilBase, until: "2027-06-01" },
|
|
164
|
+
untilBase,
|
|
165
|
+
),
|
|
166
|
+
true,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("is false when moving between standing and once-equivalent scopes with no until set", () => {
|
|
171
|
+
assert.equal(
|
|
172
|
+
ruleChangesScopeOrExpiry({ ...base, scope: "standing" }, base),
|
|
173
|
+
false,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
135
178
|
describe("buildUpdateFilterInput", () => {
|
|
136
179
|
const original = filterToRule(filter());
|
|
137
180
|
|
|
@@ -172,4 +215,47 @@ describe("buildUpdateFilterInput", () => {
|
|
|
172
215
|
it("is empty when nothing changed", () => {
|
|
173
216
|
assert.deepEqual(buildUpdateFilterInput(original, original), {});
|
|
174
217
|
});
|
|
218
|
+
|
|
219
|
+
it("sends scope Temporary and a derived expiresAt when moving to until-a-date (reader #266)", () => {
|
|
220
|
+
const changed: FilterRule = {
|
|
221
|
+
...original,
|
|
222
|
+
scope: "until",
|
|
223
|
+
until: "2027-03-04",
|
|
224
|
+
};
|
|
225
|
+
const body = buildUpdateFilterInput(changed, original);
|
|
226
|
+
assert.equal(body.scope, "Temporary");
|
|
227
|
+
assert.match(body.expiresAt ?? "", /^2027-03-04T/);
|
|
228
|
+
assert.equal("matchOperator" in body, false);
|
|
229
|
+
assert.equal("name" in body, false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("sends scope Standing with no expiresAt when moving off a Temporary filter", () => {
|
|
233
|
+
const temporaryOriginal = filterToRule(
|
|
234
|
+
filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
|
|
235
|
+
);
|
|
236
|
+
const changed: FilterRule = { ...temporaryOriginal, scope: "standing" };
|
|
237
|
+
const body = buildUpdateFilterInput(changed, temporaryOriginal);
|
|
238
|
+
assert.equal(body.scope, "Standing");
|
|
239
|
+
assert.equal("expiresAt" in body, false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("sends scope and the new expiresAt when only the date changes", () => {
|
|
243
|
+
const temporaryOriginal = filterToRule(
|
|
244
|
+
filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
|
|
245
|
+
);
|
|
246
|
+
const changed: FilterRule = { ...temporaryOriginal, until: "2027-06-01" };
|
|
247
|
+
const body = buildUpdateFilterInput(changed, temporaryOriginal);
|
|
248
|
+
assert.equal(body.scope, "Temporary");
|
|
249
|
+
assert.match(body.expiresAt ?? "", /^2027-06-01T/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("carries no anchor field — the editor never has one to send (reader #266)", () => {
|
|
253
|
+
const changed: FilterRule = {
|
|
254
|
+
...original,
|
|
255
|
+
scope: "until",
|
|
256
|
+
until: "2027-03-04",
|
|
257
|
+
};
|
|
258
|
+
const body = buildUpdateFilterInput(changed, original);
|
|
259
|
+
assert.equal("anchorMessageId" in body, false);
|
|
260
|
+
});
|
|
175
261
|
});
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
RemitImapUpdateFilterInput,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
5
|
import type { FilterRule, RuleClause } from "@remit/ui";
|
|
6
|
+
import { pickedDateToExpiresAt } from "./filter-status";
|
|
6
7
|
import { NO_ACTION } from "./organize-model";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -79,13 +80,34 @@ export const ruleChangesPredicateOrAction = (
|
|
|
79
80
|
original: FilterRule,
|
|
80
81
|
): boolean => predicateActionKey(rule) !== predicateActionKey(original);
|
|
81
82
|
|
|
83
|
+
const scopeExpiryKey = (rule: FilterRule): string =>
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
scope: rule.scope === "until" ? "until" : "standing",
|
|
86
|
+
until: rule.scope === "until" ? (rule.until ?? "") : "",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether the edited rule changes scope (standing ↔ until-a-date) or the date
|
|
91
|
+
* itself, versus the one it was loaded from (reader #266). Scope and expiry
|
|
92
|
+
* are mutable on an existing filter, unlike the anchor, and a change here
|
|
93
|
+
* bumps `ruleChangedAt` the same way a predicate/action change does — moving a
|
|
94
|
+
* lapsed filter back to Standing (or extending its date) is the same kind of
|
|
95
|
+
* "the user just reasserted this rule" moment, and re-offers the back-apply so
|
|
96
|
+
* mail delivered while the filter sat inactive can be caught up.
|
|
97
|
+
*/
|
|
98
|
+
export const ruleChangesScopeOrExpiry = (
|
|
99
|
+
rule: FilterRule,
|
|
100
|
+
original: FilterRule,
|
|
101
|
+
): boolean => scopeExpiryKey(rule) !== scopeExpiryKey(original);
|
|
102
|
+
|
|
82
103
|
/**
|
|
83
104
|
* The PATCH body for an edited filter. A cosmetic rename sends `{ name }` only,
|
|
84
|
-
* so the server's `
|
|
105
|
+
* so the server's `changesRuleAssertion` guard leaves `ruleChangedAt`
|
|
85
106
|
* untouched (RFC 034 Decision 3.2). A predicate or action change sends the
|
|
86
|
-
* operator, clauses, and move target
|
|
87
|
-
*
|
|
88
|
-
*
|
|
107
|
+
* operator, clauses, and move target; a scope or expiry change sends `scope`
|
|
108
|
+
* and, for the `until` scope, `expiresAt` (reader #266) — either bumps
|
|
109
|
+
* `ruleChangedAt`. The label action and the anchor are never in the editor's
|
|
110
|
+
* gift, so they never enter the patch; the partial update preserves them.
|
|
89
111
|
*/
|
|
90
112
|
export const buildUpdateFilterInput = (
|
|
91
113
|
rule: FilterRule,
|
|
@@ -102,5 +124,11 @@ export const buildUpdateFilterInput = (
|
|
|
102
124
|
}));
|
|
103
125
|
body.actionMailboxId = rule.moveMailboxId ?? NO_ACTION;
|
|
104
126
|
}
|
|
127
|
+
if (ruleChangesScopeOrExpiry(rule, original)) {
|
|
128
|
+
body.scope = rule.scope === "until" ? "Temporary" : "Standing";
|
|
129
|
+
if (rule.scope === "until") {
|
|
130
|
+
body.expiresAt = pickedDateToExpiresAt(rule.until ?? "");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
105
133
|
return body;
|
|
106
134
|
};
|
|
@@ -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
|