@remit/web-client 0.0.67 → 0.0.69
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/SelfUpdatePanel.tsx +2 -2
- package/src/hooks/useRuleEditorState.ts +174 -0
- package/src/hooks/useSearchFilterSeed.render.test.ts +83 -0
- package/src/hooks/useSearchFilterSeed.ts +61 -0
- package/src/lib/organize/search-to-rule.test.ts +146 -0
- package/src/lib/organize/search-to-rule.ts +161 -0
- package/src/lib/self-update-state.ts +4 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filter-from-search chip editor (RFC 038 D5) over the live preview/apply
|
|
3
|
+
* endpoints. The contract these tests pin: the converted clauses open pre-filled,
|
|
4
|
+
* the conversion states honestly what the search carried that the filter cannot,
|
|
5
|
+
* and the count on screen is the literal set a commit acts on — the commit is
|
|
6
|
+
* blocked until the count settles and carries exactly the previewed predicate.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { afterEach, describe, it } from "node:test";
|
|
11
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
12
|
+
import { createElement } from "react";
|
|
13
|
+
import { convertSearchToRule } from "@/lib/organize/search-to-rule";
|
|
14
|
+
import {
|
|
15
|
+
parseSearchTokens,
|
|
16
|
+
type SearchTokenContext,
|
|
17
|
+
} from "@/lib/search-tokens";
|
|
18
|
+
import { createDomHarness, type DomHarness } from "../../../test-support/dom";
|
|
19
|
+
import { makeMailbox } from "../../../test-support/fixtures";
|
|
20
|
+
import {
|
|
21
|
+
type HttpCall,
|
|
22
|
+
type HttpMock,
|
|
23
|
+
mockFetch,
|
|
24
|
+
} from "../../../test-support/http";
|
|
25
|
+
import { SearchFilterEditor } from "./SearchFilterEditor";
|
|
26
|
+
|
|
27
|
+
let harness: DomHarness | undefined;
|
|
28
|
+
let http: HttpMock | undefined;
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
harness?.close();
|
|
32
|
+
harness = undefined;
|
|
33
|
+
http?.restore();
|
|
34
|
+
http = undefined;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ACCOUNT_ID = "acc-1";
|
|
38
|
+
|
|
39
|
+
const MAILBOXES = [
|
|
40
|
+
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
41
|
+
makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const CONTEXT: SearchTokenContext = {
|
|
45
|
+
mailboxesByName: new Map([["archive", "mbx-archive"]]),
|
|
46
|
+
accountsByName: new Map(),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const conversion = (query: string, searchHadSemanticReach = false) =>
|
|
50
|
+
convertSearchToRule(parseSearchTokens(query, CONTEXT), {
|
|
51
|
+
searchHadSemanticReach,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
type Responder = (call: HttpCall) => unknown;
|
|
55
|
+
|
|
56
|
+
const previewCounts = (counts: number[]): Responder => {
|
|
57
|
+
let index = 0;
|
|
58
|
+
return (call) => {
|
|
59
|
+
if (call.path.endsWith("/organize/preview")) {
|
|
60
|
+
const count = counts[Math.min(index, counts.length - 1)];
|
|
61
|
+
index += 1;
|
|
62
|
+
return { matchedCount: count, messageIds: [] };
|
|
63
|
+
}
|
|
64
|
+
if (call.path.endsWith("/filters")) {
|
|
65
|
+
return { filterId: "filter-1", name: "R", scope: "Standing" };
|
|
66
|
+
}
|
|
67
|
+
if (call.path.endsWith("/organize") && call.method === "POST") {
|
|
68
|
+
return { organizeJobId: "job-1", state: "Running" };
|
|
69
|
+
}
|
|
70
|
+
if (call.path.endsWith("/organize/job-1")) {
|
|
71
|
+
return {
|
|
72
|
+
organizeJobId: "job-1",
|
|
73
|
+
state: "Complete",
|
|
74
|
+
matchedCount: 3,
|
|
75
|
+
appliedCount: 3,
|
|
76
|
+
failedCount: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {};
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const mount = (
|
|
84
|
+
query: string,
|
|
85
|
+
responder: Responder = previewCounts([12]),
|
|
86
|
+
searchHadSemanticReach = false,
|
|
87
|
+
): DomHarness => {
|
|
88
|
+
http = mockFetch(responder);
|
|
89
|
+
harness = createDomHarness();
|
|
90
|
+
harness.queryClient.setQueryData(
|
|
91
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
|
|
92
|
+
{ items: MAILBOXES },
|
|
93
|
+
);
|
|
94
|
+
harness.renderApp(
|
|
95
|
+
createElement(SearchFilterEditor, {
|
|
96
|
+
accountId: ACCOUNT_ID,
|
|
97
|
+
conversion: conversion(query, searchHadSemanticReach),
|
|
98
|
+
seedCount: 12,
|
|
99
|
+
onClose: () => undefined,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
return harness;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
async function settlePreview(dom: DomHarness): Promise<void> {
|
|
106
|
+
await dom.flush();
|
|
107
|
+
await dom.wait(400);
|
|
108
|
+
await dom.flush();
|
|
109
|
+
await dom.flush();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const primaryButton = (dom: DomHarness, label: string): HTMLButtonElement =>
|
|
113
|
+
dom.byText("button", label) as HTMLButtonElement;
|
|
114
|
+
|
|
115
|
+
describe("SearchFilterEditor — pre-filled conversion", () => {
|
|
116
|
+
it("opens on the converted clauses", () => {
|
|
117
|
+
const dom = mount("from:alerts@github.com pull request");
|
|
118
|
+
assert.match(dom.text(), /alerts@github\.com/);
|
|
119
|
+
assert.match(dom.text(), /pull request/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("seeds the live count from the converted predicate", () => {
|
|
123
|
+
const dom = mount("receipts");
|
|
124
|
+
assert.match(dom.text(), /12 messages match/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does not offer the semantic widen on a search-derived rule", () => {
|
|
128
|
+
const dom = mount("receipts");
|
|
129
|
+
assert.doesNotMatch(dom.text(), /and similar/i);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("SearchFilterEditor — conversion honesty (RFC 038 D5)", () => {
|
|
134
|
+
it("states a folder-scoped search is kept out of the filter", () => {
|
|
135
|
+
const dom = mount("in:archive receipts");
|
|
136
|
+
assert.match(dom.text(), /limited to archive/i);
|
|
137
|
+
assert.match(dom.text(), /any folder/i);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("names the dropped attribute facets", () => {
|
|
141
|
+
const dom = mount("invoice has:attachment is:unread");
|
|
142
|
+
assert.match(dom.text(), /Has attachment/);
|
|
143
|
+
assert.match(dom.text(), /Unread/);
|
|
144
|
+
assert.match(dom.text(), /left out/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("states the dropped semantic reach when the search surfaced similar mail", () => {
|
|
148
|
+
const dom = mount("things like this", previewCounts([12]), true);
|
|
149
|
+
assert.match(dom.text(), /matches these words literally/i);
|
|
150
|
+
assert.match(dom.text(), /similar mail/i);
|
|
151
|
+
// The widen chip is never offered on a search-derived rule.
|
|
152
|
+
assert.doesNotMatch(dom.text(), /and anything similar/i);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("says nothing about semantics when the search had no similar mail", () => {
|
|
156
|
+
const dom = mount("things like this", previewCounts([12]), false);
|
|
157
|
+
assert.doesNotMatch(dom.text(), /similar mail/i);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("SearchFilterEditor — commit gate", () => {
|
|
162
|
+
it("blocks the save until a folder and name are set, then commits the previewed predicate", async () => {
|
|
163
|
+
const dom = mount("from:alerts@github.com receipts");
|
|
164
|
+
|
|
165
|
+
// Standing is the default scope: it needs a folder and a name.
|
|
166
|
+
assert.equal(primaryButton(dom, "Save rule").disabled, true);
|
|
167
|
+
dom.select(dom.byLabel("Destination folder"), "mbx-archive");
|
|
168
|
+
await dom.flush();
|
|
169
|
+
dom.type(dom.byLabel("Rule name"), "GitHub receipts");
|
|
170
|
+
await dom.flush();
|
|
171
|
+
assert.equal(primaryButton(dom, "Save rule").disabled, false);
|
|
172
|
+
|
|
173
|
+
dom.click(primaryButton(dom, "Save rule"));
|
|
174
|
+
await dom.flush();
|
|
175
|
+
|
|
176
|
+
const filters = http?.to("/filters") ?? [];
|
|
177
|
+
assert.equal(filters.length, 1);
|
|
178
|
+
assert.equal(filters[0].body?.scope, "Standing");
|
|
179
|
+
assert.deepEqual(filters[0].body?.literalClauses, [
|
|
180
|
+
{ field: "From", value: "alerts@github.com" },
|
|
181
|
+
{ field: "HasWords", value: "receipts" },
|
|
182
|
+
]);
|
|
183
|
+
assert.match(dom.text(), /Filter saved/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("stales the count on an edit and holds the save until it settles", async () => {
|
|
187
|
+
// The seed count (12) is passed directly; the first network preview is the
|
|
188
|
+
// re-count after the edit, so it returns 40.
|
|
189
|
+
const dom = mount("receipts", previewCounts([40]));
|
|
190
|
+
dom.select(dom.byLabel("Destination folder"), "mbx-archive");
|
|
191
|
+
await dom.flush();
|
|
192
|
+
dom.type(dom.byLabel("Rule name"), "Receipts");
|
|
193
|
+
await dom.flush();
|
|
194
|
+
|
|
195
|
+
dom.click(primaryButton(dom, "Add clause"));
|
|
196
|
+
dom.select(dom.byLabel("Clause field"), "Subject");
|
|
197
|
+
dom.type(dom.byLabel("Clause value"), "paid");
|
|
198
|
+
dom.click(primaryButton(dom, "Add"));
|
|
199
|
+
await dom.flush();
|
|
200
|
+
|
|
201
|
+
// The count is stale and the save is blocked until the re-preview lands.
|
|
202
|
+
assert.match(dom.text(), /recounting/i);
|
|
203
|
+
assert.equal(primaryButton(dom, "Save rule").disabled, true);
|
|
204
|
+
|
|
205
|
+
await settlePreview(dom);
|
|
206
|
+
assert.match(dom.text(), /40 messages match/);
|
|
207
|
+
assert.equal(primaryButton(dom, "Save rule").disabled, false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("runs a one-time apply as a back-apply job when the scope is 'just once'", async () => {
|
|
211
|
+
const dom = mount("receipts");
|
|
212
|
+
dom.select(dom.byLabel("Destination folder"), "mbx-archive");
|
|
213
|
+
await dom.flush();
|
|
214
|
+
const radio = dom.query('input[name="rule-scope"][value="once"]');
|
|
215
|
+
if (!radio) throw new Error("no once scope option");
|
|
216
|
+
dom.click(radio);
|
|
217
|
+
await dom.flush();
|
|
218
|
+
|
|
219
|
+
dom.click(primaryButton(dom, "Apply now"));
|
|
220
|
+
await dom.flush();
|
|
221
|
+
const jobs = (http?.to("/organize") ?? []).filter(
|
|
222
|
+
(call) => call.method === "POST",
|
|
223
|
+
);
|
|
224
|
+
assert.equal(jobs.length, 1);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import {
|
|
3
|
+
type FilterRule,
|
|
4
|
+
FilterRuleEditor,
|
|
5
|
+
type FolderOption,
|
|
6
|
+
SearchConversionNoticeView,
|
|
7
|
+
} from "@remit/ui";
|
|
8
|
+
import { useQuery } from "@tanstack/react-query";
|
|
9
|
+
import { useMemo, useState } from "react";
|
|
10
|
+
import { useCreateFilter } from "@/hooks/useFilters";
|
|
11
|
+
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
12
|
+
import { useRuleEditorState } from "@/hooks/useRuleEditorState";
|
|
13
|
+
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
14
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
15
|
+
import { buildMoveTargets } from "@/lib/move-targets";
|
|
16
|
+
import {
|
|
17
|
+
rulePredicate,
|
|
18
|
+
ruleToDraft,
|
|
19
|
+
SUPPORTED_CLAUSE_FIELDS,
|
|
20
|
+
} from "@/lib/organize/rule-model";
|
|
21
|
+
import {
|
|
22
|
+
buildSearchRule,
|
|
23
|
+
type SearchConversion,
|
|
24
|
+
} from "@/lib/organize/search-to-rule";
|
|
25
|
+
import {
|
|
26
|
+
CommitError,
|
|
27
|
+
FilterSaved,
|
|
28
|
+
JobProgress,
|
|
29
|
+
SavingState,
|
|
30
|
+
} from "./rule-editor-states";
|
|
31
|
+
|
|
32
|
+
interface SearchFilterEditorProps {
|
|
33
|
+
/** The account the filter is created for (an `account:` facet, else the active account). */
|
|
34
|
+
accountId: string;
|
|
35
|
+
/** The converted search — its clauses seed the rule, its drops seed the notice. */
|
|
36
|
+
conversion: SearchConversion;
|
|
37
|
+
/** The converted literal predicate's live count, seeding the editor without a re-fetch. */
|
|
38
|
+
seedCount: number;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The filter-from-search surface as the chip editor (RFC 038 D5). The current
|
|
44
|
+
* search's literal terms and facets arrive already converted to clauses; this
|
|
45
|
+
* opens the shared rule editor pre-filled on them, over the same preview/apply
|
|
46
|
+
* endpoints as Organize. The conversion notice states what the search carried
|
|
47
|
+
* that the filter cannot — a folder scope, non-clause facets, semantic reach.
|
|
48
|
+
*
|
|
49
|
+
* A search-derived rule has no message anchor, so the semantic widen chip is not
|
|
50
|
+
* offered here (its loss, where the deployment could have served it, is the
|
|
51
|
+
* notice's job). The commit gate is the same: the count on screen is the literal
|
|
52
|
+
* set a commit acts on.
|
|
53
|
+
*/
|
|
54
|
+
export function SearchFilterEditor({
|
|
55
|
+
accountId,
|
|
56
|
+
conversion,
|
|
57
|
+
seedCount,
|
|
58
|
+
onClose,
|
|
59
|
+
}: SearchFilterEditorProps) {
|
|
60
|
+
const [initialRule] = useState<FilterRule>(() => buildSearchRule(conversion));
|
|
61
|
+
const { rule, handlers } = useRuleEditorState({ initialRule });
|
|
62
|
+
|
|
63
|
+
const { data: mailboxesData } = useQuery({
|
|
64
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
65
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const folders: FolderOption[] = useMemo(
|
|
69
|
+
() =>
|
|
70
|
+
buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
|
|
71
|
+
id: mailbox.mailboxId,
|
|
72
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
73
|
+
})),
|
|
74
|
+
[mailboxesData?.items],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const preview = useRulePreview(accountId, rulePredicate(rule), seedCount);
|
|
78
|
+
|
|
79
|
+
const organizeJob = useOrganizeJob(accountId);
|
|
80
|
+
const createFilter = useCreateFilter(accountId);
|
|
81
|
+
|
|
82
|
+
const commit = () => {
|
|
83
|
+
const draft = ruleToDraft(rule);
|
|
84
|
+
if (rule.scope === "once") {
|
|
85
|
+
organizeJob.start(draft);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
createFilter.createFilter(
|
|
89
|
+
draft,
|
|
90
|
+
rule.scope === "standing" ? "standing" : "temporary",
|
|
91
|
+
(rule.name ?? "").trim(),
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
|
|
96
|
+
return (
|
|
97
|
+
<JobProgress
|
|
98
|
+
progress={organizeJob.progress}
|
|
99
|
+
isDone={organizeJob.isDone}
|
|
100
|
+
runningLabel="Organizing your mail…"
|
|
101
|
+
onClose={onClose}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (createFilter.isPending) return <SavingState />;
|
|
107
|
+
if (createFilter.isSuccess) return <FilterSaved onClose={onClose} />;
|
|
108
|
+
if (createFilter.isError)
|
|
109
|
+
return <CommitError onRetry={createFilter.reset} onClose={onClose} />;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<FilterRuleEditor
|
|
113
|
+
rule={rule}
|
|
114
|
+
folders={folders}
|
|
115
|
+
preview={preview}
|
|
116
|
+
notice={
|
|
117
|
+
<SearchConversionNoticeView
|
|
118
|
+
notice={{
|
|
119
|
+
scopedOutFolder: conversion.scopedOut?.label,
|
|
120
|
+
droppedFacets: conversion.droppedFacets.map((facet) => facet.label),
|
|
121
|
+
droppedSemantic: conversion.droppedSemantic,
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
}
|
|
125
|
+
semanticAvailable={false}
|
|
126
|
+
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
127
|
+
{...handlers}
|
|
128
|
+
onCommit={commit}
|
|
129
|
+
onCancel={onClose}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Button } from "@remit/ui";
|
|
2
|
+
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
3
|
+
import type { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The non-editing states a filter-rule editor lands in after commit, shared by
|
|
7
|
+
* the Organize and filter-from-search surfaces so a back-apply job, a saved
|
|
8
|
+
* standing rule, and a commit failure read identically wherever the rule was
|
|
9
|
+
* authored (RFC 038 D1).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function JobProgress({
|
|
13
|
+
progress,
|
|
14
|
+
isDone,
|
|
15
|
+
runningLabel,
|
|
16
|
+
onClose,
|
|
17
|
+
}: {
|
|
18
|
+
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
19
|
+
isDone: boolean;
|
|
20
|
+
/** In-flight copy — the caller names what it is organizing. */
|
|
21
|
+
runningLabel: string;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
}) {
|
|
24
|
+
const failed = progress.state === "Failed";
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
27
|
+
{!isDone ? (
|
|
28
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
29
|
+
) : failed ? (
|
|
30
|
+
<span className="text-sm font-semibold text-danger">
|
|
31
|
+
Organize failed
|
|
32
|
+
</span>
|
|
33
|
+
) : (
|
|
34
|
+
<CheckCircle2 className="size-8 text-positive" />
|
|
35
|
+
)}
|
|
36
|
+
|
|
37
|
+
{!isDone && <p className="text-sm font-medium text-fg">{runningLabel}</p>}
|
|
38
|
+
|
|
39
|
+
{isDone && !failed && (
|
|
40
|
+
<div className="text-sm text-fg">
|
|
41
|
+
<p className="font-medium">Done</p>
|
|
42
|
+
<p className="mt-1 text-xs text-fg-muted">
|
|
43
|
+
{progress.appliedCount} of {progress.matchedCount} moved
|
|
44
|
+
{progress.failedCount > 0
|
|
45
|
+
? ` · ${progress.failedCount} failed`
|
|
46
|
+
: ""}
|
|
47
|
+
.
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
|
|
52
|
+
{isDone && failed && (
|
|
53
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
54
|
+
{progress.errorMessage || "Something went wrong. Please try again."}
|
|
55
|
+
</p>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
{isDone && (
|
|
59
|
+
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
60
|
+
Done
|
|
61
|
+
</Button>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function SavingState() {
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
70
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
71
|
+
<p className="text-sm font-medium text-fg">Saving rule…</p>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function FilterSaved({ onClose }: { onClose: () => void }) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
79
|
+
<CheckCircle2 className="size-8 text-positive" />
|
|
80
|
+
<p className="text-sm font-medium text-fg">Filter saved</p>
|
|
81
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
82
|
+
You can see it, and when it expires, under Settings › Filters.
|
|
83
|
+
</p>
|
|
84
|
+
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
85
|
+
Done
|
|
86
|
+
</Button>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function CommitError({
|
|
92
|
+
onRetry,
|
|
93
|
+
onClose,
|
|
94
|
+
}: {
|
|
95
|
+
onRetry: () => void;
|
|
96
|
+
onClose: () => void;
|
|
97
|
+
}) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
100
|
+
<p className="text-sm font-medium text-danger">Couldn't save the rule</p>
|
|
101
|
+
<p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
|
|
102
|
+
<div className="mt-2 flex gap-2">
|
|
103
|
+
<Button variant="primary" onClick={onRetry}>
|
|
104
|
+
Try again
|
|
105
|
+
</Button>
|
|
106
|
+
<Button variant="ghost" onClick={onClose}>
|
|
107
|
+
Not now
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The self-update surface in Settings › Advanced — the only place that talks
|
|
3
|
-
* about updates. Renders nothing when the surface is absent (no manifest URL,
|
|
4
|
-
* not
|
|
3
|
+
* about updates. Renders nothing when the surface is absent (no manifest URL, or
|
|
4
|
+
* not signed in), so there is no entry point at all in that case.
|
|
5
5
|
*/
|
|
6
6
|
import { SelfUpdateConfirmDialog, SelfUpdateSection } from "@remit/ui";
|
|
7
7
|
import { useState } from "react";
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClauseEditState,
|
|
3
|
+
ClauseField,
|
|
4
|
+
FilterRule,
|
|
5
|
+
MatchOperator,
|
|
6
|
+
RuleScope,
|
|
7
|
+
} from "@remit/ui";
|
|
8
|
+
import { useRef, useState } from "react";
|
|
9
|
+
import {
|
|
10
|
+
normalizeClauseValue,
|
|
11
|
+
SUPPORTED_CLAUSE_FIELDS,
|
|
12
|
+
} from "@/lib/organize/rule-model";
|
|
13
|
+
|
|
14
|
+
export interface RuleEditorState {
|
|
15
|
+
rule: FilterRule;
|
|
16
|
+
setRule: (updater: (current: FilterRule) => FilterRule) => void;
|
|
17
|
+
/** The clause-editing and rule-mutation handlers a {@link FilterRuleEditor} binds. */
|
|
18
|
+
handlers: {
|
|
19
|
+
clauseEdit: ClauseEditState | undefined;
|
|
20
|
+
onStartAddClause: () => void;
|
|
21
|
+
onStartEditClause: (clauseId: string) => void;
|
|
22
|
+
onRemoveClause: (clauseId: string) => void;
|
|
23
|
+
onChangeDraftField: (field: ClauseField) => void;
|
|
24
|
+
onChangeDraftValue: (value: string) => void;
|
|
25
|
+
onSubmitClause: () => void;
|
|
26
|
+
onCancelClause: () => void;
|
|
27
|
+
onAddWiden: () => void;
|
|
28
|
+
onRemoveWiden: () => void;
|
|
29
|
+
onChangeMatchOperator: (matchOperator: MatchOperator) => void;
|
|
30
|
+
onChangeMove: (mailboxId: string) => void;
|
|
31
|
+
onChangeScope: (scope: RuleScope) => void;
|
|
32
|
+
onChangeName: (name: string) => void;
|
|
33
|
+
onChangeUntil: (until: string) => void;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RuleEditorStateInput {
|
|
38
|
+
/** The rule the editor opens on. */
|
|
39
|
+
initialRule: FilterRule;
|
|
40
|
+
/**
|
|
41
|
+
* How many anchors a widen the user adds inline should carry. A search-seeded
|
|
42
|
+
* rule has no message anchor, so its editor never offers the widen and this is
|
|
43
|
+
* unused; the Organize editor passes its selection size.
|
|
44
|
+
*/
|
|
45
|
+
widenAnchorCount?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The clause-editing and rule-mutation state a filter-rule editor owns, shared by
|
|
50
|
+
* every entry point onto the chip editor (RFC 038 D1) so the Organize and
|
|
51
|
+
* search-derived surfaces edit the rule identically. Preview and commit stay with
|
|
52
|
+
* the caller — they differ by entry point (an anchored widen count and back-apply
|
|
53
|
+
* job for Organize, a literal count for a search-derived rule).
|
|
54
|
+
*/
|
|
55
|
+
export const useRuleEditorState = ({
|
|
56
|
+
initialRule,
|
|
57
|
+
widenAnchorCount = 1,
|
|
58
|
+
}: RuleEditorStateInput): RuleEditorState => {
|
|
59
|
+
const [rule, setRuleState] = useState<FilterRule>(initialRule);
|
|
60
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
61
|
+
const nextClauseId = useRef(0);
|
|
62
|
+
|
|
63
|
+
const setRule = (updater: (current: FilterRule) => FilterRule) =>
|
|
64
|
+
setRuleState(updater);
|
|
65
|
+
|
|
66
|
+
const startAddClause = () =>
|
|
67
|
+
setClauseEdit({
|
|
68
|
+
mode: "add",
|
|
69
|
+
draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const startEditClause = (clauseId: string) => {
|
|
73
|
+
const clause = rule.clauses.find((entry) => entry.id === clauseId);
|
|
74
|
+
if (!clause) return;
|
|
75
|
+
setClauseEdit({
|
|
76
|
+
mode: "edit",
|
|
77
|
+
clauseId,
|
|
78
|
+
draft: { field: clause.field, value: clause.value },
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const changeDraftField = (field: ClauseField) =>
|
|
83
|
+
setClauseEdit((edit) =>
|
|
84
|
+
edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const changeDraftValue = (value: string) =>
|
|
88
|
+
setClauseEdit((edit) =>
|
|
89
|
+
edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const submitClause = () => {
|
|
93
|
+
if (!clauseEdit) return;
|
|
94
|
+
const field = clauseEdit.draft.field;
|
|
95
|
+
const value = normalizeClauseValue(field, clauseEdit.draft.value);
|
|
96
|
+
if (value === "") return;
|
|
97
|
+
setRuleState((current) => {
|
|
98
|
+
if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
|
|
99
|
+
return {
|
|
100
|
+
...current,
|
|
101
|
+
clauses: current.clauses.map((clause) =>
|
|
102
|
+
clause.id === clauseEdit.clauseId
|
|
103
|
+
? { id: clause.id, field, value }
|
|
104
|
+
: clause,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
nextClauseId.current += 1;
|
|
109
|
+
return {
|
|
110
|
+
...current,
|
|
111
|
+
clauses: [
|
|
112
|
+
...current.clauses,
|
|
113
|
+
{ id: `clause-${nextClauseId.current}`, field, value },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
setClauseEdit(undefined);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const removeClause = (clauseId: string) =>
|
|
121
|
+
setRuleState((current) => ({
|
|
122
|
+
...current,
|
|
123
|
+
clauses: current.clauses.filter((clause) => clause.id !== clauseId),
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const addWiden = () =>
|
|
127
|
+
setRuleState((current) => ({
|
|
128
|
+
...current,
|
|
129
|
+
widen: { anchorCount: Math.max(widenAnchorCount, 1) },
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const removeWiden = () =>
|
|
133
|
+
setRuleState((current) => ({ ...current, widen: undefined }));
|
|
134
|
+
|
|
135
|
+
const changeMatchOperator = (matchOperator: MatchOperator) =>
|
|
136
|
+
setRuleState((current) => ({ ...current, matchOperator }));
|
|
137
|
+
|
|
138
|
+
const changeMove = (mailboxId: string) =>
|
|
139
|
+
setRuleState((current) => ({
|
|
140
|
+
...current,
|
|
141
|
+
moveMailboxId: mailboxId || undefined,
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const changeScope = (scope: RuleScope) =>
|
|
145
|
+
setRuleState((current) => ({ ...current, scope }));
|
|
146
|
+
|
|
147
|
+
const changeName = (name: string) =>
|
|
148
|
+
setRuleState((current) => ({ ...current, name }));
|
|
149
|
+
|
|
150
|
+
const changeUntil = (until: string) =>
|
|
151
|
+
setRuleState((current) => ({ ...current, until }));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
rule,
|
|
155
|
+
setRule,
|
|
156
|
+
handlers: {
|
|
157
|
+
clauseEdit,
|
|
158
|
+
onStartAddClause: startAddClause,
|
|
159
|
+
onStartEditClause: startEditClause,
|
|
160
|
+
onRemoveClause: removeClause,
|
|
161
|
+
onChangeDraftField: changeDraftField,
|
|
162
|
+
onChangeDraftValue: changeDraftValue,
|
|
163
|
+
onSubmitClause: submitClause,
|
|
164
|
+
onCancelClause: () => setClauseEdit(undefined),
|
|
165
|
+
onAddWiden: addWiden,
|
|
166
|
+
onRemoveWiden: removeWiden,
|
|
167
|
+
onChangeMatchOperator: changeMatchOperator,
|
|
168
|
+
onChangeMove: changeMove,
|
|
169
|
+
onChangeScope: changeScope,
|
|
170
|
+
onChangeName: changeName,
|
|
171
|
+
onChangeUntil: changeUntil,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
};
|