@remit/web-client 0.0.92 → 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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/DailyBrief.selection.test.ts +6 -2
  3. package/src/components/mail/DailyBrief.tsx +57 -112
  4. package/src/components/mail/FlaggedList.tsx +42 -0
  5. package/src/components/mail/MailListHeader.tsx +41 -39
  6. package/src/components/mail/MessageList.selection.test.ts +10 -5
  7. package/src/components/mail/MessageList.tsx +78 -82
  8. package/src/components/mail/SelectionWizardHost.tsx +721 -64
  9. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  10. package/src/components/mail/make-filter-entry.test.ts +63 -0
  11. package/src/components/settings/FilterEditor.tsx +1 -1
  12. package/src/hooks/useCreateMailbox.ts +16 -4
  13. package/src/hooks/useFilters.ts +30 -1
  14. package/src/hooks/useMatchSample.ts +63 -0
  15. package/src/hooks/useRulePreview.ts +24 -5
  16. package/src/lib/list-header-chrome.ts +10 -0
  17. package/src/lib/mail-context.ts +0 -9
  18. package/src/lib/organize/organize-model.test.ts +110 -0
  19. package/src/lib/organize/organize-model.ts +69 -0
  20. package/src/lib/organize/rule-model.ts +2 -4
  21. package/src/lib/wizard-history.test.ts +31 -0
  22. package/src/lib/wizard-history.ts +65 -2
  23. package/src/routes/mail.tsx +3 -8
  24. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  25. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  26. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  27. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  28. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  29. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  30. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  31. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  32. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  33. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
  34. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  35. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  36. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  37. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  38. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  39. package/src/hooks/useRuleEditorState.ts +0 -182
  40. package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
  41. package/src/hooks/useSearchFilterSeed.ts +0 -79
  42. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  43. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -1,285 +0,0 @@
