@remit/web-client 0.0.66 → 0.0.68
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/MailListHeader.tsx +74 -23
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +19 -220
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +36 -0
- package/src/components/mail/organize/SearchFilterDialog.tsx +91 -0
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +226 -0
- package/src/components/mail/organize/SearchFilterEditor.tsx +132 -0
- package/src/components/mail/organize/rule-editor-states.tsx +112 -0
- 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/useRuleEditorState.ts +174 -0
- package/src/hooks/useRulePreview.ts +10 -5
- package/src/hooks/useSearchFilterSeed.render.test.ts +83 -0
- package/src/hooks/useSearchFilterSeed.ts +61 -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/search-to-rule.test.ts +146 -0
- package/src/lib/organize/search-to-rule.ts +161 -0
- package/src/routes/settings/filters.tsx +26 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import type { FilterRule } from "@remit/ui";
|
|
5
|
+
import {
|
|
6
|
+
buildUpdateFilterInput,
|
|
7
|
+
expiresAtToPickedDate,
|
|
8
|
+
filterToRule,
|
|
9
|
+
ruleChangesPredicateOrAction,
|
|
10
|
+
} from "./filter-edit-model";
|
|
11
|
+
|
|
12
|
+
const filter = (
|
|
13
|
+
overrides: Partial<RemitImapFilterResponse> = {},
|
|
14
|
+
): RemitImapFilterResponse => ({
|
|
15
|
+
filterId: "f-1",
|
|
16
|
+
accountConfigId: "acc-1",
|
|
17
|
+
name: "Receipts",
|
|
18
|
+
scope: "Standing",
|
|
19
|
+
state: "Active",
|
|
20
|
+
hasAnchor: false,
|
|
21
|
+
ruleChangedAt: 0,
|
|
22
|
+
matchOperator: "And",
|
|
23
|
+
literalClauses: [{ field: "From", value: "receipts@stripe.com" }],
|
|
24
|
+
actionLabelId: "None",
|
|
25
|
+
actionMailboxId: "mbx-receipts",
|
|
26
|
+
createdAt: 0,
|
|
27
|
+
updatedAt: 0,
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("filterToRule", () => {
|
|
32
|
+
it("loads the persisted predicate, action and name unchanged", () => {
|
|
33
|
+
const rule = filterToRule(
|
|
34
|
+
filter({
|
|
35
|
+
matchOperator: "Or",
|
|
36
|
+
literalClauses: [
|
|
37
|
+
{ field: "From", value: "a@x.com" },
|
|
38
|
+
{ field: "Subject", value: "invoice" },
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
assert.equal(rule.matchOperator, "any");
|
|
43
|
+
assert.equal(rule.name, "Receipts");
|
|
44
|
+
assert.equal(rule.moveMailboxId, "mbx-receipts");
|
|
45
|
+
assert.equal(rule.scope, "standing");
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
rule.clauses.map((clause) => [clause.field, clause.value]),
|
|
48
|
+
[
|
|
49
|
+
["From", "a@x.com"],
|
|
50
|
+
["Subject", "invoice"],
|
|
51
|
+
],
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("maps the None move sentinel to no action", () => {
|
|
56
|
+
const rule = filterToRule(filter({ actionMailboxId: "None" }));
|
|
57
|
+
assert.equal(rule.moveMailboxId, undefined);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("loads a Temporary filter as an until-a-date rule", () => {
|
|
61
|
+
const rule = filterToRule(
|
|
62
|
+
filter({ scope: "Temporary", expiresAt: "2027-03-04T23:59:59+00:00" }),
|
|
63
|
+
);
|
|
64
|
+
assert.equal(rule.scope, "until");
|
|
65
|
+
assert.match(rule.until ?? "", /^\d{4}-\d{2}-\d{2}$/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("shows a semantic anchor as an active widen chip when the deployment can serve it", () => {
|
|
69
|
+
const rule = filterToRule(filter({ hasAnchor: true }));
|
|
70
|
+
assert.ok(rule.widen);
|
|
71
|
+
assert.equal(rule.widen?.inactive, undefined);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("marks the widen inactive on a deployment that cannot evaluate it (D4)", () => {
|
|
75
|
+
const rule = filterToRule(filter({ hasAnchor: true }), true);
|
|
76
|
+
assert.equal(rule.widen?.inactive, true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("carries no widen when the filter has no anchor", () => {
|
|
80
|
+
assert.equal(filterToRule(filter({ hasAnchor: false })).widen, undefined);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("expiresAtToPickedDate", () => {
|
|
85
|
+
it("returns empty for absent or unparseable input", () => {
|
|
86
|
+
assert.equal(expiresAtToPickedDate(undefined), "");
|
|
87
|
+
assert.equal(expiresAtToPickedDate("not-a-date"), "");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reads the local calendar day", () => {
|
|
91
|
+
assert.match(
|
|
92
|
+
expiresAtToPickedDate("2027-03-04T12:00:00Z"),
|
|
93
|
+
/^2027-03-0[45]$/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("ruleChangesPredicateOrAction", () => {
|
|
99
|
+
const base = filterToRule(filter({ hasAnchor: true }));
|
|
100
|
+
|
|
101
|
+
it("is false for an identical rule and for a rename only", () => {
|
|
102
|
+
assert.equal(ruleChangesPredicateOrAction(base, base), false);
|
|
103
|
+
assert.equal(
|
|
104
|
+
ruleChangesPredicateOrAction({ ...base, name: "New name" }, base),
|
|
105
|
+
false,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is true when a clause, operator, move target or widen changes", () => {
|
|
110
|
+
assert.equal(
|
|
111
|
+
ruleChangesPredicateOrAction({ ...base, matchOperator: "any" }, base),
|
|
112
|
+
true,
|
|
113
|
+
);
|
|
114
|
+
assert.equal(
|
|
115
|
+
ruleChangesPredicateOrAction(
|
|
116
|
+
{ ...base, moveMailboxId: "mbx-other" },
|
|
117
|
+
base,
|
|
118
|
+
),
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
assert.equal(
|
|
122
|
+
ruleChangesPredicateOrAction({ ...base, widen: undefined }, base),
|
|
123
|
+
true,
|
|
124
|
+
);
|
|
125
|
+
assert.equal(
|
|
126
|
+
ruleChangesPredicateOrAction(
|
|
127
|
+
{ ...base, clauses: [{ id: "c", field: "Subject", value: "x" }] },
|
|
128
|
+
base,
|
|
129
|
+
),
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("buildUpdateFilterInput", () => {
|
|
136
|
+
const original = filterToRule(filter());
|
|
137
|
+
|
|
138
|
+
it("sends only the name for a cosmetic rename — no predicate fields", () => {
|
|
139
|
+
const body = buildUpdateFilterInput(
|
|
140
|
+
{ ...original, name: "Invoices" },
|
|
141
|
+
original,
|
|
142
|
+
);
|
|
143
|
+
assert.deepEqual(body, { name: "Invoices" });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("sends the predicate and action on a rule change, so the server bumps ruleChangedAt", () => {
|
|
147
|
+
const changed: FilterRule = {
|
|
148
|
+
...original,
|
|
149
|
+
matchOperator: "any",
|
|
150
|
+
clauses: [{ id: "c", field: "ListId", value: "list.example.com" }],
|
|
151
|
+
moveMailboxId: "mbx-archive",
|
|
152
|
+
};
|
|
153
|
+
const body = buildUpdateFilterInput(changed, original);
|
|
154
|
+
assert.equal(body.matchOperator, "Or");
|
|
155
|
+
assert.equal(body.actionMailboxId, "mbx-archive");
|
|
156
|
+
assert.deepEqual(body.literalClauses, [
|
|
157
|
+
{ field: "ListId", value: "list.example.com" },
|
|
158
|
+
]);
|
|
159
|
+
assert.equal("name" in body, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("drops a cleared move target to the None sentinel", () => {
|
|
163
|
+
const changed = {
|
|
164
|
+
...original,
|
|
165
|
+
moveMailboxId: undefined,
|
|
166
|
+
matchOperator: "any" as const,
|
|
167
|
+
};
|
|
168
|
+
const body = buildUpdateFilterInput(changed, original);
|
|
169
|
+
assert.equal(body.actionMailboxId, "None");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("is empty when nothing changed", () => {
|
|
173
|
+
assert.deepEqual(buildUpdateFilterInput(original, original), {});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -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,146 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
|
|
4
|
+
import {
|
|
5
|
+
buildSearchRule,
|
|
6
|
+
convertSearchToRule,
|
|
7
|
+
isConvertible,
|
|
8
|
+
} from "./search-to-rule";
|
|
9
|
+
|
|
10
|
+
const CONTEXT: SearchTokenContext = {
|
|
11
|
+
mailboxesByName: new Map([["archive", "mbx-archive"]]),
|
|
12
|
+
accountsByName: new Map([["work", "acc-work"]]),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const convert = (
|
|
16
|
+
query: string,
|
|
17
|
+
options: { searchHadSemanticReach?: boolean } = {},
|
|
18
|
+
) =>
|
|
19
|
+
convertSearchToRule(parseSearchTokens(query, CONTEXT), {
|
|
20
|
+
searchHadSemanticReach: options.searchHadSemanticReach ?? false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("convertSearchToRule — facet → clause mapping", () => {
|
|
24
|
+
it("maps literal terms to a HasWords clause", () => {
|
|
25
|
+
const conversion = convert("quarterly report");
|
|
26
|
+
assert.deepEqual(conversion.clauses, [
|
|
27
|
+
{ field: "HasWords", value: "quarterly report" },
|
|
28
|
+
]);
|
|
29
|
+
assert.equal(conversion.keptTerms, true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("maps a from: facet to a From clause", () => {
|
|
33
|
+
const conversion = convert("from:alerts@github.com");
|
|
34
|
+
assert.deepEqual(conversion.clauses, [
|
|
35
|
+
{ field: "From", value: "alerts@github.com" },
|
|
36
|
+
]);
|
|
37
|
+
assert.equal(conversion.keptTerms, false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("combines terms and a sender under an all-match operator", () => {
|
|
41
|
+
const conversion = convert("from:alerts@github.com pull request");
|
|
42
|
+
assert.equal(conversion.matchOperator, "all");
|
|
43
|
+
assert.deepEqual(conversion.clauses, [
|
|
44
|
+
{ field: "From", value: "alerts@github.com" },
|
|
45
|
+
{ field: "HasWords", value: "pull request" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("convertSearchToRule — facets with no clause equivalent", () => {
|
|
51
|
+
it("keeps a folder-scoped search OUT of the rule, never silently unscoping it", () => {
|
|
52
|
+
const conversion = convert("in:archive receipts");
|
|
53
|
+
assert.deepEqual(conversion.scopedOut, {
|
|
54
|
+
mailboxId: "mbx-archive",
|
|
55
|
+
label: "archive",
|
|
56
|
+
});
|
|
57
|
+
// The folder never becomes a clause.
|
|
58
|
+
assert.deepEqual(conversion.clauses, [
|
|
59
|
+
{ field: "HasWords", value: "receipts" },
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("drops attachment, unread and date facets, naming each", () => {
|
|
64
|
+
const conversion = convert(
|
|
65
|
+
"invoice has:attachment is:unread before:2026-01-01 after:2025-01-01",
|
|
66
|
+
);
|
|
67
|
+
const labels = conversion.droppedFacets.map((facet) => facet.label);
|
|
68
|
+
assert.deepEqual(labels, [
|
|
69
|
+
"Has attachment",
|
|
70
|
+
"Unread",
|
|
71
|
+
"Before 2026-01-01",
|
|
72
|
+
"After 2025-01-01",
|
|
73
|
+
]);
|
|
74
|
+
assert.deepEqual(conversion.clauses, [
|
|
75
|
+
{ field: "HasWords", value: "invoice" },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("targets the account an account: facet names, without a clause for it", () => {
|
|
80
|
+
const conversion = convert("account:work standup");
|
|
81
|
+
assert.equal(conversion.targetAccountId, "acc-work");
|
|
82
|
+
assert.deepEqual(conversion.clauses, [
|
|
83
|
+
{ field: "HasWords", value: "standup" },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("convertSearchToRule — semantic honesty (RFC 038 D5)", () => {
|
|
89
|
+
it("states the dropped reach when the search surfaced similar mail", () => {
|
|
90
|
+
// The literal filter cannot reproduce the reach the search just showed.
|
|
91
|
+
const conversion = convert("things like this", {
|
|
92
|
+
searchHadSemanticReach: true,
|
|
93
|
+
});
|
|
94
|
+
assert.equal(conversion.droppedSemantic, true);
|
|
95
|
+
// The literal words are still kept.
|
|
96
|
+
assert.deepEqual(conversion.clauses, [
|
|
97
|
+
{ field: "HasWords", value: "things like this" },
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("says nothing when the search had no semantic reach to drop", () => {
|
|
102
|
+
const conversion = convert("things like this", {
|
|
103
|
+
searchHadSemanticReach: false,
|
|
104
|
+
});
|
|
105
|
+
assert.equal(conversion.droppedSemantic, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("never reports dropped semantics for a facet-only search", () => {
|
|
109
|
+
const conversion = convert("from:a@b.com", {
|
|
110
|
+
searchHadSemanticReach: true,
|
|
111
|
+
});
|
|
112
|
+
assert.equal(conversion.keptTerms, false);
|
|
113
|
+
assert.equal(conversion.droppedSemantic, false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("isConvertible / buildSearchRule", () => {
|
|
118
|
+
it("is not convertible when the search yields no clause", () => {
|
|
119
|
+
assert.equal(isConvertible(convert("has:attachment")), false);
|
|
120
|
+
assert.equal(isConvertible(convert("in:archive")), false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("is convertible once a term or sender is present", () => {
|
|
124
|
+
assert.equal(isConvertible(convert("in:archive receipts")), true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("builds a standing rule with stable clause ids, no widen, empty name", () => {
|
|
128
|
+
const rule = buildSearchRule(convert("from:a@b.com nightly"));
|
|
129
|
+
assert.equal(rule.scope, "standing");
|
|
130
|
+
assert.equal(rule.widen, undefined);
|
|
131
|
+
assert.equal(rule.name, "");
|
|
132
|
+
assert.deepEqual(
|
|
133
|
+
rule.clauses.map((clause) => clause.id),
|
|
134
|
+
["search-0", "search-1"],
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("honors an explicit scope and move target", () => {
|
|
139
|
+
const rule = buildSearchRule(convert("nightly"), {
|
|
140
|
+
scope: "once",
|
|
141
|
+
moveMailboxId: "mbx-archive",
|
|
142
|
+
});
|
|
143
|
+
assert.equal(rule.scope, "once");
|
|
144
|
+
assert.equal(rule.moveMailboxId, "mbx-archive");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClauseField,
|
|
3
|
+
FilterRule,
|
|
4
|
+
MatchOperator,
|
|
5
|
+
RuleScope,
|
|
6
|
+
} from "@remit/ui";
|
|
7
|
+
import {
|
|
8
|
+
type ParsedSearchQuery,
|
|
9
|
+
type SearchToken,
|
|
10
|
+
searchTokenLabel,
|
|
11
|
+
} from "../search-tokens";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Filter-from-search (RFC 038 D5). A search is literal terms and facets; a filter
|
|
15
|
+
* is clause chips. This is the conversion between them — the current search
|
|
16
|
+
* becomes a pre-filled rule the shared chip editor opens on.
|
|
17
|
+
*
|
|
18
|
+
* The mapping is honest about what a filter cannot carry. A facet with no clause
|
|
19
|
+
* equivalent is never silently folded into the rule: a folder scope is reported
|
|
20
|
+
* as dropped-and-kept-out (the filter matches everywhere, not just there), the
|
|
21
|
+
* attribute facets (attachment / unread / date) are reported as left out, and a
|
|
22
|
+
* free-text query kept as a literal `HasWords` clause is reported as having lost
|
|
23
|
+
* its semantic "similar mail" reach on a deployment that cannot embed the query
|
|
24
|
+
* (D5). Pure functions only — the capability is injected, not probed here.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface DroppedFacet {
|
|
28
|
+
/** The facet that had no clause equivalent. */
|
|
29
|
+
type: SearchToken["type"];
|
|
30
|
+
/** What was dropped, named for the user (e.g. "Has attachment", "Before 2026-01-01"). */
|
|
31
|
+
label: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScopedOutFolder {
|
|
35
|
+
mailboxId: string;
|
|
36
|
+
/** The folder the search was limited to. */
|
|
37
|
+
label: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SearchConversion {
|
|
41
|
+
/** Clauses derived from the search — `From` (a `from:` facet) and `HasWords` (the free text). */
|
|
42
|
+
clauses: { field: ClauseField; value: string }[];
|
|
43
|
+
matchOperator: MatchOperator;
|
|
44
|
+
/** A folder an `in:` facet scoped the search to — kept OUT of the rule (never silently unscoped). */
|
|
45
|
+
scopedOut?: ScopedOutFolder;
|
|
46
|
+
/** Facets with no clause equivalent, each named. */
|
|
47
|
+
droppedFacets: DroppedFacet[];
|
|
48
|
+
/** The account an `account:` facet targets — the filter is created for it. */
|
|
49
|
+
targetAccountId?: string;
|
|
50
|
+
/** The search carried free-text terms, kept as a `HasWords` clause. */
|
|
51
|
+
keptTerms: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The filter this conversion builds is always literal-only — free text has no
|
|
54
|
+
* anchor message for a semantic widen — so the search's semantic "similar mail"
|
|
55
|
+
* reach is dropped whenever the search had one (RFC 038 D5). True exactly when
|
|
56
|
+
* free text was kept AND the search surfaced semantically-similar mail; on a
|
|
57
|
+
* deployment with no semantic reach there was nothing to drop, so no note.
|
|
58
|
+
*/
|
|
59
|
+
droppedSemantic: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ConvertOptions {
|
|
63
|
+
/**
|
|
64
|
+
* Whether the current search surfaced semantically-similar mail (a non-empty
|
|
65
|
+
* "Related" section). This is the reach the literal filter cannot reproduce —
|
|
66
|
+
* the direct existing signal, read from the search's own semantic results, not
|
|
67
|
+
* a probe of deployment capability.
|
|
68
|
+
*/
|
|
69
|
+
searchHadSemanticReach: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> = new Set([
|
|
73
|
+
"hasAttachment",
|
|
74
|
+
"isUnread",
|
|
75
|
+
"before",
|
|
76
|
+
"after",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Convert the current search into a rule's clauses and a record of what could not
|
|
81
|
+
* be carried. Terms become a `HasWords` clause; a `from:` facet a `From` clause;
|
|
82
|
+
* an `in:` facet a kept-out folder scope; `account:` the target account; the
|
|
83
|
+
* attribute facets are dropped. The search's terms are ANDed with its facets, so
|
|
84
|
+
* the rule matches all of them (`all`).
|
|
85
|
+
*/
|
|
86
|
+
export const convertSearchToRule = (
|
|
87
|
+
parsed: ParsedSearchQuery,
|
|
88
|
+
{ searchHadSemanticReach }: ConvertOptions,
|
|
89
|
+
): SearchConversion => {
|
|
90
|
+
const clauses: SearchConversion["clauses"] = [];
|
|
91
|
+
const droppedFacets: DroppedFacet[] = [];
|
|
92
|
+
let scopedOut: ScopedOutFolder | undefined;
|
|
93
|
+
let targetAccountId: string | undefined;
|
|
94
|
+
|
|
95
|
+
for (const token of parsed.tokens) {
|
|
96
|
+
if (token.type === "from") {
|
|
97
|
+
clauses.push({ field: "From", value: token.value });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (token.type === "in") {
|
|
101
|
+
scopedOut = { mailboxId: token.mailboxId, label: token.value };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (token.type === "account") {
|
|
105
|
+
targetAccountId = token.accountId;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (FACET_HAS_NO_CLAUSE.has(token.type)) {
|
|
109
|
+
droppedFacets.push({ type: token.type, label: searchTokenLabel(token) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const freeText = parsed.freeText.trim();
|
|
114
|
+
const keptTerms = freeText.length > 0;
|
|
115
|
+
if (keptTerms) clauses.push({ field: "HasWords", value: freeText });
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
clauses,
|
|
119
|
+
matchOperator: "all",
|
|
120
|
+
scopedOut,
|
|
121
|
+
droppedFacets,
|
|
122
|
+
targetAccountId,
|
|
123
|
+
keptTerms,
|
|
124
|
+
droppedSemantic: keptTerms && searchHadSemanticReach,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether the conversion yields a rule with something to match. A search of only
|
|
130
|
+
* dropped facets or a bare folder scope converts to no clauses, so there is no
|
|
131
|
+
* filter to open — the entry point offers nothing rather than an empty editor.
|
|
132
|
+
*/
|
|
133
|
+
export const isConvertible = (conversion: SearchConversion): boolean =>
|
|
134
|
+
conversion.clauses.length > 0;
|
|
135
|
+
|
|
136
|
+
interface BuildRuleOptions {
|
|
137
|
+
scope?: RuleScope;
|
|
138
|
+
moveMailboxId?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The rule the editor opens on, from a conversion. A search-derived rule defaults
|
|
143
|
+
* to a standing filter — "make this a filter" is a request to keep applying it —
|
|
144
|
+
* and the editor lets the user drop it back to a one-time apply. It carries no
|
|
145
|
+
* widen: a free-text query has no message anchor, so the semantic chip is not
|
|
146
|
+
* offered on this surface (its loss is stated in the conversion notice instead).
|
|
147
|
+
*/
|
|
148
|
+
export const buildSearchRule = (
|
|
149
|
+
conversion: SearchConversion,
|
|
150
|
+
{ scope = "standing", moveMailboxId }: BuildRuleOptions = {},
|
|
151
|
+
): FilterRule => ({
|
|
152
|
+
clauses: conversion.clauses.map((clause, index) => ({
|
|
153
|
+
id: `search-${index}`,
|
|
154
|
+
field: clause.field,
|
|
155
|
+
value: clause.value,
|
|
156
|
+
})),
|
|
157
|
+
matchOperator: conversion.matchOperator,
|
|
158
|
+
moveMailboxId,
|
|
159
|
+
scope,
|
|
160
|
+
name: "",
|
|
161
|
+
});
|
|
@@ -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
|
/>
|