@remit/web-client 0.0.65 → 0.0.66
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/hooks/useRulePreview.ts +86 -0
- package/src/lib/organize/rule-model.test.ts +297 -0
- package/src/lib/organize/rule-model.ts +205 -0
- 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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { PreviewCount } from "@remit/ui";
|
|
3
|
+
import { useMutation } from "@tanstack/react-query";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
6
|
+
import {
|
|
7
|
+
derivePreview,
|
|
8
|
+
PREVIEW_DEBOUNCE_MS,
|
|
9
|
+
type PreviewState,
|
|
10
|
+
predicateSignature,
|
|
11
|
+
} from "@/lib/organize/rule-model";
|
|
12
|
+
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The live match count for the edited rule (RFC 038 D1). Every predicate change
|
|
16
|
+
* schedules a debounced `POST /organize/preview` and, until it lands, reports
|
|
17
|
+
* the last count as stale so the count never blanks and the commit gate can
|
|
18
|
+
* hold apply. Seeded with the widen probe's count, so opening the editor shows a
|
|
19
|
+
* settled number without an immediate re-fetch.
|
|
20
|
+
*
|
|
21
|
+
* Out-of-order responses are dropped: only the reply to the most recent request
|
|
22
|
+
* updates the count, so a slow early preview never overwrites a newer one. The
|
|
23
|
+
* server bounds the match, so a broad predicate over a large mailbox stays a
|
|
24
|
+
* single cheap request; the debounce is what keeps rapid edits from spamming it.
|
|
25
|
+
*/
|
|
26
|
+
export const useRulePreview = (
|
|
27
|
+
accountId: string | undefined,
|
|
28
|
+
predicate: OrganizeMatchPredicate,
|
|
29
|
+
seedCount: number,
|
|
30
|
+
): PreviewCount => {
|
|
31
|
+
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
32
|
+
const { mutateAsync } = mutation;
|
|
33
|
+
const currentSignature = predicateSignature(predicate);
|
|
34
|
+
|
|
35
|
+
const [state, setState] = useState<PreviewState>(() => ({
|
|
36
|
+
count: seedCount,
|
|
37
|
+
previewedSignature: currentSignature,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const predicateRef = useRef(predicate);
|
|
41
|
+
predicateRef.current = predicate;
|
|
42
|
+
const latestRequested = useRef(currentSignature);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!accountId) return;
|
|
46
|
+
if (currentSignature === state.previewedSignature) return;
|
|
47
|
+
if (currentSignature === state.errorSignature) return;
|
|
48
|
+
|
|
49
|
+
const handle = setTimeout(() => {
|
|
50
|
+
const signature = currentSignature;
|
|
51
|
+
latestRequested.current = signature;
|
|
52
|
+
mutateAsync({
|
|
53
|
+
path: { accountId },
|
|
54
|
+
body: buildOrganizeInput({ ...predicateRef.current }),
|
|
55
|
+
})
|
|
56
|
+
.then((response) => {
|
|
57
|
+
if (latestRequested.current !== signature) return;
|
|
58
|
+
setState({
|
|
59
|
+
count: response.matchedCount,
|
|
60
|
+
previewedSignature: signature,
|
|
61
|
+
});
|
|
62
|
+
})
|
|
63
|
+
.catch((error: unknown) => {
|
|
64
|
+
if (latestRequested.current !== signature) return;
|
|
65
|
+
setState((previous) => ({
|
|
66
|
+
...previous,
|
|
67
|
+
error:
|
|
68
|
+
error instanceof Error
|
|
69
|
+
? error.message
|
|
70
|
+
: "Couldn't count matches.",
|
|
71
|
+
errorSignature: signature,
|
|
72
|
+
}));
|
|
73
|
+
});
|
|
74
|
+
}, PREVIEW_DEBOUNCE_MS);
|
|
75
|
+
|
|
76
|
+
return () => clearTimeout(handle);
|
|
77
|
+
}, [
|
|
78
|
+
accountId,
|
|
79
|
+
currentSignature,
|
|
80
|
+
state.previewedSignature,
|
|
81
|
+
state.errorSignature,
|
|
82
|
+
mutateAsync,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
return derivePreview(state, currentSignature);
|
|
86
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule ↔ predicate wiring behind the Organize chip editor (RFC 038 D1). The
|
|
3
|
+
* load-bearing contract lives here: the predicate the editor previews is exactly
|
|
4
|
+
* the predicate a commit applies, so the count on screen is the set that moves.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import type { FilterRule } from "@remit/ui";
|
|
10
|
+
import { buildOrganizeInput } from "./organize-model";
|
|
11
|
+
import {
|
|
12
|
+
buildInitialRule,
|
|
13
|
+
derivePreview,
|
|
14
|
+
normalizeClauseValue,
|
|
15
|
+
normalizeListId,
|
|
16
|
+
predicateSignature,
|
|
17
|
+
rulePredicate,
|
|
18
|
+
ruleToDraft,
|
|
19
|
+
SUPPORTED_CLAUSE_FIELDS,
|
|
20
|
+
} from "./rule-model";
|
|
21
|
+
|
|
22
|
+
describe("SUPPORTED_CLAUSE_FIELDS", () => {
|
|
23
|
+
it("offers every field the backend matcher now evaluates, ListId and FromDomain included (#262)", () => {
|
|
24
|
+
assert.deepEqual(SUPPORTED_CLAUSE_FIELDS, [
|
|
25
|
+
"From",
|
|
26
|
+
"Subject",
|
|
27
|
+
"HasWords",
|
|
28
|
+
"ListId",
|
|
29
|
+
"FromDomain",
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("normalizeListId", () => {
|
|
35
|
+
it("strips the bracketed identifier, trims, and case-folds — matching the backend", () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
normalizeListId("<Weekly.News.Example.com>"),
|
|
38
|
+
"weekly.news.example.com",
|
|
39
|
+
);
|
|
40
|
+
assert.equal(normalizeListId(" Weekly.News "), "weekly.news");
|
|
41
|
+
assert.equal(normalizeListId(""), "");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("only normalizes the ListId field, trimming the rest", () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
normalizeClauseValue("ListId", "<List.Example.COM>"),
|
|
47
|
+
"list.example.com",
|
|
48
|
+
);
|
|
49
|
+
assert.equal(normalizeClauseValue("From", " a@x.com "), "a@x.com");
|
|
50
|
+
assert.equal(
|
|
51
|
+
normalizeClauseValue("FromDomain", " github.com "),
|
|
52
|
+
"github.com",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("buildInitialRule", () => {
|
|
58
|
+
it("opens a semantic-capable rule on the widen anchor with no literal clauses", () => {
|
|
59
|
+
const rule = buildInitialRule({
|
|
60
|
+
anchorMessageId: "msg-1",
|
|
61
|
+
semanticUnavailable: false,
|
|
62
|
+
senders: [],
|
|
63
|
+
selectionCount: 3,
|
|
64
|
+
});
|
|
65
|
+
assert.equal(rule.clauses.length, 0);
|
|
66
|
+
assert.deepEqual(rule.widen, { anchorCount: 3 });
|
|
67
|
+
assert.equal(rule.matchOperator, "all");
|
|
68
|
+
assert.equal(rule.scope, "once");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("opens the sender fallback on visible, editable derived From chips (#251)", () => {
|
|
72
|
+
const rule = buildInitialRule({
|
|
73
|
+
anchorMessageId: "msg-1",
|
|
74
|
+
semanticUnavailable: true,
|
|
75
|
+
senders: ["a@x.com", "b@y.com"],
|
|
76
|
+
selectionCount: 2,
|
|
77
|
+
});
|
|
78
|
+
assert.equal(rule.widen, undefined);
|
|
79
|
+
assert.equal(rule.matchOperator, "any");
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
rule.clauses.map((clause) => ({
|
|
82
|
+
field: clause.field,
|
|
83
|
+
value: clause.value,
|
|
84
|
+
derived: clause.derived,
|
|
85
|
+
})),
|
|
86
|
+
[
|
|
87
|
+
{ field: "From", value: "a@x.com", derived: true },
|
|
88
|
+
{ field: "From", value: "b@y.com", derived: true },
|
|
89
|
+
],
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("collapses senders sharing a registrable domain to one derived FromDomain chip (#262)", () => {
|
|
94
|
+
const rule = buildInitialRule({
|
|
95
|
+
semanticUnavailable: true,
|
|
96
|
+
senders: ["npm@github.com", "ci@sub.github.com"],
|
|
97
|
+
selectionCount: 2,
|
|
98
|
+
});
|
|
99
|
+
assert.equal(rule.matchOperator, "any");
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
rule.clauses.map((clause) => ({
|
|
102
|
+
field: clause.field,
|
|
103
|
+
value: clause.value,
|
|
104
|
+
derived: clause.derived,
|
|
105
|
+
})),
|
|
106
|
+
[{ field: "FromDomain", value: "github.com", derived: true }],
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("carries a Something-else seed's folder and scope", () => {
|
|
111
|
+
const rule = buildInitialRule({
|
|
112
|
+
anchorMessageId: "msg-1",
|
|
113
|
+
semanticUnavailable: false,
|
|
114
|
+
senders: [],
|
|
115
|
+
selectionCount: 1,
|
|
116
|
+
seedMailboxId: "mbx-archive",
|
|
117
|
+
seedScope: "standing",
|
|
118
|
+
});
|
|
119
|
+
assert.equal(rule.moveMailboxId, "mbx-archive");
|
|
120
|
+
assert.equal(rule.scope, "standing");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("offers no widen when there is no anchor to ride on", () => {
|
|
124
|
+
const rule = buildInitialRule({
|
|
125
|
+
semanticUnavailable: false,
|
|
126
|
+
senders: [],
|
|
127
|
+
selectionCount: 0,
|
|
128
|
+
});
|
|
129
|
+
assert.equal(rule.widen, undefined);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const semanticRule: FilterRule = {
|
|
134
|
+
clauses: [{ id: "c1", field: "Subject", value: "receipt" }],
|
|
135
|
+
matchOperator: "all",
|
|
136
|
+
widen: { anchorCount: 2 },
|
|
137
|
+
moveMailboxId: "mbx-archive",
|
|
138
|
+
scope: "once",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
describe("rulePredicate", () => {
|
|
142
|
+
it("includes the anchor while the widen is present and active", () => {
|
|
143
|
+
const predicate = rulePredicate(semanticRule, "msg-1");
|
|
144
|
+
assert.equal(predicate.anchorMessageId, "msg-1");
|
|
145
|
+
assert.equal(predicate.matchOperator, "And");
|
|
146
|
+
assert.deepEqual(predicate.literalClauses, [
|
|
147
|
+
{ field: "Subject", value: "receipt" },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("drops the anchor once the widen is removed", () => {
|
|
152
|
+
const predicate = rulePredicate(
|
|
153
|
+
{ ...semanticRule, widen: undefined },
|
|
154
|
+
"msg-1",
|
|
155
|
+
);
|
|
156
|
+
assert.equal(predicate.anchorMessageId, undefined);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("drops the anchor for a widen the deployment cannot evaluate (D4 inactive)", () => {
|
|
160
|
+
const predicate = rulePredicate(
|
|
161
|
+
{ ...semanticRule, widen: { anchorCount: 2, inactive: true } },
|
|
162
|
+
"msg-1",
|
|
163
|
+
);
|
|
164
|
+
assert.equal(predicate.anchorMessageId, undefined);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("maps the any operator to Or", () => {
|
|
168
|
+
const predicate = rulePredicate(
|
|
169
|
+
{ ...semanticRule, matchOperator: "any" },
|
|
170
|
+
"msg-1",
|
|
171
|
+
);
|
|
172
|
+
assert.equal(predicate.matchOperator, "Or");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("predicateSignature", () => {
|
|
177
|
+
it("is stable across identical predicates", () => {
|
|
178
|
+
assert.equal(
|
|
179
|
+
predicateSignature(rulePredicate(semanticRule, "msg-1")),
|
|
180
|
+
predicateSignature(rulePredicate(semanticRule, "msg-1")),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("changes when a clause value changes", () => {
|
|
185
|
+
const changed: FilterRule = {
|
|
186
|
+
...semanticRule,
|
|
187
|
+
clauses: [{ id: "c1", field: "Subject", value: "invoice" }],
|
|
188
|
+
};
|
|
189
|
+
assert.notEqual(
|
|
190
|
+
predicateSignature(rulePredicate(semanticRule, "msg-1")),
|
|
191
|
+
predicateSignature(rulePredicate(changed, "msg-1")),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("changes when the widen is removed", () => {
|
|
196
|
+
assert.notEqual(
|
|
197
|
+
predicateSignature(rulePredicate(semanticRule, "msg-1")),
|
|
198
|
+
predicateSignature(
|
|
199
|
+
rulePredicate({ ...semanticRule, widen: undefined }, "msg-1"),
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("the previewed set equals the applied set", () => {
|
|
206
|
+
it("carries exactly the previewed predicate into the commit draft", () => {
|
|
207
|
+
const preview = buildOrganizeInput({
|
|
208
|
+
...rulePredicate(semanticRule, "msg-1"),
|
|
209
|
+
matchOperator: rulePredicate(semanticRule, "msg-1").matchOperator,
|
|
210
|
+
literalClauses: rulePredicate(semanticRule, "msg-1").literalClauses,
|
|
211
|
+
});
|
|
212
|
+
const apply = buildOrganizeInput(ruleToDraft(semanticRule, "msg-1"));
|
|
213
|
+
assert.equal(apply.anchorMessageId, preview.anchorMessageId);
|
|
214
|
+
assert.equal(apply.matchOperator, preview.matchOperator);
|
|
215
|
+
assert.deepEqual(apply.literalClauses, preview.literalClauses);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("keeps them equal for the sender fallback (no anchor, Or)", () => {
|
|
219
|
+
const rule = buildInitialRule({
|
|
220
|
+
semanticUnavailable: true,
|
|
221
|
+
senders: ["a@x.com", "b@y.com"],
|
|
222
|
+
selectionCount: 2,
|
|
223
|
+
});
|
|
224
|
+
const predicate = rulePredicate(rule);
|
|
225
|
+
const apply = buildOrganizeInput(ruleToDraft(rule));
|
|
226
|
+
assert.equal(apply.anchorMessageId, undefined);
|
|
227
|
+
assert.equal(apply.matchOperator, "Or");
|
|
228
|
+
assert.deepEqual(apply.literalClauses, predicate.literalClauses);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("adds the expiry only for the until scope, never touching the match set", () => {
|
|
232
|
+
const once = ruleToDraft(semanticRule, "msg-1");
|
|
233
|
+
assert.equal(once.expiresAt, undefined);
|
|
234
|
+
|
|
235
|
+
const until = ruleToDraft(
|
|
236
|
+
{ ...semanticRule, scope: "until", until: "2999-01-02" },
|
|
237
|
+
"msg-1",
|
|
238
|
+
);
|
|
239
|
+
assert.ok(until.expiresAt?.startsWith("2999-01-02"));
|
|
240
|
+
assert.deepEqual(until.literalClauses, once.literalClauses);
|
|
241
|
+
assert.equal(until.anchorMessageId, once.anchorMessageId);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("carries the move destination into the draft", () => {
|
|
245
|
+
assert.equal(
|
|
246
|
+
ruleToDraft(semanticRule, "msg-1").moveMailboxId,
|
|
247
|
+
"mbx-archive",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("derivePreview", () => {
|
|
253
|
+
const signature = "sig-current";
|
|
254
|
+
|
|
255
|
+
it("is loading before the first count lands", () => {
|
|
256
|
+
assert.deepEqual(derivePreview({}, signature), { status: "loading" });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("is ready when the count was counted for the current predicate", () => {
|
|
260
|
+
assert.deepEqual(
|
|
261
|
+
derivePreview({ count: 42, previewedSignature: signature }, signature),
|
|
262
|
+
{ status: "ready", count: 42 },
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("goes stale — never blank — when the predicate has moved past the count", () => {
|
|
267
|
+
assert.deepEqual(
|
|
268
|
+
derivePreview({ count: 42, previewedSignature: "sig-old" }, signature),
|
|
269
|
+
{ status: "ready", count: 42, stale: true },
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("surfaces an error raised for the current predicate", () => {
|
|
274
|
+
assert.deepEqual(
|
|
275
|
+
derivePreview(
|
|
276
|
+
{ count: 1, error: "boom", errorSignature: signature },
|
|
277
|
+
signature,
|
|
278
|
+
),
|
|
279
|
+
{ status: "error", reason: "boom" },
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("ignores an error raised for a predicate already moved past", () => {
|
|
284
|
+
assert.deepEqual(
|
|
285
|
+
derivePreview(
|
|
286
|
+
{
|
|
287
|
+
count: 5,
|
|
288
|
+
previewedSignature: signature,
|
|
289
|
+
error: "boom",
|
|
290
|
+
errorSignature: "sig-old",
|
|
291
|
+
},
|
|
292
|
+
signature,
|
|
293
|
+
),
|
|
294
|
+
{ status: "ready", count: 5 },
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClauseField,
|
|
3
|
+
FilterRule,
|
|
4
|
+
MatchOperator,
|
|
5
|
+
PreviewCount,
|
|
6
|
+
RuleClause,
|
|
7
|
+
RuleScope,
|
|
8
|
+
} from "@remit/ui";
|
|
9
|
+
import { pickedDateToExpiresAt } from "./filter-status";
|
|
10
|
+
import type { OrganizeDraft } from "./organize-model";
|
|
11
|
+
import {
|
|
12
|
+
deriveSenderClauses,
|
|
13
|
+
type OrganizeMatchPredicate,
|
|
14
|
+
} from "./sender-fallback";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The clause fields the Organize editor may offer (RFC 038 D2). Gated on what
|
|
18
|
+
* the backend matcher evaluates, not on what the generated enum carries: every
|
|
19
|
+
* one of these is honored by the literal matcher and the back-apply corpus
|
|
20
|
+
* projection (match.ts), so a chip never previews a set the apply can't
|
|
21
|
+
* reproduce. `ListId` and `FromDomain` joined once their matcher shipped (#262).
|
|
22
|
+
*/
|
|
23
|
+
export const SUPPORTED_CLAUSE_FIELDS: ClauseField[] = [
|
|
24
|
+
"From",
|
|
25
|
+
"Subject",
|
|
26
|
+
"HasWords",
|
|
27
|
+
"ListId",
|
|
28
|
+
"FromDomain",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Canonical form of a `ListId` clause value, matching the backend's
|
|
33
|
+
* `normalizeListId` (list-id.ts): the bracketed identifier when present,
|
|
34
|
+
* otherwise the whole value, trimmed and case-folded. Normalizing at input keeps
|
|
35
|
+
* the chip the user sees identical to the value the matcher compares, so
|
|
36
|
+
* `<weekly.news.example.com>` and `Weekly.News.Example.com` are one list.
|
|
37
|
+
*/
|
|
38
|
+
export const normalizeListId = (value: string): string => {
|
|
39
|
+
const trimmed = value.trim();
|
|
40
|
+
const bracketed = trimmed.match(/<([^>]+)>/);
|
|
41
|
+
return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Canonicalize a clause value for its field before it becomes a chip. */
|
|
45
|
+
export const normalizeClauseValue = (
|
|
46
|
+
field: ClauseField,
|
|
47
|
+
value: string,
|
|
48
|
+
): string => (field === "ListId" ? normalizeListId(value) : value.trim());
|
|
49
|
+
|
|
50
|
+
const toApiOperator = (operator: MatchOperator): "And" | "Or" =>
|
|
51
|
+
operator === "all" ? "And" : "Or";
|
|
52
|
+
|
|
53
|
+
export interface InitialRuleInput {
|
|
54
|
+
/** The semantic anchor — the first selected message. */
|
|
55
|
+
anchorMessageId?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The deployment ships no vector pipeline, so the widen cannot run. The rule
|
|
58
|
+
* opens on the sender-derived literal clauses instead of an anchor.
|
|
59
|
+
*/
|
|
60
|
+
semanticUnavailable: boolean;
|
|
61
|
+
/** Distinct sender addresses of the selection, for the fallback clauses. */
|
|
62
|
+
senders: readonly string[];
|
|
63
|
+
/** How many messages the selection holds — the widen's anchor count. */
|
|
64
|
+
selectionCount: number;
|
|
65
|
+
/** A folder a "Something else" shortcut pre-picked. */
|
|
66
|
+
seedMailboxId?: string;
|
|
67
|
+
/** A scope a "Something else" shortcut pre-picked. */
|
|
68
|
+
seedScope?: RuleScope;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The rule the editor opens on, built from the widen probe. A semantic-capable
|
|
73
|
+
* deployment opens on the widen chip (the anchor, no literal clauses); one
|
|
74
|
+
* without the vector pipeline opens on the sender-fallback `From` chips (#251),
|
|
75
|
+
* visible and editable, matched with `Or`. Either way the move destination and
|
|
76
|
+
* scope come from any "Something else" seed.
|
|
77
|
+
*/
|
|
78
|
+
export const buildInitialRule = (input: InitialRuleInput): FilterRule => {
|
|
79
|
+
const scope = input.seedScope ?? "once";
|
|
80
|
+
if (input.semanticUnavailable) {
|
|
81
|
+
// The same derivation the widen fallback runs (#251, #262): one `From`
|
|
82
|
+
// clause per sender, or a single `FromDomain` clause when they share a
|
|
83
|
+
// registrable domain. Surfaced as ordinary editable chips.
|
|
84
|
+
const clauses: RuleClause[] = deriveSenderClauses(input.senders).map(
|
|
85
|
+
(clause, index) => ({
|
|
86
|
+
id: `derived-${index}`,
|
|
87
|
+
field: clause.field,
|
|
88
|
+
value: clause.value,
|
|
89
|
+
derived: true,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
return {
|
|
93
|
+
clauses,
|
|
94
|
+
matchOperator: "any",
|
|
95
|
+
moveMailboxId: input.seedMailboxId,
|
|
96
|
+
scope,
|
|
97
|
+
name: "",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
clauses: [],
|
|
102
|
+
matchOperator: "all",
|
|
103
|
+
widen: input.anchorMessageId
|
|
104
|
+
? { anchorCount: Math.max(input.selectionCount, 1) }
|
|
105
|
+
: undefined,
|
|
106
|
+
moveMailboxId: input.seedMailboxId,
|
|
107
|
+
scope,
|
|
108
|
+
name: "",
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The match-relevant projection of a rule — the anchor (only while the widen is
|
|
114
|
+
* present and active), the operator, and the literal clauses. Preview and apply
|
|
115
|
+
* both derive from this, so the set the editor counts is the set a commit acts
|
|
116
|
+
* on. Fields that do not change the match set (folder, scope, name, date) are
|
|
117
|
+
* deliberately absent.
|
|
118
|
+
*/
|
|
119
|
+
export const rulePredicate = (
|
|
120
|
+
rule: FilterRule,
|
|
121
|
+
anchorMessageId?: string,
|
|
122
|
+
): OrganizeMatchPredicate => {
|
|
123
|
+
const widenActive = rule.widen !== undefined && !rule.widen.inactive;
|
|
124
|
+
const literalClauses = rule.clauses.map((clause) => ({
|
|
125
|
+
field: clause.field,
|
|
126
|
+
value: clause.value,
|
|
127
|
+
}));
|
|
128
|
+
return {
|
|
129
|
+
...(widenActive && anchorMessageId ? { anchorMessageId } : {}),
|
|
130
|
+
matchOperator: toApiOperator(rule.matchOperator),
|
|
131
|
+
literalClauses,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A stable key for a predicate's match set. Two predicates with the same key
|
|
137
|
+
* match the same messages; a change to the key is what marks the live count
|
|
138
|
+
* stale and schedules the next preview.
|
|
139
|
+
*/
|
|
140
|
+
export const predicateSignature = (predicate: OrganizeMatchPredicate): string =>
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
anchor: predicate.anchorMessageId ?? null,
|
|
143
|
+
operator: predicate.matchOperator,
|
|
144
|
+
clauses: predicate.literalClauses.map(
|
|
145
|
+
(clause) => `${clause.field} ${clause.value}`,
|
|
146
|
+
),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The commit draft for a rule. The match fields are exactly
|
|
151
|
+
* {@link rulePredicate}'s — the previewed predicate — with the folder action
|
|
152
|
+
* and, for the `until` scope, the derived expiry added. The expiry never enters
|
|
153
|
+
* the match set, so preview and apply still count and act on the same messages.
|
|
154
|
+
*/
|
|
155
|
+
export const ruleToDraft = (
|
|
156
|
+
rule: FilterRule,
|
|
157
|
+
anchorMessageId?: string,
|
|
158
|
+
): OrganizeDraft => {
|
|
159
|
+
const predicate = rulePredicate(rule, anchorMessageId);
|
|
160
|
+
return {
|
|
161
|
+
...(predicate.anchorMessageId
|
|
162
|
+
? { anchorMessageId: predicate.anchorMessageId }
|
|
163
|
+
: {}),
|
|
164
|
+
matchOperator: predicate.matchOperator,
|
|
165
|
+
literalClauses: predicate.literalClauses,
|
|
166
|
+
moveMailboxId: rule.moveMailboxId,
|
|
167
|
+
expiresAt:
|
|
168
|
+
rule.scope === "until"
|
|
169
|
+
? pickedDateToExpiresAt(rule.until ?? "")
|
|
170
|
+
: undefined,
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export interface PreviewState {
|
|
175
|
+
/** The last count that came back, `undefined` before the first lands. */
|
|
176
|
+
count?: number;
|
|
177
|
+
/** The signature the {@link count} was counted for. */
|
|
178
|
+
previewedSignature?: string;
|
|
179
|
+
/** The signature the last error was raised for. */
|
|
180
|
+
errorSignature?: string;
|
|
181
|
+
error?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The live count for the rule on screen. The count is `ready` only when it was
|
|
186
|
+
* counted for the current predicate; a predicate change since then reads
|
|
187
|
+
* `stale` (recounting, never blank) so the commit gate can hold apply until it
|
|
188
|
+
* settles.
|
|
189
|
+
*/
|
|
190
|
+
export const derivePreview = (
|
|
191
|
+
state: PreviewState,
|
|
192
|
+
currentSignature: string,
|
|
193
|
+
): PreviewCount => {
|
|
194
|
+
if (state.error !== undefined && state.errorSignature === currentSignature) {
|
|
195
|
+
return { status: "error", reason: state.error };
|
|
196
|
+
}
|
|
197
|
+
if (state.count === undefined) return { status: "loading" };
|
|
198
|
+
if (state.previewedSignature === currentSignature) {
|
|
199
|
+
return { status: "ready", count: state.count };
|
|
200
|
+
}
|
|
201
|
+
return { status: "ready", count: state.count, stale: true };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** How long a rule change settles before the next preview fires. */
|
|
205
|
+
export const PREVIEW_DEBOUNCE_MS = 350;
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The back-apply progress copy. On a deployment without the vector pipeline the
|
|
3
|
-
* "all like these" job moves the sender-matched set, so the in-progress line must
|
|
4
|
-
* state the sender semantics — never "similar mail" (#250's honesty
|
|
5
|
-
* requirement). The semantic path keeps the "similar mail" wording.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import assert from "node:assert/strict";
|
|
9
|
-
import { afterEach, describe, it } from "node:test";
|
|
10
|
-
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
11
|
-
import { createElement } from "react";
|
|
12
|
-
import type { OrganizeMatchPredicate } from "../../../lib/organize/sender-fallback";
|
|
13
|
-
import { createDomHarness, type DomHarness } from "../../../test-support/dom";
|
|
14
|
-
import { makeMailbox } from "../../../test-support/fixtures";
|
|
15
|
-
import { type HttpMock, mockFetch } from "../../../test-support/http";
|
|
16
|
-
import { OrganizePanel } from "./OrganizePanel";
|
|
17
|
-
|
|
18
|
-
let harness: DomHarness | undefined;
|
|
19
|
-
let http: HttpMock | undefined;
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
harness?.close();
|
|
23
|
-
harness = undefined;
|
|
24
|
-
http?.restore();
|
|
25
|
-
http = undefined;
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const ACCOUNT_ID = "acc-1";
|
|
29
|
-
|
|
30
|
-
const MAILBOXES = [
|
|
31
|
-
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
32
|
-
makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
const SEMANTIC_PREDICATE: OrganizeMatchPredicate = {
|
|
36
|
-
anchorMessageId: "msg-1",
|
|
37
|
-
matchOperator: "And",
|
|
38
|
-
literalClauses: [],
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const SENDER_PREDICATE: OrganizeMatchPredicate = {
|
|
42
|
-
matchOperator: "Or",
|
|
43
|
-
literalClauses: [{ field: "From", value: "npm@github.com" }],
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const mount = (
|
|
47
|
-
props: Partial<Parameters<typeof OrganizePanel>[0]>,
|
|
48
|
-
): DomHarness => {
|
|
49
|
-
http = mockFetch((call) => {
|
|
50
|
-
if (call.path.endsWith("/organize") && call.method === "POST") {
|
|
51
|
-
return { organizeJobId: "job-1", state: "Processing" };
|
|
52
|
-
}
|
|
53
|
-
if (call.path.endsWith("/organize/job-1") && call.method === "GET") {
|
|
54
|
-
return {
|
|
55
|
-
organizeJobId: "job-1",
|
|
56
|
-
state: "Processing",
|
|
57
|
-
matchedCount: 128,
|
|
58
|
-
appliedCount: 0,
|
|
59
|
-
failedCount: 0,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
return {};
|
|
63
|
-
});
|
|
64
|
-
harness = createDomHarness();
|
|
65
|
-
harness.queryClient.setQueryData(
|
|
66
|
-
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
|
|
67
|
-
{ items: MAILBOXES },
|
|
68
|
-
);
|
|
69
|
-
harness.renderApp(
|
|
70
|
-
createElement(OrganizePanel, {
|
|
71
|
-
accountId: ACCOUNT_ID,
|
|
72
|
-
mailboxId: "mbx-inbox",
|
|
73
|
-
selectedMessageIds: ["msg-1", "msg-2"],
|
|
74
|
-
matchPredicate: SEMANTIC_PREDICATE,
|
|
75
|
-
matchedCount: 128,
|
|
76
|
-
seedMailboxId: "mbx-archive",
|
|
77
|
-
onClose: () => undefined,
|
|
78
|
-
...props,
|
|
79
|
-
}),
|
|
80
|
-
);
|
|
81
|
-
return harness;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
async function startJob(dom: DomHarness): Promise<void> {
|
|
85
|
-
dom.click(dom.byText("button", "Organize"));
|
|
86
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
87
|
-
await dom.flush();
|
|
88
|
-
if (/Organizing/.test(dom.text())) return;
|
|
89
|
-
await dom.wait(1);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
describe("OrganizePanel back-apply progress copy", () => {
|
|
94
|
-
it("states the sender semantics while organizing in the sender fallback", async () => {
|
|
95
|
-
const dom = mount({
|
|
96
|
-
matchPredicate: SENDER_PREDICATE,
|
|
97
|
-
semanticUnavailable: true,
|
|
98
|
-
senders: ["npm@github.com"],
|
|
99
|
-
});
|
|
100
|
-
await startJob(dom);
|
|
101
|
-
|
|
102
|
-
assert.match(dom.text(), /Organizing mail from these senders/);
|
|
103
|
-
assert.doesNotMatch(dom.text(), /Organizing similar mail/);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("keeps the similar-mail wording on the semantic path", async () => {
|
|
107
|
-
const dom = mount({});
|
|
108
|
-
await startJob(dom);
|
|
109
|
-
|
|
110
|
-
assert.match(dom.text(), /Organizing similar mail/);
|
|
111
|
-
assert.doesNotMatch(dom.text(), /from these senders/);
|
|
112
|
-
});
|
|
113
|
-
});
|