1
- import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import {
3
- type FilterRule,
4
- FilterRuleEditor,
5
- type FolderOption,
6
- type LabelOption,
7
- type RuleMatchMode,
8
- type RuleScope,
9
- } from "@remit/ui";
10
- import { useQuery } from "@tanstack/react-query";
11
- import { useEffect, useMemo, useRef, useState } from "react";
12
- import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
13
- import { useCreateMailbox } from "@/hooks/useCreateMailbox";
14
- import { useCreateFilter } from "@/hooks/useFilters";
15
- import { useCreateLabel, useLabelList } from "@/hooks/useLabels";
16
- import { useOrganizeJob } from "@/hooks/useOrganizeJob";
17
- import { useRuleEditorState } from "@/hooks/useRuleEditorState";
18
- import { useRulePreview } from "@/hooks/useRulePreview";
19
- import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
20
- import { getMailboxDisplayName } from "@/lib/folder-roles";
21
- import { buildMoveTargets } from "@/lib/move-targets";
22
- import {
23
- canBackApplyDraft,
24
- type OrganizeDraft,
25
- } from "@/lib/organize/organize-model";
26
- import {
27
- buildInitialRule,
28
- defaultMatchMode,
29
- matchersForMode,
30
- rulePredicate,
31
- ruleToDraft,
32
- SUPPORTED_CLAUSE_FIELDS,
33
- } from "@/lib/organize/rule-model";
34
- import {
35
- BackApplyError,
36
- CommitError,
37
- FilterSaved,
38
- JobProgress,
39
- SavingState,
40
- } from "./rule-editor-states";
41
-
42
- interface OrganizeRuleEditorProps {
43
- accountId: string;
44
- selectedMessageIds: string[];
45
- /** The widen probe's matched total — seeds the live count without a re-fetch. */
46
- seedCount: number;
47
- /**
48
- * The deployment ships no vector pipeline, so the widen cannot run. The rule
49
- * opens on the sender-fallback `From` chips instead of the anchor.
50
- */
51
- semanticUnavailable?: boolean;
52
- /** Distinct sender addresses, for the fallback clauses and the progress copy. */
53
- senders?: string[];
54
- /**
55
- * The mode the editor opens on. Omitted, it opens on the semantic widen
56
- * wherever the deployment can run it — a caller passes `properties` only to
57
- * enter the flow on a rule built from the selection's own properties.
58
- */
59
- seedMatchMode?: RuleMatchMode;
60
- /** A folder a "Something else" shortcut pre-picked. */
61
- seedMailboxId?: string;
62
- /** A scope a "Something else" shortcut pre-picked. */
63
- seedScope?: RuleScope;
64
- onClose: () => void;
65
- }
66
-
67
- /**
68
- * The Organize surface as the chip editor (RFC 038 D1). The rule is rendered and
69
- * edited over the existing preview/apply endpoints: clause chips, a
70
- * match-operator toggle, a move action, and a scope that maps one-time apply to
71
- * a back-apply job and standing/until to a `Filter`. Creating a filter also runs
72
- * the back-apply once, so the rule reaches the mail already in the mailbox and
73
- * not only the mail that arrives next. The count is live and the
74
- * commit gate holds apply until it settles, so the set the editor shows is the
75
- * set a commit acts on. Rendered inside the desktop dialog and the mobile sheet
76
- * alike, so the two cannot drift.
77
- */
78
- export function OrganizeRuleEditor({
79
- accountId,
80
- selectedMessageIds,
81
- seedCount,
82
- semanticUnavailable = false,
83
- senders = [],
84
- seedMatchMode,
85
- seedMailboxId,
86
- seedScope,
87
- onClose,
88
- }: OrganizeRuleEditorProps) {
89
- const anchorMessageId = selectedMessageIds[0];
90
- const senderFallback = semanticUnavailable && senders.length > 0;
91
- const subjects = useSelectedSubjects(selectedMessageIds);
92
-
93
- // Semantic stays the default wherever the deployment can serve it; the
94
- // properties mode is the third way in, not a replacement (RFC 038 D2).
95
- const [matchMode, setMatchMode] = useState<RuleMatchMode>(
96
- () => seedMatchMode ?? defaultMatchMode(semanticUnavailable),
97
- );
98
-
99
- const [initialRule] = useState<FilterRule>(() =>
100
- buildInitialRule({
101
- anchorMessageId,
102
- semanticUnavailable,
103
- matchMode,
104
- senders,
105
- subjects,
106
- selectionCount: selectedMessageIds.length,
107
- seedMailboxId,
108
- seedScope,
109
- }),
110
- );
111
- const { rule, setRule, handlers } = useRuleEditorState({
112
- initialRule,
113
- widenAnchorCount: selectedMessageIds.length,
114
- });
115
-
116
- // Switching mode rebuilds only what the rule matches on — the derived chips
117
- // and the widen. The destination, label, scope, name, and any clause the user
118
- // typed themselves survive, so the choice stays reversible.
119
- const changeMatchMode = (next: RuleMatchMode) => {
120
- if (next === matchMode) return;
121
- setMatchMode(next);
122
- setRule((current) => ({
123
- ...current,
124
- ...matchersForMode(next, {
125
- anchorMessageId,
126
- senders,
127
- subjects,
128
- selectionCount: selectedMessageIds.length,
129
- keepClauses: current.clauses.filter((clause) => !clause.derived),
130
- currentOperator: current.matchOperator,
131
- }),
132
- }));
133
- };
134
-
135
- // The selection's own senders are the likeliest values for an address clause
136
- // and are already in hand, so they lead the list before anything is typed.
137
- const clauseSuggestions = useClauseSuggestions(
138
- handlers.clauseEdit?.draft.field,
139
- handlers.clauseEdit?.draft.value ?? "",
140
- senders,
141
- );
142
-
143
- const { data: mailboxesData } = useQuery({
144
- ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
145
- staleTime: Number.POSITIVE_INFINITY,
146
- });
147
-
148
- const folders: FolderOption[] = useMemo(
149
- () =>
150
- buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
151
- id: mailbox.mailboxId,
152
- label: getMailboxDisplayName(mailbox.fullPath),
153
- })),
154
- [mailboxesData?.items],
155
- );
156
-
157
- const { labels: labelItems } = useLabelList(accountId);
158
- const labels: LabelOption[] = useMemo(
159
- () =>
160
- labelItems.map((label) => ({
161
- id: label.labelId,
162
- name: label.name,
163
- color: label.color,
164
- })),
165
- [labelItems],
166
- );
167
- const { createLabel } = useCreateLabel(accountId);
168
- const onCreateLabel = async (name: string): Promise<LabelOption> => {
169
- const label = await createLabel(name);
170
- return { id: label.labelId, name: label.name, color: label.color };
171
- };
172
-
173
- const preview = useRulePreview(
174
- accountId,
175
- rulePredicate(rule, anchorMessageId),
176
- seedCount,
177
- );
178
-
179
- const organizeJob = useOrganizeJob(accountId);
180
- const createFilter = useCreateFilter(accountId);
181
- const { createFolder } = useCreateMailbox(accountId);
182
-
183
- // Creating a filter also moves the mail that already matches, not only the
184
- // mail that arrives next: the same retroactive back-apply the one-time scope
185
- // runs. The filter is created first so the rule is live before the pass, then
186
- // the pass runs over the existing corpus. `backApplyDraft` is the predicate
187
- // to run once the create succeeds, and is undefined when the rule cannot be
188
- // back-applied (a `HasWords` clause the vector-free pass can't evaluate — the
189
- // filter still saves and applies to incoming mail). It is kept, not cleared,
190
- // so a failed start can be retried; `backApplyStarted` guards the one-shot
191
- // auto-start against re-firing on re-render.
192
- const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
193
- const backApplyStarted = useRef(false);
194
-
195
- const commit = () => {
196
- const draft = ruleToDraft(rule, anchorMessageId);
197
- if (rule.scope === "once") {
198
- organizeJob.start(draft);
199
- return;
200
- }
201
- backApplyStarted.current = false;
202
- setBackApplyDraft(canBackApplyDraft(draft) ? draft : undefined);
203
- createFilter.createFilter(
204
- draft,
205
- rule.scope === "standing" ? "standing" : "temporary",
206
- (rule.name ?? "").trim(),
207
- );
208
- };
209
-
210
- useEffect(() => {
211
- if (!backApplyDraft || backApplyStarted.current || !createFilter.isSuccess)
212
- return;
213
- backApplyStarted.current = true;
214
- organizeJob.start(backApplyDraft);
215
- }, [backApplyDraft, createFilter.isSuccess, organizeJob.start]);
216
-
217
- const retryBackApply = () => {
218
- if (backApplyDraft) organizeJob.start(backApplyDraft);
219
- };
220
-
221
- if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
222
- return (
223
- <JobProgress
224
- progress={organizeJob.progress}
225
- isDone={organizeJob.isDone}
226
- runningLabel={
227
- senderFallback
228
- ? "Organizing mail from these senders…"
229
- : matchMode === "properties"
230
- ? "Organizing matching mail…"
231
- : "Organizing similar mail…"
232
- }
233
- onClose={onClose}
234
- />
235
- );
236
- }
237
-
238
- // The filter saved, but the back-apply's own request failed (a 5xx or dropped
239
- // connection on start, or a failed retry) — no job id was ever assigned, so no
240
- // JobProgress state holds. Surface it distinctly instead of falling through to
241
- // "Filter saved" and swallowing it: the rule is live, the move is what to
242
- // retry.
243
- if (createFilter.isSuccess && organizeJob.isError) {
244
- return <BackApplyError onRetry={retryBackApply} onClose={onClose} />;
245
- }
246
-
247
- if (createFilter.isPending) {
248
- return <SavingState />;
249
- }
250
-
251
- if (createFilter.isSuccess) {
252
- // The filter is saved; the back-apply is about to start (the effect hands
253
- // off to the job on the next tick). Keep the saving state until it takes
254
- // over so the success screen never flashes between them. When the rule
255
- // can't be back-applied, `backApplyDraft` is undefined and this is the
256
- // terminal state — saved, applying to incoming mail only.
257
- if (backApplyDraft) return <SavingState />;
258
- return <FilterSaved onClose={onClose} />;
259
- }
260
-
261
- if (createFilter.isError) {
262
- return <CommitError onRetry={createFilter.reset} onClose={onClose} />;
263
- }
264
-
265
- return (
266
- <FilterRuleEditor
267
- rule={rule}
268
- folders={folders}
269
- labels={labels}
270
- preview={preview}
271
- semanticAvailable={!semanticUnavailable}
272
- // A deployment without the vector pipeline has only one mode to be in,
273
- // so it gets no choice to make — the rule already matches on properties.
274
- matchMode={semanticUnavailable ? undefined : matchMode}
275
- onChangeMatchMode={changeMatchMode}
276
- clauseFields={SUPPORTED_CLAUSE_FIELDS}
277
- clauseSuggestions={clauseSuggestions}
278
- {...handlers}
279
- onCreateFolder={createFolder}
280
- onCreateLabel={onCreateLabel}
281
- onCommit={commit}
282
- onCancel={onClose}
283
- />
284
- );
285
- }
@@ -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
- });