@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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing a standing filter in the shared chip editor (RFC 038 D6), driven
|
|
3
|
+
* through the real component in a DOM over the live update / preview / apply
|
|
4
|
+
* endpoints.
|
|
5
|
+
*
|
|
6
|
+
* The contract these tests pin: a predicate or action change patches the
|
|
7
|
+
* predicate fields (so the server bumps `ruleChangedAt`) and offers a
|
|
8
|
+
* re-back-apply carrying exactly the previewed predicate; a cosmetic rename
|
|
9
|
+
* patches the name alone and offers nothing (RFC 034 Decision 3.2). A degraded
|
|
10
|
+
* filter lists its widen inactive and lets the user take it off.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { afterEach, describe, it } from "node:test";
|
|
15
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
16
|
+
import { createElement } from "react";
|
|
17
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
18
|
+
import {
|
|
19
|
+
type HttpCall,
|
|
20
|
+
type HttpMock,
|
|
21
|
+
mockFetch,
|
|
22
|
+
} from "../../test-support/http";
|
|
23
|
+
import { FilterEditor } from "./FilterEditor";
|
|
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 FOLDERS = [
|
|
38
|
+
{ id: "mbx-receipts", label: "Receipts" },
|
|
39
|
+
{ id: "mbx-archive", label: "Archive" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const filterFixture = (
|
|
43
|
+
overrides: Partial<RemitImapFilterResponse> = {},
|
|
44
|
+
): RemitImapFilterResponse => ({
|
|
45
|
+
filterId: "f-1",
|
|
46
|
+
accountConfigId: ACCOUNT_ID,
|
|
47
|
+
name: "Receipts",
|
|
48
|
+
scope: "Standing",
|
|
49
|
+
state: "Active",
|
|
50
|
+
hasAnchor: false,
|
|
51
|
+
ruleChangedAt: 100,
|
|
52
|
+
matchOperator: "And",
|
|
53
|
+
literalClauses: [{ field: "From", value: "receipts@stripe.com" }],
|
|
54
|
+
actionLabelId: "None",
|
|
55
|
+
actionMailboxId: "mbx-receipts",
|
|
56
|
+
createdAt: 0,
|
|
57
|
+
updatedAt: 0,
|
|
58
|
+
...overrides,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
type Responder = (call: HttpCall) => unknown;
|
|
62
|
+
|
|
63
|
+
/** Fields whose presence in a patch bumps `ruleChangedAt` (RFC 034 Decision 3.2). */
|
|
64
|
+
const PREDICATE_OR_ACTION = [
|
|
65
|
+
"matchOperator",
|
|
66
|
+
"literalClauses",
|
|
67
|
+
"actionLabelId",
|
|
68
|
+
"actionMailboxId",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A backend that echoes the real update contract: the patch merges over the
|
|
73
|
+
* stored filter, `hasAnchor` survives it (the update endpoint carries no anchor
|
|
74
|
+
* field — `sanitizePatch` drops it), and `ruleChangedAt` advances only when the
|
|
75
|
+
* patch touches a predicate or action field.
|
|
76
|
+
*/
|
|
77
|
+
const backend = (
|
|
78
|
+
filter: RemitImapFilterResponse,
|
|
79
|
+
previewCount = 8,
|
|
80
|
+
): Responder => {
|
|
81
|
+
return (call) => {
|
|
82
|
+
if (call.path.endsWith("/organize/preview")) {
|
|
83
|
+
return { matchedCount: previewCount, messageIds: [] };
|
|
84
|
+
}
|
|
85
|
+
if (call.path.endsWith("/filters/f-1") && call.method === "PATCH") {
|
|
86
|
+
const body = call.body ?? {};
|
|
87
|
+
const bumped = PREDICATE_OR_ACTION.some((field) => field in body);
|
|
88
|
+
return {
|
|
89
|
+
...filter,
|
|
90
|
+
...body,
|
|
91
|
+
hasAnchor: filter.hasAnchor,
|
|
92
|
+
ruleChangedAt: bumped ? filter.ruleChangedAt + 1 : filter.ruleChangedAt,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (call.path.endsWith("/organize") && call.method === "POST") {
|
|
96
|
+
return { organizeJobId: "job-1", state: "Running" };
|
|
97
|
+
}
|
|
98
|
+
if (call.path.endsWith("/organize/job-1")) {
|
|
99
|
+
return {
|
|
100
|
+
organizeJobId: "job-1",
|
|
101
|
+
state: "Complete",
|
|
102
|
+
matchedCount: previewCount,
|
|
103
|
+
appliedCount: previewCount,
|
|
104
|
+
failedCount: 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {};
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const mount = (
|
|
112
|
+
filter: RemitImapFilterResponse,
|
|
113
|
+
responder?: Responder,
|
|
114
|
+
semanticUnavailable = false,
|
|
115
|
+
): DomHarness => {
|
|
116
|
+
http = mockFetch(responder ?? backend(filter));
|
|
117
|
+
harness = createDomHarness();
|
|
118
|
+
harness.renderApp(
|
|
119
|
+
createElement(FilterEditor, {
|
|
120
|
+
accountId: ACCOUNT_ID,
|
|
121
|
+
filter,
|
|
122
|
+
folders: FOLDERS,
|
|
123
|
+
semanticUnavailable,
|
|
124
|
+
onClose: () => undefined,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
return harness;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
async function settlePreview(dom: DomHarness): Promise<void> {
|
|
131
|
+
await dom.flush();
|
|
132
|
+
await dom.wait(400);
|
|
133
|
+
await dom.flush();
|
|
134
|
+
await dom.flush();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const primaryButton = (dom: DomHarness, label: string): HTMLButtonElement =>
|
|
138
|
+
dom.byText("button", label) as HTMLButtonElement;
|
|
139
|
+
|
|
140
|
+
const addClause = (dom: DomHarness, field: string, value: string): void => {
|
|
141
|
+
dom.click(primaryButton(dom, "Add clause"));
|
|
142
|
+
dom.select(dom.byLabel("Clause field"), field);
|
|
143
|
+
dom.type(dom.byLabel("Clause value"), value);
|
|
144
|
+
dom.click(primaryButton(dom, "Add"));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const patchCalls = (): HttpCall[] =>
|
|
148
|
+
(http?.calls ?? []).filter(
|
|
149
|
+
(call) => call.path.endsWith("/filters/f-1") && call.method === "PATCH",
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
describe("FilterEditor — open, edit, save round trip", () => {
|
|
153
|
+
it("loads the persisted rule into the editor", async () => {
|
|
154
|
+
const dom = mount(filterFixture());
|
|
155
|
+
await settlePreview(dom);
|
|
156
|
+
assert.match(dom.text(), /receipts@stripe\.com/);
|
|
157
|
+
assert.equal(
|
|
158
|
+
(dom.byLabel("Rule name") as HTMLInputElement).value,
|
|
159
|
+
"Receipts",
|
|
160
|
+
);
|
|
161
|
+
assert.match(dom.text(), /Save rule/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("patches only the predicate on a clause change, leaving the name untouched", async () => {
|
|
165
|
+
const dom = mount(filterFixture());
|
|
166
|
+
await settlePreview(dom);
|
|
167
|
+
|
|
168
|
+
addClause(dom, "Subject", "invoice");
|
|
169
|
+
await settlePreview(dom);
|
|
170
|
+
|
|
171
|
+
dom.click(primaryButton(dom, "Save rule"));
|
|
172
|
+
await dom.flush();
|
|
173
|
+
|
|
174
|
+
const patch = patchCalls();
|
|
175
|
+
assert.equal(patch.length, 1);
|
|
176
|
+
assert.equal(patch[0].body?.matchOperator, "And");
|
|
177
|
+
assert.equal(patch[0].body?.actionMailboxId, "mbx-receipts");
|
|
178
|
+
assert.deepEqual(patch[0].body?.literalClauses, [
|
|
179
|
+
{ field: "From", value: "receipts@stripe.com" },
|
|
180
|
+
{ field: "Subject", value: "invoice" },
|
|
181
|
+
]);
|
|
182
|
+
// The name is untouched, so it never travels — the partial patch preserves it.
|
|
183
|
+
assert.equal("name" in (patch[0].body ?? {}), false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("FilterEditor — cosmetic rename (RFC 034 Decision 3.2)", () => {
|
|
188
|
+
it("patches the name alone and offers no re-apply", async () => {
|
|
189
|
+
const dom = mount(filterFixture());
|
|
190
|
+
await settlePreview(dom);
|
|
191
|
+
|
|
192
|
+
dom.type(dom.byLabel("Rule name"), "Invoices");
|
|
193
|
+
dom.click(primaryButton(dom, "Save rule"));
|
|
194
|
+
await dom.flush();
|
|
195
|
+
|
|
196
|
+
const patch = patchCalls();
|
|
197
|
+
assert.equal(patch.length, 1);
|
|
198
|
+
assert.deepEqual(patch[0].body, { name: "Invoices" });
|
|
199
|
+
|
|
200
|
+
// No predicate field, so the server leaves ruleChangedAt alone — and the
|
|
201
|
+
// editor never offers to move existing mail.
|
|
202
|
+
assert.match(dom.text(), /Filter updated/);
|
|
203
|
+
assert.doesNotMatch(dom.text(), /Move existing mail/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("FilterEditor — rule change offers the re-back-apply", () => {
|
|
208
|
+
it("offers a re-apply that carries exactly the previewed predicate", async () => {
|
|
209
|
+
const dom = mount(filterFixture());
|
|
210
|
+
await settlePreview(dom);
|
|
211
|
+
|
|
212
|
+
addClause(dom, "Subject", "invoice");
|
|
213
|
+
await settlePreview(dom);
|
|
214
|
+
|
|
215
|
+
dom.click(primaryButton(dom, "Save rule"));
|
|
216
|
+
await dom.flush();
|
|
217
|
+
|
|
218
|
+
// The rule changed, so the editor offers to move the mail already filed.
|
|
219
|
+
assert.match(dom.text(), /Move existing mail/);
|
|
220
|
+
|
|
221
|
+
dom.click(primaryButton(dom, "Move existing mail"));
|
|
222
|
+
await dom.flush();
|
|
223
|
+
|
|
224
|
+
const previews = http?.to("/organize/preview") ?? [];
|
|
225
|
+
const lastPreview = previews[previews.length - 1];
|
|
226
|
+
const applied = (http?.calls ?? []).filter(
|
|
227
|
+
(call) => call.path.endsWith("/organize") && call.method === "POST",
|
|
228
|
+
);
|
|
229
|
+
assert.equal(applied.length, 1);
|
|
230
|
+
// Apply carries exactly the predicate the settled preview counted.
|
|
231
|
+
assert.equal(applied[0].body?.matchOperator, "And");
|
|
232
|
+
assert.deepEqual(
|
|
233
|
+
applied[0].body?.literalClauses,
|
|
234
|
+
lastPreview.body?.literalClauses,
|
|
235
|
+
);
|
|
236
|
+
assert.deepEqual(applied[0].body?.literalClauses, [
|
|
237
|
+
{ field: "From", value: "receipts@stripe.com" },
|
|
238
|
+
{ field: "Subject", value: "invoice" },
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("FilterEditor — degraded semantic filter (RFC 038 D4)", () => {
|
|
244
|
+
it("lists the widen inactive and display-only — the anchor is fixed at creation", async () => {
|
|
245
|
+
const dom = mount(filterFixture({ hasAnchor: true }), undefined, true);
|
|
246
|
+
await settlePreview(dom);
|
|
247
|
+
|
|
248
|
+
// The anchor this deployment cannot evaluate lists inactive.
|
|
249
|
+
assert.match(dom.text(), /not available here/i);
|
|
250
|
+
|
|
251
|
+
// The update endpoint carries no anchor, so the chip cannot be removed here:
|
|
252
|
+
// no remove affordance, and a note says the anchor is fixed at creation.
|
|
253
|
+
assert.equal(
|
|
254
|
+
dom.query('[aria-label="Remove the similar-mail widen"]'),
|
|
255
|
+
null,
|
|
256
|
+
);
|
|
257
|
+
assert.match(dom.text(), /set when a filter is created/i);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("FilterEditor — scope and expiry are read-only (reader #266)", () => {
|
|
262
|
+
it("shows an until-a-date filter's scope statically, with no scope or date control", async () => {
|
|
263
|
+
const dom = mount(
|
|
264
|
+
filterFixture({
|
|
265
|
+
scope: "Temporary",
|
|
266
|
+
expiresAt: "2027-09-01T23:59:59+00:00",
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
await settlePreview(dom);
|
|
270
|
+
|
|
271
|
+
// The live scope segmented control and the date input are gone — a
|
|
272
|
+
// scope/date change cannot be persisted, so it is never offered.
|
|
273
|
+
assert.equal(dom.query('input[name="rule-scope"]'), null);
|
|
274
|
+
assert.equal(dom.query('[aria-label="Expiry date"]'), null);
|
|
275
|
+
assert.match(dom.text(), /Until 2027-09-01/);
|
|
276
|
+
assert.match(dom.text(), /set when a filter is created/i);
|
|
277
|
+
|
|
278
|
+
// The name stays editable.
|
|
279
|
+
assert.ok(dom.byLabel("Rule name"));
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
type ClauseEditState,
|
|
5
|
+
type ClauseField,
|
|
6
|
+
type FilterRule,
|
|
7
|
+
FilterRuleEditor,
|
|
8
|
+
type FolderOption,
|
|
9
|
+
type MatchOperator,
|
|
10
|
+
previewCountSummary,
|
|
11
|
+
} from "@remit/ui";
|
|
12
|
+
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
13
|
+
import { useMemo, useRef, useState } from "react";
|
|
14
|
+
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
15
|
+
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
16
|
+
import { useUpdateFilter } from "@/hooks/useUpdateFilter";
|
|
17
|
+
import {
|
|
18
|
+
buildUpdateFilterInput,
|
|
19
|
+
filterToRule,
|
|
20
|
+
ruleChangesPredicateOrAction,
|
|
21
|
+
} from "@/lib/organize/filter-edit-model";
|
|
22
|
+
import {
|
|
23
|
+
normalizeClauseValue,
|
|
24
|
+
rulePredicate,
|
|
25
|
+
ruleToDraft,
|
|
26
|
+
SUPPORTED_CLAUSE_FIELDS,
|
|
27
|
+
} from "@/lib/organize/rule-model";
|
|
28
|
+
|
|
29
|
+
interface FilterEditorProps {
|
|
30
|
+
accountId: string;
|
|
31
|
+
filter: RemitImapFilterResponse;
|
|
32
|
+
folders: FolderOption[];
|
|
33
|
+
/**
|
|
34
|
+
* This deployment ships no vector pipeline, so a semantic anchor cannot be
|
|
35
|
+
* evaluated (RFC 038 D4). The filter's widen chip lists inactive and the rule
|
|
36
|
+
* matches by its literal clauses only.
|
|
37
|
+
*/
|
|
38
|
+
semanticUnavailable?: boolean;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Editing a standing filter in the same chip editor the Organize surface uses
|
|
44
|
+
* (RFC 038 D6). The row's persisted rule opens in the editor — clauses, match
|
|
45
|
+
* operator, move action, scope, and the semantic anchor as a widen chip. Saving
|
|
46
|
+
* a predicate or action change bumps `ruleChangedAt` and offers, never runs, a
|
|
47
|
+
* re-back-apply over existing mail; a cosmetic rename does neither (RFC 034
|
|
48
|
+
* Decision 3.2). The re-apply carries exactly the previewed predicate and is
|
|
49
|
+
* held behind the same settled-count commit gate as creation.
|
|
50
|
+
*/
|
|
51
|
+
export function FilterEditor({
|
|
52
|
+
accountId,
|
|
53
|
+
filter,
|
|
54
|
+
folders,
|
|
55
|
+
semanticUnavailable = false,
|
|
56
|
+
onClose,
|
|
57
|
+
}: FilterEditorProps) {
|
|
58
|
+
const original = useMemo(
|
|
59
|
+
() => filterToRule(filter, semanticUnavailable),
|
|
60
|
+
[filter, semanticUnavailable],
|
|
61
|
+
);
|
|
62
|
+
const [rule, setRule] = useState<FilterRule>(original);
|
|
63
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
64
|
+
const [offerReapply, setOfferReapply] = useState(false);
|
|
65
|
+
const nextClauseId = useRef(0);
|
|
66
|
+
|
|
67
|
+
const preview = useRulePreview(accountId, rulePredicate(rule));
|
|
68
|
+
const update = useUpdateFilter(accountId, filter.filterId);
|
|
69
|
+
const organizeJob = useOrganizeJob(accountId);
|
|
70
|
+
|
|
71
|
+
const startAddClause = () =>
|
|
72
|
+
setClauseEdit({
|
|
73
|
+
mode: "add",
|
|
74
|
+
draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const startEditClause = (clauseId: string) => {
|
|
78
|
+
const clause = rule.clauses.find((entry) => entry.id === clauseId);
|
|
79
|
+
if (!clause) return;
|
|
80
|
+
setClauseEdit({
|
|
81
|
+
mode: "edit",
|
|
82
|
+
clauseId,
|
|
83
|
+
draft: { field: clause.field, value: clause.value },
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const changeDraftField = (field: ClauseField) =>
|
|
88
|
+
setClauseEdit((edit) =>
|
|
89
|
+
edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const changeDraftValue = (value: string) =>
|
|
93
|
+
setClauseEdit((edit) =>
|
|
94
|
+
edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const submitClause = () => {
|
|
98
|
+
if (!clauseEdit) return;
|
|
99
|
+
const field = clauseEdit.draft.field;
|
|
100
|
+
const value = normalizeClauseValue(field, clauseEdit.draft.value);
|
|
101
|
+
if (value === "") return;
|
|
102
|
+
setRule((current) => {
|
|
103
|
+
if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
|
|
104
|
+
return {
|
|
105
|
+
...current,
|
|
106
|
+
clauses: current.clauses.map((clause) =>
|
|
107
|
+
clause.id === clauseEdit.clauseId
|
|
108
|
+
? { id: clause.id, field, value }
|
|
109
|
+
: clause,
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
nextClauseId.current += 1;
|
|
114
|
+
return {
|
|
115
|
+
...current,
|
|
116
|
+
clauses: [
|
|
117
|
+
...current.clauses,
|
|
118
|
+
{ id: `new-${nextClauseId.current}`, field, value },
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
setClauseEdit(undefined);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const removeClause = (clauseId: string) =>
|
|
126
|
+
setRule((current) => ({
|
|
127
|
+
...current,
|
|
128
|
+
clauses: current.clauses.filter((clause) => clause.id !== clauseId),
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const changeMatchOperator = (matchOperator: MatchOperator) =>
|
|
132
|
+
setRule((current) => ({ ...current, matchOperator }));
|
|
133
|
+
|
|
134
|
+
const changeMove = (mailboxId: string) =>
|
|
135
|
+
setRule((current) => ({
|
|
136
|
+
...current,
|
|
137
|
+
moveMailboxId: mailboxId || undefined,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const changeName = (name: string) =>
|
|
141
|
+
setRule((current) => ({ ...current, name }));
|
|
142
|
+
|
|
143
|
+
const commit = () => {
|
|
144
|
+
const predicateChanged = ruleChangesPredicateOrAction(rule, original);
|
|
145
|
+
const body = buildUpdateFilterInput(rule, original);
|
|
146
|
+
if (Object.keys(body).length === 0) {
|
|
147
|
+
onClose();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
setOfferReapply(predicateChanged);
|
|
151
|
+
update.updateFilter(body);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const reapply = () => organizeJob.start(ruleToDraft(rule));
|
|
155
|
+
|
|
156
|
+
if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
|
|
157
|
+
return (
|
|
158
|
+
<ReapplyProgress
|
|
159
|
+
progress={organizeJob.progress}
|
|
160
|
+
isDone={organizeJob.isDone}
|
|
161
|
+
onClose={onClose}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (update.isPending) return <SavingState />;
|
|
167
|
+
|
|
168
|
+
if (update.isError) {
|
|
169
|
+
return <SaveError onRetry={update.reset} onClose={onClose} />;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (update.isSuccess) {
|
|
173
|
+
if (!offerReapply) return <FilterUpdated onClose={onClose} />;
|
|
174
|
+
return (
|
|
175
|
+
<ReapplyOffer
|
|
176
|
+
preview={previewCountSummary(preview)}
|
|
177
|
+
blocked={preview.status !== "ready" || preview.stale === true}
|
|
178
|
+
onReapply={reapply}
|
|
179
|
+
onClose={onClose}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<FilterRuleEditor
|
|
186
|
+
rule={rule}
|
|
187
|
+
folders={folders}
|
|
188
|
+
preview={preview}
|
|
189
|
+
// The update endpoint carries no anchor, so a widen can be neither added
|
|
190
|
+
// nor removed here: the "…and similar" add is never offered, and the
|
|
191
|
+
// existing chip is display-only (no onRemoveWiden). `semanticUnavailable`
|
|
192
|
+
// only drives the chip's inactive styling via `filterToRule`.
|
|
193
|
+
semanticAvailable={false}
|
|
194
|
+
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
195
|
+
lifecycleLocked
|
|
196
|
+
clauseEdit={clauseEdit}
|
|
197
|
+
onStartAddClause={startAddClause}
|
|
198
|
+
onStartEditClause={startEditClause}
|
|
199
|
+
onRemoveClause={removeClause}
|
|
200
|
+
onChangeDraftField={changeDraftField}
|
|
201
|
+
onChangeDraftValue={changeDraftValue}
|
|
202
|
+
onSubmitClause={submitClause}
|
|
203
|
+
onCancelClause={() => setClauseEdit(undefined)}
|
|
204
|
+
onChangeMatchOperator={changeMatchOperator}
|
|
205
|
+
onChangeMove={changeMove}
|
|
206
|
+
onChangeName={changeName}
|
|
207
|
+
onCommit={commit}
|
|
208
|
+
onCancel={onClose}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function SavingState() {
|
|
214
|
+
return (
|
|
215
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
216
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
217
|
+
<p className="text-sm font-medium text-fg">Saving changes…</p>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function FilterUpdated({ onClose }: { onClose: () => void }) {
|
|
223
|
+
return (
|
|
224
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
225
|
+
<CheckCircle2 className="size-8 text-positive" />
|
|
226
|
+
<p className="text-sm font-medium text-fg">Filter updated</p>
|
|
227
|
+
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
228
|
+
Done
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ReapplyOffer({
|
|
235
|
+
preview,
|
|
236
|
+
blocked,
|
|
237
|
+
onReapply,
|
|
238
|
+
onClose,
|
|
239
|
+
}: {
|
|
240
|
+
preview: string;
|
|
241
|
+
blocked: boolean;
|
|
242
|
+
onReapply: () => void;
|
|
243
|
+
onClose: () => void;
|
|
244
|
+
}) {
|
|
245
|
+
return (
|
|
246
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
247
|
+
<CheckCircle2 className="size-8 text-positive" />
|
|
248
|
+
<p className="text-sm font-medium text-fg">Rule saved</p>
|
|
249
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
250
|
+
New mail follows the rule automatically. {preview} — move the mail
|
|
251
|
+
already in your mailbox too?
|
|
252
|
+
</p>
|
|
253
|
+
<div className="mt-2 flex gap-2">
|
|
254
|
+
<Button variant="primary" onClick={onReapply} disabled={blocked}>
|
|
255
|
+
Move existing mail
|
|
256
|
+
</Button>
|
|
257
|
+
<Button variant="ghost" onClick={onClose}>
|
|
258
|
+
Not now
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function ReapplyProgress({
|
|
266
|
+
progress,
|
|
267
|
+
isDone,
|
|
268
|
+
onClose,
|
|
269
|
+
}: {
|
|
270
|
+
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
271
|
+
isDone: boolean;
|
|
272
|
+
onClose: () => void;
|
|
273
|
+
}) {
|
|
274
|
+
const failed = progress.state === "Failed";
|
|
275
|
+
return (
|
|
276
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
277
|
+
{!isDone ? (
|
|
278
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
279
|
+
) : failed ? (
|
|
280
|
+
<span className="text-sm font-semibold text-danger">Move failed</span>
|
|
281
|
+
) : (
|
|
282
|
+
<CheckCircle2 className="size-8 text-positive" />
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{!isDone && (
|
|
286
|
+
<p className="text-sm font-medium text-fg">Moving existing mail…</p>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{isDone && !failed && (
|
|
290
|
+
<div className="text-sm text-fg">
|
|
291
|
+
<p className="font-medium">Done</p>
|
|
292
|
+
<p className="mt-1 text-xs text-fg-muted">
|
|
293
|
+
{progress.appliedCount} of {progress.matchedCount} moved
|
|
294
|
+
{progress.failedCount > 0
|
|
295
|
+
? ` · ${progress.failedCount} failed`
|
|
296
|
+
: ""}
|
|
297
|
+
.
|
|
298
|
+
</p>
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{isDone && failed && (
|
|
303
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
304
|
+
{progress.errorMessage || "Something went wrong. Please try again."}
|
|
305
|
+
</p>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{isDone && (
|
|
309
|
+
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
310
|
+
Done
|
|
311
|
+
</Button>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function SaveError({
|
|
318
|
+
onRetry,
|
|
319
|
+
onClose,
|
|
320
|
+
}: {
|
|
321
|
+
onRetry: () => void;
|
|
322
|
+
onClose: () => void;
|
|
323
|
+
}) {
|
|
324
|
+
return (
|
|
325
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
326
|
+
<p className="text-sm font-medium text-danger">Couldn't save changes</p>
|
|
327
|
+
<p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
|
|
328
|
+
<div className="mt-2 flex gap-2">
|
|
329
|
+
<Button variant="primary" onClick={onRetry}>
|
|
330
|
+
Try again
|
|
331
|
+
</Button>
|
|
332
|
+
<Button variant="ghost" onClick={onClose}>
|
|
333
|
+
Not now
|
|
334
|
+
</Button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { BottomSheet, Dialog, type FolderOption } from "@remit/ui";
|
|
3
|
+
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
4
|
+
import { FilterEditor } from "./FilterEditor";
|
|
5
|
+
|
|
6
|
+
interface FilterEditorSurfaceProps {
|
|
7
|
+
accountId: string;
|
|
8
|
+
filter: RemitImapFilterResponse;
|
|
9
|
+
folders: FolderOption[];
|
|
10
|
+
semanticUnavailable?: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The settings filter editor in its responsive home: the centered dialog on
|
|
16
|
+
* desktop, the bottom sheet on mobile (RFC 038 D1), mounting the same
|
|
17
|
+
* {@link FilterEditor} so the two can never drift. Opened from a filter row in
|
|
18
|
+
* Settings › Filters.
|
|
19
|
+
*/
|
|
20
|
+
export function FilterEditorSurface({
|
|
21
|
+
accountId,
|
|
22
|
+
filter,
|
|
23
|
+
folders,
|
|
24
|
+
semanticUnavailable,
|
|
25
|
+
onClose,
|
|
26
|
+
}: FilterEditorSurfaceProps) {
|
|
27
|
+
const isDesktop = useIsDesktop();
|
|
28
|
+
|
|
29
|
+
const editor = (
|
|
30
|
+
<FilterEditor
|
|
31
|
+
accountId={accountId}
|
|
32
|
+
filter={filter}
|
|
33
|
+
folders={folders}
|
|
34
|
+
semanticUnavailable={semanticUnavailable}
|
|
35
|
+
onClose={onClose}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (isDesktop) {
|
|
40
|
+
return (
|
|
41
|
+
<Dialog open onClose={onClose} title="Filter rule">
|
|
42
|
+
{editor}
|
|
43
|
+
</Dialog>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<BottomSheet open onClose={onClose} dismissLabel="Dismiss filter rule">
|
|
49
|
+
{editor}
|
|
50
|
+
</BottomSheet>
|
|
51
|
+
);
|
|
52
|
+
}
|