@remit/web-client 0.0.93 → 0.0.94

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.
@@ -37,6 +37,33 @@ export const wizardStepFromParam = (value: unknown): StepId | undefined => {
37
37
  return parsed.success ? parsed.data : undefined;
38
38
  };
39
39
 
40
+ /**
41
+ * Which affordance opened the wizard. Absent is the selection bar; `search` is
42
+ * the make-filter affordance in the search results header, whose clauses come
43
+ * from the query rather than from ticked rows (#477 1.8).
44
+ *
45
+ * It rides the URL beside the step for the same reason the step does: a
46
+ * reloaded document has to know which walk it is in the middle of, and the
47
+ * query it was seeded from is in the URL already. It decides two things and no
48
+ * more — which step the wizard opens on, and whether the query seeds the
49
+ * clauses (#477 3.4). One host answers either way.
50
+ */
51
+ const wizardEntry = z.literal("search");
52
+
53
+ export type WizardEntry = z.infer<typeof wizardEntry>;
54
+
55
+ export const wizardEntryFromParam = (
56
+ value: unknown,
57
+ ): WizardEntry | undefined => {
58
+ const parsed = wizardEntry.safeParse(value);
59
+ return parsed.success ? parsed.data : undefined;
60
+ };
61
+
62
+ export const wizardEntryValue = z.unknown().transform(wizardEntryFromParam);
63
+
64
+ export const useWizardEntryValue = (): WizardEntry | undefined =>
65
+ useSearch({ from: "/mail", select: (search) => search.wizardFrom });
66
+
40
67
  /**
41
68
  * The route's `wizard` field. A value the wizard cannot be on reads as no step
42
69
  * rather than as a validation failure, so a truncated or hand-typed link lands
@@ -54,14 +81,21 @@ export const useWizardStepValue = (): StepId | undefined =>
54
81
 
55
82
  /**
56
83
  * Opens the wizard on a step, from a surface that does not drive it — a verb on
57
- * the selection bar. The push is the wizard's first owned entry, so the back
58
- * that leaves it lands on the list with the selection still ticked.
84
+ * the selection bar, or the make-filter affordance on a search. The push is the
85
+ * wizard's first owned entry, so the back that leaves it lands on the list with
86
+ * the selection still ticked.
59
87
  */
