@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapFilterResponse,
|
|
3
|
+
RemitImapUpdateFilterInput,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { FilterRule, RuleClause } from "@remit/ui";
|
|
6
|
+
import { NO_ACTION } from "./organize-model";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The persisted filter as the chip editor's rule (RFC 038 D6). Loads the whole
|
|
10
|
+
* rule the user edits: literal clauses, the match operator, the move action, the
|
|
11
|
+
* scope (and, for a Temporary filter, the date it stops on), and the semantic
|
|
12
|
+
* anchor as a widen chip. The anchor renders inactive when this deployment
|
|
13
|
+
* cannot evaluate it (D4) — the rule then matches by its literal clauses only.
|
|
14
|
+
*
|
|
15
|
+
* The scope maps `Standing` → "standing" and `Temporary` → "until"; a persisted
|
|
16
|
+
* filter is never the one-time "once" scope. `moveMailboxId` drops the `"None"`
|
|
17
|
+
* sentinel to `undefined` so an empty folder select reads as "no move action".
|
|
18
|
+
*/
|
|
19
|
+
export const filterToRule = (
|
|
20
|
+
filter: RemitImapFilterResponse,
|
|
21
|
+
semanticUnavailable = false,
|
|
22
|
+
): FilterRule => {
|
|
23
|
+
const clauses: RuleClause[] = filter.literalClauses.map((clause, index) => ({
|
|
24
|
+
id: `clause-${index}`,
|
|
25
|
+
field: clause.field,
|
|
26
|
+
value: clause.value,
|
|
27
|
+
}));
|
|
28
|
+
return {
|
|
29
|
+
clauses,
|
|
30
|
+
matchOperator: filter.matchOperator === "And" ? "all" : "any",
|
|
31
|
+
widen: filter.hasAnchor
|
|
32
|
+
? { anchorCount: 1, ...(semanticUnavailable ? { inactive: true } : {}) }
|
|
33
|
+
: undefined,
|
|
34
|
+
moveMailboxId:
|
|
35
|
+
filter.actionMailboxId !== NO_ACTION ? filter.actionMailboxId : undefined,
|
|
36
|
+
scope: filter.scope === "Temporary" ? "until" : "standing",
|
|
37
|
+
until:
|
|
38
|
+
filter.scope === "Temporary"
|
|
39
|
+
? expiresAtToPickedDate(filter.expiresAt)
|
|
40
|
+
: undefined,
|
|
41
|
+
name: filter.name,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `YYYY-MM-DD` the date input shows for a stored `expiresAt`. The inverse of
|
|
47
|
+
* {@link pickedDateToExpiresAt}: it reads the local calendar day the timestamp
|
|
48
|
+
* falls on, so a date picked, saved, and reopened comes back unchanged. Empty
|
|
49
|
+
* or unparseable input yields an empty string — the picker's "no date" state.
|
|
50
|
+
*/
|
|
51
|
+
export const expiresAtToPickedDate = (
|
|
52
|
+
expiresAt: string | undefined,
|
|
53
|
+
): string => {
|
|
54
|
+
if (!expiresAt) return "";
|
|
55
|
+
const ms = Date.parse(expiresAt);
|
|
56
|
+
if (Number.isNaN(ms)) return "";
|
|
57
|
+
const date = new Date(ms);
|
|
58
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
59
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const predicateActionKey = (rule: FilterRule): string =>
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
clauses: rule.clauses.map((clause) => [clause.field, clause.value]),
|
|
65
|
+
operator: rule.matchOperator,
|
|
66
|
+
move: rule.moveMailboxId ?? null,
|
|
67
|
+
widen: rule.widen ? (rule.widen.inactive ? "inactive" : "active") : "none",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether the edited rule changes the predicate or the action versus the one it
|
|
72
|
+
* was loaded from — the clauses, the match operator, the move target, or the
|
|
73
|
+
* widen's presence. A change here is what bumps `ruleChangedAt` and offers the
|
|
74
|
+
* re-back-apply (RFC 034 Decision 3.2); the name is deliberately excluded, so a
|
|
75
|
+
* cosmetic rename is not a rule change.
|
|
76
|
+
*/
|
|
77
|
+
export const ruleChangesPredicateOrAction = (
|
|
78
|
+
rule: FilterRule,
|
|
79
|
+
original: FilterRule,
|
|
80
|
+
): boolean => predicateActionKey(rule) !== predicateActionKey(original);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The PATCH body for an edited filter. A cosmetic rename sends `{ name }` only,
|
|
84
|
+
* so the server's `changesPredicateOrAction` guard leaves `ruleChangedAt`
|
|
85
|
+
* untouched (RFC 034 Decision 3.2). A predicate or action change sends the
|
|
86
|
+
* operator, clauses, and move target, which bumps `ruleChangedAt`. Fields the
|
|
87
|
+
* editor never touches — the label action, the immutable scope/expiry — are
|
|
88
|
+
* absent from the patch, so the partial update preserves them.
|
|
89
|
+
*/
|
|
90
|
+
export const buildUpdateFilterInput = (
|
|
91
|
+
rule: FilterRule,
|
|
92
|
+
original: FilterRule,
|
|
93
|
+
): RemitImapUpdateFilterInput => {
|
|
94
|
+
const body: RemitImapUpdateFilterInput = {};
|
|
95
|
+
const name = (rule.name ?? "").trim();
|
|
96
|
+
if (name !== (original.name ?? "").trim()) body.name = name;
|
|
97
|
+
if (ruleChangesPredicateOrAction(rule, original)) {
|
|
98
|
+
body.matchOperator = rule.matchOperator === "all" ? "And" : "Or";
|
|
99
|
+
body.literalClauses = rule.clauses.map((clause) => ({
|
|
100
|
+
field: clause.field,
|
|
101
|
+
value: clause.value,
|
|
102
|
+
}));
|
|
103
|
+
body.actionMailboxId = rule.moveMailboxId ?? NO_ACTION;
|
|
104
|
+
}
|
|
105
|
+
return body;
|
|
106
|
+
};
|
|
@@ -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;
|
|
@@ -6,11 +6,13 @@ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.
|
|
|
6
6
|
import { SettingsShell } from "@remit/ui";
|
|
7
7
|
import { useQuery } from "@tanstack/react-query";
|
|
8
8
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
9
|
-
import { useCallback, useState } from "react";
|
|
9
|
+
import { useCallback, useMemo, useState } from "react";
|
|
10
|
+
import { FilterEditorSurface } from "@/components/settings/FilterEditorSurface";
|
|
10
11
|
import { FiltersList } from "@/components/settings/FiltersList";
|
|
11
12
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
12
13
|
import { useDeleteFilter, useFilterList } from "@/hooks/useFilters";
|
|
13
14
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
15
|
+
import { buildMoveTargets } from "@/lib/move-targets";
|
|
14
16
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
15
17
|
|
|
16
18
|
export const Route = createFileRoute("/settings/filters")({
|
|
@@ -36,6 +38,7 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
|
|
|
36
38
|
const { filters, isPending, isError, error, refetch } =
|
|
37
39
|
useFilterList(accountId);
|
|
38
40
|
const { deleteFilter, deletingFilterId } = useDeleteFilter(accountId);
|
|
41
|
+
const [editingFilterId, setEditingFilterId] = useState<string | undefined>();
|
|
39
42
|
|
|
40
43
|
const { data: mailboxesData } = useQuery({
|
|
41
44
|
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
@@ -52,9 +55,30 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
|
|
|
52
55
|
[mailboxesData?.items],
|
|
53
56
|
);
|
|
54
57
|
|
|
58
|
+
const folders = useMemo(
|
|
59
|
+
() =>
|
|
60
|
+
buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
|
|
61
|
+
id: mailbox.mailboxId,
|
|
62
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
63
|
+
})),
|
|
64
|
+
[mailboxesData?.items],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const editingFilter = filters.find(
|
|
68
|
+
(filter) => filter.filterId === editingFilterId,
|
|
69
|
+
);
|
|
70
|
+
|
|
55
71
|
return (
|
|
56
72
|
<section className="space-y-2">
|
|
57
73
|
<h2 className="text-sm font-semibold text-fg">{account.email}</h2>
|
|
74
|
+
{editingFilter && (
|
|
75
|
+
<FilterEditorSurface
|
|
76
|
+
accountId={accountId}
|
|
77
|
+
filter={editingFilter}
|
|
78
|
+
folders={folders}
|
|
79
|
+
onClose={() => setEditingFilterId(undefined)}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
58
82
|
{isPending ? (
|
|
59
83
|
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on loading skeleton provides useful context for assistive tech
|
|
60
84
|
<div
|
|
@@ -75,6 +99,7 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
|
|
|
75
99
|
<FiltersList
|
|
76
100
|
filters={filters}
|
|
77
101
|
mailboxName={mailboxName}
|
|
102
|
+
onEdit={setEditingFilterId}
|
|
78
103
|
onDelete={deleteFilter}
|
|
79
104
|
deletingFilterId={deletingFilterId}
|
|
80
105
|
/>
|