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