60
- export const useOpenWizard = (): ((step: StepId) => void) => {
88
+ export const useOpenWizard = (): ((
89
+ step: StepId,
90
+ entry?: WizardEntry,
91
+ ) => void) => {
61
92
  const navigate = useNavigate();
62
93
  return useCallback(
63
- (step: StepId) => {
64
- navigate({ to: ".", search: (prev) => ({ ...prev, wizard: step }) });
94
+ (step: StepId, entry?: WizardEntry) => {
95
+ navigate({
96
+ to: ".",
97
+ search: (prev) => ({ ...prev, wizard: step, wizardFrom: entry }),
98
+ });
65
99
  },
66
100
  [navigate],
67
101
  );
@@ -78,10 +112,15 @@ export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
78
112
  const router = useRouter();
79
113
  const navigate = useNavigate();
80
114
  const step = useWizardStepValue();
115
+ const entry = useWizardEntryValue();
81
116
  // Whether this document loaded already holding a step, which is the one
82
117
  // entrance that leaves the wizard unrooted. A step the app itself pushed
83
118
  // arrives rooted, so re-rooting it would duplicate the entry underneath it.
84
119
  const loadedHoldingStep = useRef(step !== undefined);
120
+ // The entry that step was reached by, so the root the wizard is put back on
121
+ // carries neither the step nor the affordance that opened it, and the entry
122
+ // pushed over it carries both.
123
+ const loadedEntry = useRef(entry);
85
124
  const pushedTo = useRef<StepId | undefined>(undefined);
86
125
 
87
126
  // Two taps on Continue land before the URL settles, and both would push the
@@ -94,15 +133,24 @@ export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
94
133
  useEffect(() => {
95
134
  if (!loadedHoldingStep.current) return;
96
135
  loadedHoldingStep.current = false;
136
+ const openingEntry = loadedEntry.current;
97
137
  void (async () => {
98
138
  await navigate({
99
139
  to: ".",
100
- search: (prev) => ({ ...prev, wizard: undefined }),
140
+ search: (prev) => ({
141
+ ...prev,
142
+ wizard: undefined,
143
+ wizardFrom: undefined,
144
+ }),
101
145
  replace: true,
102
146
  });
103
147
  await navigate({
104
148
  to: ".",
105
- search: (prev) => ({ ...prev, wizard: openingStep }),
149
+ search: (prev) => ({
150
+ ...prev,
151
+ wizard: openingStep,
152
+ wizardFrom: openingEntry,
153
+ }),
106
154
  });
107
155
  })();
108
156
  }, [openingStep, navigate]);
@@ -43,7 +43,7 @@ import {
43
43
  searchInputForView,
44
44
  shouldMirrorQuery,
45
45
  } from "@/lib/search-view";
46
- import { wizardStepValue } from "@/lib/wizard-history";
46
+ import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
47
47
  import "@/lib/client";
48
48
 
49
49
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -57,6 +57,8 @@ const mailSearchSchema = z.object({
57
57
  // The selection wizard's step (#477 clause 1.6). The router owns history, so
58
58
  // the step is a validated search param rather than a raw pushState entry.
59
59
  wizard: wizardStepValue,
60
+ // Which affordance opened it, so a reload lands back on the walk it was in.
61
+ wizardFrom: wizardEntryValue,
60
62
  });
61
63
 
62
64
  export const Route = createFileRoute("/mail")({
@@ -1,47 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
- import React, { createElement } from "react";
5
- import { renderToString } from "react-dom/server";
6
- import { parseSearchTokens } from "@/lib/search-tokens";
7
- import { SearchFilterDialog } from "./SearchFilterDialog";
8
-
9
- // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
10
- // runtime, which references a global `React`.
11
- (globalThis as { React?: typeof React }).React = React;
12
-
13
- const render = (open: boolean, query = "from:receipts@stripe.com") =>
14
- renderToString(
15
- createElement(
16
- QueryClientProvider,
17
- { client: new QueryClient() },
18
- createElement(SearchFilterDialog, {
19
- open,
20
- accountId: "acc-1",
21
- parsed: parseSearchTokens(query, {}),
22
- searchHadSemanticReach: true,
23
- onClose: () => undefined,
24
- }),
25
- ) as never,
26
- );
27
-
28
- describe("SearchFilterDialog", () => {
29
- it("renders nothing when closed", () => {
30
- assert.equal(render(false), "");
31
- });
32
-
33
- it("shows the conversion step while the seed preview is in flight", () => {
34
- assert.match(render(true), /Turning your search into a filter/);
35
- });
36
-
37
- it("opens the editor for a free-text search instead of failing to count it", () => {
38
- // The free text converts to a `HasWords` clause, which the vector-free
39
- // matcher refuses outright. Asking for a count is a 500, so nothing is
40
- // asked: the editor opens and the count region carries the reason.
41
- const html = render(true, "receipts");
42
- assert.doesNotMatch(html, /Turning your search into a filter/);
43
- assert.doesNotMatch(html, /Couldn't build the filter/);
44
- assert.match(html, /These chips are the whole rule/);
45
- assert.match(html, /reads message bodies/);
46
- });
47
- });
@@ -1,90 +0,0 @@
1
- import { Button, buildSearchRule, Dialog } from "@remit/ui";
2
- import { Loader2 } from "lucide-react";
3
- import { useMemo } from "react";
4
- import { useSearchFilterSeed } from "@/hooks/useSearchFilterSeed";
5
- import { rulePredicate } from "@/lib/organize/rule-model";
6
- import { convertSearchToRule } from "@/lib/organize/search-to-rule";
7
- import type { ParsedSearchQuery } from "@/lib/search-tokens";
8
- import { SearchFilterEditor } from "./SearchFilterEditor";
9
-
10
- interface SearchFilterDialogProps {
11
- open: boolean;
12
- /** The account the filter is created for (an `account:` facet, else the active account). */
13
- accountId: string;
14
- /** The current search, already split into free text and facets. */
15
- parsed: ParsedSearchQuery;
16
- /**
17
- * The search surfaced semantically-similar mail (a non-empty "Related"
18
- * section). The literal filter cannot reproduce that reach, so the conversion
19
- * states it — read from the search's own results, never probed (RFC 038 D5).
20
- */
21
- searchHadSemanticReach: boolean;
22
- onClose: () => void;
23
- }
24
-
25
- /**
26
- * "Make this a filter" (RFC 038 D5). Converts the current search to clauses and
27
- * hands off to the shared chip editor pre-filled. No new endpoint: the seed
28
- * count rides `POST /organize/preview` and the commit drives the existing filter
29
- * CRUD.
30
- */
31
- export function SearchFilterDialog({
32
- open,
33
- accountId,
34
- parsed,
35
- searchHadSemanticReach,
36
- onClose,
37
- }: SearchFilterDialogProps) {
38
- const conversion = useMemo(
39
- () => convertSearchToRule(parsed, { searchHadSemanticReach }),
40
- [parsed, searchHadSemanticReach],
41
- );
42
- const literalPredicate = useMemo(
43
- () => rulePredicate(buildSearchRule(conversion)),
44
- [conversion],
45
- );
46
-
47
- const { seedCount, isPending, isError, retry } = useSearchFilterSeed(
48
- open ? accountId : undefined,
49
- literalPredicate,
50
- );
51
- // A search kept as a `HasWords` clause has no seed count and never will; the
52
- // editor opens on the uncountable reason rather than a dead end.
53
-
54
- if (!open) return null;
55
-
56
- return (
57
- <Dialog open={open} onClose={onClose} title="Filter rule">
58
- {isError ? (
59
- <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
60
- <p className="text-sm font-medium text-danger">
61
- Couldn't build the filter
62
- </p>
63
- <p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
64
- <div className="mt-2 flex gap-2">
65
- <Button variant="primary" onClick={retry}>
66
- Try again
67
- </Button>
68
- <Button variant="ghost" onClick={onClose}>
69
- Not now
70
- </Button>
71
- </div>
72
- </div>
73
- ) : isPending ? (
74
- <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
75
- <Loader2 className="size-8 animate-spin text-accent-2" />
76
- <p className="text-sm font-medium text-fg">
77
- Turning your search into a filter…
78
- </p>
79
- </div>
80
- ) : (
81
- <SearchFilterEditor
82
- accountId={accountId}
83
- conversion={conversion}
84
- seedCount={seedCount}
85
- onClose={onClose}
86
- />
87
- )}
88
- </Dialog>
89
- );
90
- }
@@ -1,260 +0,0 @@
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("from:receipts@stripe.com");
124
- assert.match(dom.text(), /12 messages match/);
125
- });
126
-
127
- it("never asks the matcher to count a body-text rule it cannot evaluate", async () => {
128
- // A free-text search converts to a `HasWords` clause and carries no anchor,
129
- // so the vector-free matcher rejects it outright (organize.ts
130
- // `assertNoBodyContentClause`). The count is unavailable and says so; the
131
- // request is never sent.
132
- const dom = mount("receipts");
133
- await settlePreview(dom);
134
- assert.match(dom.text(), /can't count matches/i);
135
- assert.equal((http?.to("/organize/preview") ?? []).length, 0);
136
- });
137
-
138
- it("does not offer the semantic widen on a search-derived rule", () => {
139
- const dom = mount("receipts");
140
- assert.doesNotMatch(dom.text(), /and similar/i);
141
- });
142
- });
143
-
144
- describe("SearchFilterEditor — conversion honesty (RFC 038 D5)", () => {
145
- it("states a folder-scoped search is kept out of the filter", () => {
146
- const dom = mount("in:archive receipts");
147
- assert.match(dom.text(), /limited to archive/i);
148
- assert.match(dom.text(), /any folder/i);
149
- });
150
-
151
- it("names the dropped attribute facets", () => {
152
- const dom = mount("invoice has:attachment is:unread");
153
- assert.match(dom.text(), /Has attachment/);
154
- assert.match(dom.text(), /Unread/);
155
- assert.match(dom.text(), /left out/);
156
- });
157
-
158
- it("states the dropped semantic reach when the search surfaced similar mail", () => {
159
- const dom = mount("things like this", previewCounts([12]), true);
160
- assert.match(dom.text(), /matches these words literally/i);
161
- assert.match(dom.text(), /similar mail/i);
162
- // The widen chip is never offered on a search-derived rule.
163
- assert.doesNotMatch(dom.text(), /and anything similar/i);
164
- });
165
-
166
- it("says nothing about semantics when the search had no similar mail", () => {
167
- const dom = mount("things like this", previewCounts([12]), false);
168
- assert.doesNotMatch(dom.text(), /similar mail/i);
169
- });
170
- });
171
-
172
- describe("SearchFilterEditor — commit gate", () => {
173
- it("blocks the save until a folder and name are set, then commits the previewed predicate", async () => {
174
- const dom = mount("from:alerts@github.com receipts");
175
-
176
- // Standing is the default scope: it needs a folder and a name.
177
- assert.equal(primaryButton(dom, "Save rule").disabled, true);
178
- dom.select(dom.byLabel("Destination folder"), "mbx-archive");
179
- await dom.flush();
180
- dom.type(dom.byLabel("Rule name"), "GitHub receipts");
181
- await dom.flush();
182
- assert.equal(primaryButton(dom, "Save rule").disabled, false);
183
-
184
- dom.click(primaryButton(dom, "Save rule"));
185
- await dom.flush();
186
-
187
- const filters = http?.to("/filters") ?? [];
188
- assert.equal(filters.length, 1);
189
- assert.equal(filters[0].body?.scope, "Standing");
190
- assert.deepEqual(filters[0].body?.literalClauses, [
191
- { field: "From", value: "alerts@github.com" },
192
- { field: "HasWords", value: "receipts" },
193
- ]);
194
- assert.match(dom.text(), /Filter saved/);
195
- });
196
-
197
- it("stales the count on an edit and holds the save until it settles", async () => {
198
- // The seed count (12) is passed directly; the first network preview is the
199
- // re-count after the edit, so it returns 40.
200
- const dom = mount("from:receipts@stripe.com", previewCounts([40]));
201
- dom.select(dom.byLabel("Destination folder"), "mbx-archive");
202
- await dom.flush();
203
- dom.type(dom.byLabel("Rule name"), "Receipts");
204
- await dom.flush();
205
-
206
- dom.click(primaryButton(dom, "Add clause"));
207
- dom.select(dom.byLabel("Clause field"), "Subject");
208
- dom.type(dom.byLabel("Clause value"), "paid");
209
- dom.click(primaryButton(dom, "Add"));
210
- await dom.flush();
211
-
212
- // The count is stale and the save is blocked until the re-preview lands.
213
- assert.match(dom.text(), /recounting/i);
214
- assert.equal(primaryButton(dom, "Save rule").disabled, true);
215
-
216
- await settlePreview(dom);
217
- assert.match(dom.text(), /40 messages match/);
218
- assert.equal(primaryButton(dom, "Save rule").disabled, false);
219
- });
220
-
221
- it("runs a one-time apply as a back-apply job when the scope is 'just once'", async () => {
222
- const dom = mount("from:receipts@stripe.com");
223
- dom.select(dom.byLabel("Destination folder"), "mbx-archive");
224
- await dom.flush();
225
- const radio = dom.query('input[name="rule-scope"][value="once"]');
226
- if (!radio) throw new Error("no once scope option");
227
- dom.click(radio);
228
- await dom.flush();
229
-
230
- dom.click(primaryButton(dom, "Apply now"));
231
- await dom.flush();
232
- const jobs = (http?.to("/organize") ?? []).filter(
233
- (call) => call.method === "POST",
234
- );
235
- assert.equal(jobs.length, 1);
236
- });
237
-
238
- it("holds the one-time apply for a body-text rule, offering the saved rule instead", async () => {
239
- // The one-time apply runs the same vector-free matcher the count does. A
240
- // `HasWords` clause has no reader there, so the apply is held with the
241
- // reason stated — saved as a standing rule the same clause works, because
242
- // the index-time matcher reads the whole body.
243
- const dom = mount("receipts");
244
- dom.select(dom.byLabel("Destination folder"), "mbx-archive");
245
- await dom.flush();
246
- const radio = dom.query('input[name="rule-scope"][value="once"]');
247
- if (!radio) throw new Error("no once scope option");
248
- dom.click(radio);
249
- await dom.flush();
250
-
251
- assert.equal(primaryButton(dom, "Apply now").disabled, true);
252
- assert.match(dom.text(), /can't read message bodies/i);
253
- assert.equal(
254
- (http?.calls ?? []).filter(
255
- (call) => call.path.endsWith("/organize") && call.method === "POST",
256
- ).length,
257
- 0,
258
- );
259
- });
260
- });
@@ -1,140 +0,0 @@
1
- import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import {
3
- buildSearchRule,
4
- type FilterRule,
5
- FilterRuleEditor,
6
- type FolderOption,
7
- type SearchConversion,
8
- SearchConversionNoticeView,
9
- } from "@remit/ui";
10
- import { useQuery } from "@tanstack/react-query";
11
- import { useMemo, useState } from "react";
12
- import { useCreateFilter } from "@/hooks/useFilters";
13
- import { useOrganizeJob } from "@/hooks/useOrganizeJob";
14
- import { useRuleEditorState } from "@/hooks/useRuleEditorState";
15
- import { useRulePreview } from "@/hooks/useRulePreview";
16
- import { getMailboxDisplayName } from "@/lib/folder-roles";
17
- import { buildMoveTargets } from "@/lib/move-targets";
18
- import {
19
- rulePredicate,
20
- ruleToDraft,
21
- SUPPORTED_CLAUSE_FIELDS,
22
- } from "@/lib/organize/rule-model";
23
- import {
24
- CommitError,
25
- FilterSaved,
26
- JobProgress,
27
- SavingState,
28
- } from "./rule-editor-states";
29
-
30
- interface SearchFilterEditorProps {
31
- /** The account the filter is created for (an `account:` facet, else the active account). */
32
- accountId: string;
33
- /** The converted search — its clauses seed the rule, its drops seed the notice. */
34
- conversion: SearchConversion;
35
- /**
36
- * The converted literal predicate's live count, seeding the editor without a
37
- * re-fetch. Absent when the predicate is one the vector-free matcher refuses —
38
- * a `HasWords` clause from the search's free text — in which case the count
39
- * region says so and the one-time apply is held; saving it as a standing rule
40
- * still works, since the index-time matcher does read message bodies.
41
- */
42
- seedCount?: number;
43
- onClose: () => void;
44
- }
45
-
46
- /**
47
- * The filter-from-search surface as the chip editor (RFC 038 D5). The current
48
- * search's literal terms and facets arrive already converted to clauses; this
49
- * opens the shared rule editor pre-filled on them, over the same preview/apply
50
- * endpoints as Organize. The conversion notice states what the search carried
51
- * that the filter cannot — a folder scope, non-clause facets, semantic reach.
52
- *
53
- * A search-derived rule has no message anchor, so the semantic widen chip is not
54
- * offered here (its loss, where the deployment could have served it, is the
55
- * notice's job). The commit gate is the same: the count on screen is the literal
56
- * set a commit acts on.
57
- */
58
- export function SearchFilterEditor({
59
- accountId,
60
- conversion,
61
- seedCount,
62
- onClose,
63
- }: SearchFilterEditorProps) {
64
- const [initialRule] = useState<FilterRule>(() => buildSearchRule(conversion));
65
- const { rule, handlers } = useRuleEditorState({ initialRule });
66
-
67
- const { data: mailboxesData } = useQuery({
68
- ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
69
- staleTime: Number.POSITIVE_INFINITY,
70
- });
71
-
72
- const folders: FolderOption[] = useMemo(
73
- () =>
74
- buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
75
- id: mailbox.mailboxId,
76
- label: getMailboxDisplayName(mailbox.fullPath),
77
- })),
78
- [mailboxesData?.items],
79
- );
80
-
81
- const { count: preview } = useRulePreview(
82
- accountId,
83
- rulePredicate(rule),
84
- seedCount,
85
- );
86
-
87
- const organizeJob = useOrganizeJob(accountId);
88
- const createFilter = useCreateFilter(accountId);
89
-
90
- const commit = () => {
91
- const draft = ruleToDraft(rule);
92
- if (rule.scope === "once") {
93
- organizeJob.start(draft);
94
- return;
95
- }
96
- createFilter.createFilter(
97
- draft,
98
- rule.scope === "standing" ? "standing" : "temporary",
99
- (rule.name ?? "").trim(),
100
- );
101
- };
102
-
103
- if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
104
- return (
105
- <JobProgress
106
- progress={organizeJob.progress}
107
- isDone={organizeJob.isDone}
108
- runningLabel="Organizing your mail…"
109
- onClose={onClose}
110
- />
111
- );
112
- }
113
-
114
- if (createFilter.isPending) return <SavingState />;
115
- if (createFilter.isSuccess) return <FilterSaved onClose={onClose} />;
116
- if (createFilter.isError)
117
- return <CommitError onRetry={createFilter.reset} onClose={onClose} />;
118
-
119
- return (
120
- <FilterRuleEditor
121
- rule={rule}
122
- folders={folders}
123
- preview={preview}
124
- notice={
125
- <SearchConversionNoticeView
126
- notice={{
127
- scopedOutFolder: conversion.scopedOut?.label,
128
- droppedFacets: conversion.droppedFacets.map((facet) => facet.label),
129
- droppedSemantic: conversion.droppedSemantic,
130
- }}
131
- />
132
- }
133
- semanticAvailable={false}
134
- clauseFields={SUPPORTED_CLAUSE_FIELDS}
135
- {...handlers}
136
- onCommit={commit}
137
- onCancel={onClose}
138
- />
139
- );
140
- }