@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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/MessageList.tsx +0 -2
  3. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -1
  4. package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -10
  5. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -1
  6. package/src/components/mail/organize/OrganizeDialog.tsx +7 -12
  7. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +358 -0
  8. package/src/components/mail/organize/OrganizeRuleEditor.tsx +361 -0
  9. package/src/components/mail/organize/smart-organize.stories.tsx +39 -119
  10. package/src/components/settings/FilterEditor.render.test.ts +281 -0
  11. package/src/components/settings/FilterEditor.tsx +338 -0
  12. package/src/components/settings/FilterEditorSurface.tsx +52 -0
  13. package/src/components/settings/FiltersList.render.test.ts +27 -1
  14. package/src/components/settings/FiltersList.tsx +33 -5
  15. package/src/components/settings/settings-filter.stories.tsx +165 -0
  16. package/src/hooks/useRulePreview.ts +91 -0
  17. package/src/hooks/useUpdateFilter.ts +56 -0
  18. package/src/lib/organize/filter-edit-model.test.ts +175 -0
  19. package/src/lib/organize/filter-edit-model.ts +106 -0
  20. package/src/lib/organize/rule-model.test.ts +297 -0
  21. package/src/lib/organize/rule-model.ts +205 -0
  22. package/src/routes/settings/filters.tsx +26 -1
  23. package/src/components/mail/organize/OrganizePanel.progress.test.ts +0 -113
  24. package/src/components/mail/organize/OrganizePanel.render.test.ts +0 -170
  25. package/src/components/mail/organize/OrganizePanel.tsx +0 -420
  26. package/src/lib/organize/organize-copy.test.ts +0 -142
  27. 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
+ }