@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,342 +0,0 @@
1
- import {
2
- mailboxOperationsListMailboxesQueryKey,
3
- threadOperationsListThreadsQueryKey,
4
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
- import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
6
- import { BottomSheet } from "@remit/ui";
7
- import type { Meta, StoryObj } from "@storybook/react-vite";
8
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
9
- import type { ReactNode } from "react";
10
- import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
11
- import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
12
- import type { FolderOption } from "./SomethingElsePanel";
13
- import { SomethingElsePanel } from "./SomethingElsePanel";
14
-
15
- /**
16
- * `Flows/Smart Organize` — the Organize surface as the chip editor (RFC 038 D1),
17
- * rendered from the real web-client component the desktop dialog and the mobile
18
- * sheet both mount, not a prototype copy. Each story opens the editor on a
19
- * seeded state — a widen-anchored rule, the sender-fallback clauses (#251), a
20
- * pre-seeded standing rule, and the capability-absent cases — so the surface
21
- * can't drift from what ships.
22
- */
23
-
24
- const ACCOUNT_ID = "acc-1";
25
-
26
- const makeMailbox = (
27
- mailboxId: string,
28
- fullPath: string,
29
- ): RemitImapMailboxResponse => ({
30
- mailboxId,
31
- accountId: ACCOUNT_ID,
32
- namespaceType: "personal",
33
- namespacePrefix: "",
34
- hierarchyDelimiter: "/",
35
- fullPath,
36
- messageCount: 0,
37
- unseenCount: 0,
38
- deletedCount: 0,
39
- lastSyncUid: 0,
40
- highWaterMarkUid: 0,
41
- lastMessageSyncAt: 0,
42
- createdAt: 0,
43
- updatedAt: 0,
44
- });
45
-
46
- const MAILBOXES: RemitImapMailboxResponse[] = [
47
- makeMailbox("mbx-inbox", "INBOX"),
48
- makeMailbox("mbx-archive", "Archive"),
49
- makeMailbox("mbx-receipts", "Receipts"),
50
- makeMailbox("mbx-travel", "Travel"),
51
- makeMailbox("mbx-junk", "Junk"),
52
- ];
53
-
54
- const FOLDER_OPTIONS: FolderOption[] = [
55
- { id: "mbx-inbox", label: "Inbox" },
56
- { id: "mbx-archive", label: "Archive" },
57
- { id: "mbx-receipts", label: "Receipts" },
58
- { id: "mbx-junk", label: "Junk" },
59
- ];
60
-
61
- /**
62
- * A QueryClient pre-seeded with the account's mailboxes, so the editor's folder
63
- * picker renders its real options without a network round-trip, and optionally
64
- * with the thread rows the properties prefill reads its subjects off — the same
65
- * rows the mailbox list renders behind the sheet.
66
- */
67
- function seededClient(
68
- subjects?: Record<string, string>,
69
- senderEmail = "receipts@example.com",
70
- ): QueryClient {
71
- const client = new QueryClient({
72
- defaultOptions: { queries: { retry: false } },
73
- });
74
- client.setQueryData(
75
- mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
76
- { items: MAILBOXES },
77
- );
78
- if (subjects) {
79
- client.setQueryData(
80
- threadOperationsListThreadsQueryKey({ path: { mailboxId: "mbx-inbox" } }),
81
- {
82
- items: Object.entries(subjects).map(([messageId, subject]) => ({
83
- messageId,
84
- threadId: messageId,
85
- subject,
86
- fromEmail: senderEmail,
87
- })),
88
- },
89
- );
90
- }
91
- return client;
92
- }
93
-
94
- /** Phone frame + a bottom sheet holding the editor, mirroring the mobile home. */
95
- function SheetStage({
96
- children,
97
- subjects,
98
- }: {
99
- children: ReactNode;
100
- subjects?: Record<string, string>;
101
- }) {
102
- return (
103
- <QueryClientProvider client={seededClient(subjects)}>
104
- <ErrorBannerProvider>
105
- <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[760px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
106
- <div className="divide-y divide-line opacity-50">
107
- {Array.from({ length: 8 }).map((_, index) => (
108
- <div
109
- // biome-ignore lint/suspicious/noArrayIndexKey: static backdrop skeleton, no ids
110
- key={index}
111
- className="flex items-start gap-3 px-row-inset py-2.5"
112
- >
113
- <div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
114
- <div className="min-w-0 flex-1 space-y-1">
115
- <div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
116
- <div className="h-2 w-2/3 rounded bg-surface-sunken" />
117
- </div>
118
- </div>
119
- ))}
120
- </div>
121
- <BottomSheet open onClose={() => undefined}>
122
- {children}
123
- </BottomSheet>
124
- </div>
125
- </ErrorBannerProvider>
126
- </QueryClientProvider>
127
- );
128
- }
129
-
130
- const meta: Meta = {
131
- title: "Flows/Smart Organize",
132
- parameters: { layout: "fullscreen" },
133
- };
134
- export default meta;
135
-
136
- type Story = StoryObj;
137
-
138
- /** The rule editor on a widened selection — the widen chip and a live count. */
139
- export const Organize: Story = {
140
- render: () => (
141
- <SheetStage>
142
- <OrganizeRuleEditor
143
- accountId={ACCOUNT_ID}
144
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
145
- seedCount={47}
146
- onClose={() => undefined}
147
- />
148
- </SheetStage>
149
- ),
150
- };
151
-
152
- /** A larger widen — the same editor over a broad match set. */
153
- export const FromSearch: Story = {
154
- name: "From Search",
155
- render: () => (
156
- <SheetStage>
157
- <OrganizeRuleEditor
158
- accountId={ACCOUNT_ID}
159
- selectedMessageIds={["msg-1"]}
160
- seedCount={412}
161
- seedMailboxId="mbx-archive"
162
- onClose={() => undefined}
163
- />
164
- </SheetStage>
165
- ),
166
- };
167
-
168
- /** The standing scope pre-selected — the rule that keeps working on new mail. */
169
- export const AlwaysRule: Story = {
170
- name: "Always Rule",
171
- render: () => (
172
- <SheetStage>
173
- <OrganizeRuleEditor
174
- accountId={ACCOUNT_ID}
175
- selectedMessageIds={["msg-1", "msg-2"]}
176
- seedCount={47}
177
- seedScope="standing"
178
- seedMailboxId="mbx-travel"
179
- onClose={() => undefined}
180
- />
181
- </SheetStage>
182
- ),
183
- };
184
-
185
- /**
186
- * The deployment ships no vector pipeline and the selection has no senders to
187
- * match on — the widen is not offered and the rule opens empty, awaiting a
188
- * clause (#226/#201).
189
- */
190
- export const SemanticUnavailable: Story = {
191
- name: "Semantic Unavailable",
192
- render: () => (
193
- <SheetStage>
194
- <OrganizeRuleEditor
195
- accountId={ACCOUNT_ID}
196
- selectedMessageIds={["msg-1", "msg-2"]}
197
- seedCount={0}
198
- semanticUnavailable
199
- onClose={() => undefined}
200
- />
201
- </SheetStage>
202
- ),
203
- };
204
-
205
- /**
206
- * No vector pipeline, but the selection has senders to match on — the widen fell
207
- * back to the sender `From` clauses (#251). The senders span different domains,
208
- * so the fallback keeps one editable `From` chip per address, combined with
209
- * "or". Every commit carries exactly these clauses.
210
- */
211
- export const SenderFallback: Story = {
212
- name: "Sender Fallback",
213
- render: () => (
214
- <SheetStage>
215
- <OrganizeRuleEditor
216
- accountId={ACCOUNT_ID}
217
- selectedMessageIds={["msg-1", "msg-2"]}
218
- seedCount={128}
219
- semanticUnavailable
220
- senders={["npm@github.com", "noreply@medium.com"]}
221
- onClose={() => undefined}
222
- />
223
- </SheetStage>
224
- ),
225
- };
226
-
227
- /**
228
- * The sender fallback where every selected sender shares one registrable domain
229
- * (RFC 038 D2): the per-address `From` chips collapse to a single `FromDomain`
230
- * chip — "anyone at github.com" — rather than listing addresses. Matches
231
- * `sub.github.com` too, never a look-alike like `github.com.evil.example`.
232
- */
233
- export const SenderFallbackDomain: Story = {
234
- name: "Sender Fallback (domain)",
235
- render: () => (
236
- <SheetStage>
237
- <OrganizeRuleEditor
238
- accountId={ACCOUNT_ID}
239
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
240
- seedCount={342}
241
- semanticUnavailable
242
- senders={[
243
- "npm@github.com",
244
- "notifications@github.com",
245
- "ci@sub.github.com",
246
- ]}
247
- onClose={() => undefined}
248
- />
249
- </SheetStage>
250
- ),
251
- };
252
-
253
- /** The sender fallback with a standing rule pre-selected. */
254
- export const SenderFallbackStanding: Story = {
255
- name: "Sender Fallback (standing)",
256
- render: () => (
257
- <SheetStage>
258
- <OrganizeRuleEditor
259
- accountId={ACCOUNT_ID}
260
- selectedMessageIds={["msg-1", "msg-2", "msg-3", "msg-4"]}
261
- seedCount={412}
262
- semanticUnavailable
263
- senders={[
264
- "npm@github.com",
265
- "notifications@github.com",
266
- "noreply@medium.com",
267
- "digest@substack.com",
268
- ]}
269
- seedScope="standing"
270
- seedMailboxId="mbx-archive"
271
- onClose={() => undefined}
272
- />
273
- </SheetStage>
274
- ),
275
- };
276
-
277
- /**
278
- * Matching on properties instead of similarity — the third way to build a rule,
279
- * with no semantics in it at all. Every selected message came from one sender,
280
- * so the rule opens on a single editable `From` chip. The mode control stays on
281
- * screen: "Anything similar" is one tap away and is still where the flow opens.
282
- */
283
- export const MatchOnPropertiesSender: Story = {
284
- name: "Match on Properties (sender)",
285
- render: () => (
286
- <SheetStage>
287
- <OrganizeRuleEditor
288
- accountId={ACCOUNT_ID}
289
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
290
- seedCount={47}
291
- seedMatchMode="properties"
292
- senders={["receipts@example.com"]}
293
- onClose={() => undefined}
294
- />
295
- </SheetStage>
296
- ),
297
- };
298
-
299
- /**
300
- * The same mode where the selection's senders differ: there is no one sender to
301
- * name, so the prefill drops to the part the subjects share — "Your receipt
302
- * from" out of three differently-numbered receipts — as an ordinary `Subject`
303
- * chip the user can widen or narrow.
304
- */
305
- export const MatchOnPropertiesSubject: Story = {
306
- name: "Match on Properties (subject)",
307
- render: () => (
308
- <SheetStage
309
- subjects={{
310
- "msg-1": "Your receipt from Blue Bottle #4821",
311
- "msg-2": "Re: Your receipt from Ritual Coffee #119",
312
- "msg-3": "Your receipt from Sightglass #77",
313
- }}
314
- >
315
- <OrganizeRuleEditor
316
- accountId={ACCOUNT_ID}
317
- selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
318
- seedCount={62}
319
- seedMatchMode="properties"
320
- senders={[
321
- "receipts@bluebottle.example",
322
- "hello@ritual.example",
323
- "no-reply@sightglass.example",
324
- ]}
325
- onClose={() => undefined}
326
- />
327
- </SheetStage>
328
- ),
329
- };
330
-
331
- /** The "Something else" fallback: shortcuts derived from folders + an input. */
332
- export const SomethingElse: Story = {
333
- render: () => (
334
- <SheetStage>
335
- <SomethingElsePanel
336
- folderOptions={FOLDER_OPTIONS}
337
- junkMailboxId="mbx-junk"
338
- onSeed={() => undefined}
339
- />
340
- </SheetStage>
341
- ),
342
- };
@@ -1,182 +0,0 @@
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
- onChangeLabel: (labelId: string) => void;
32
- onChangeScope: (scope: RuleScope) => void;
33
- onChangeName: (name: string) => void;
34
- onChangeUntil: (until: string) => void;
35
- };
36
- }
37
-
38
- interface RuleEditorStateInput {
39
- /** The rule the editor opens on. */
40
- initialRule: FilterRule;
41
- /**
42
- * How many anchors a widen the user adds inline should carry. A search-seeded
43
- * rule has no message anchor, so its editor never offers the widen and this is
44
- * unused; the Organize editor passes its selection size.
45
- */
46
- widenAnchorCount?: number;
47
- }
48
-
49
- /**
50
- * The clause-editing and rule-mutation state a filter-rule editor owns, shared by
51
- * every entry point onto the chip editor (RFC 038 D1) so the Organize and
52
- * search-derived surfaces edit the rule identically. Preview and commit stay with
53
- * the caller — they differ by entry point (an anchored widen count and back-apply
54
- * job for Organize, a literal count for a search-derived rule).
55
- */
56
- export const useRuleEditorState = ({
57
- initialRule,
58
- widenAnchorCount = 1,
59
- }: RuleEditorStateInput): RuleEditorState => {
60
- const [rule, setRuleState] = useState<FilterRule>(initialRule);
61
- const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
62
- const nextClauseId = useRef(0);
63
-
64
- const setRule = (updater: (current: FilterRule) => FilterRule) =>
65
- setRuleState(updater);
66
-
67
- const startAddClause = () =>
68
- setClauseEdit({
69
- mode: "add",
70
- draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
71
- });
72
-
73
- const startEditClause = (clauseId: string) => {
74
- const clause = rule.clauses.find((entry) => entry.id === clauseId);
75
- if (!clause) return;
76
- setClauseEdit({
77
- mode: "edit",
78
- clauseId,
79
- draft: { field: clause.field, value: clause.value },
80
- });
81
- };
82
-
83
- const changeDraftField = (field: ClauseField) =>
84
- setClauseEdit((edit) =>
85
- edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
86
- );
87
-
88
- const changeDraftValue = (value: string) =>
89
- setClauseEdit((edit) =>
90
- edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
91
- );
92
-
93
- const submitClause = () => {
94
- if (!clauseEdit) return;
95
- const field = clauseEdit.draft.field;
96
- const value = normalizeClauseValue(field, clauseEdit.draft.value);
97
- if (value === "") return;
98
- setRuleState((current) => {
99
- if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
100
- return {
101
- ...current,
102
- clauses: current.clauses.map((clause) =>
103
- clause.id === clauseEdit.clauseId
104
- ? { id: clause.id, field, value }
105
- : clause,
106
- ),
107
- };
108
- }
109
- nextClauseId.current += 1;
110
- return {
111
- ...current,
112
- clauses: [
113
- ...current.clauses,
114
- { id: `clause-${nextClauseId.current}`, field, value },
115
- ],
116
- };
117
- });
118
- setClauseEdit(undefined);
119
- };
120
-
121
- const removeClause = (clauseId: string) =>
122
- setRuleState((current) => ({
123
- ...current,
124
- clauses: current.clauses.filter((clause) => clause.id !== clauseId),
125
- }));
126
-
127
- const addWiden = () =>
128
- setRuleState((current) => ({
129
- ...current,
130
- widen: { anchorCount: Math.max(widenAnchorCount, 1) },
131
- }));
132
-
133
- const removeWiden = () =>
134
- setRuleState((current) => ({ ...current, widen: undefined }));
135
-
136
- const changeMatchOperator = (matchOperator: MatchOperator) =>
137
- setRuleState((current) => ({ ...current, matchOperator }));
138
-
139
- const changeMove = (mailboxId: string) =>
140
- setRuleState((current) => ({
141
- ...current,
142
- moveMailboxId: mailboxId || undefined,
143
- }));
144
-
145
- const changeLabel = (labelId: string) =>
146
- setRuleState((current) => ({
147
- ...current,
148
- labelId: labelId || undefined,
149
- }));
150
-
151
- const changeScope = (scope: RuleScope) =>
152
- setRuleState((current) => ({ ...current, scope }));
153
-
154
- const changeName = (name: string) =>
155
- setRuleState((current) => ({ ...current, name }));
156
-
157
- const changeUntil = (until: string) =>
158
- setRuleState((current) => ({ ...current, until }));
159
-
160
- return {
161
- rule,
162
- setRule,
163
- handlers: {
164
- clauseEdit,
165
- onStartAddClause: startAddClause,
166
- onStartEditClause: startEditClause,
167
- onRemoveClause: removeClause,
168
- onChangeDraftField: changeDraftField,
169
- onChangeDraftValue: changeDraftValue,
170
- onSubmitClause: submitClause,
171
- onCancelClause: () => setClauseEdit(undefined),
172
- onAddWiden: addWiden,
173
- onRemoveWiden: removeWiden,
174
- onChangeMatchOperator: changeMatchOperator,
175
- onChangeMove: changeMove,
176
- onChangeLabel: changeLabel,
177
- onChangeScope: changeScope,
178
- onChangeName: changeName,
179
- onChangeUntil: changeUntil,
180
- },
181
- };
182
- };
@@ -1,118 +0,0 @@
1
- /**
2
- * Opening the filter-from-search editor: the seed count comes from the converted
3
- * literal predicate in one request, under the account the filter targets. No
4
- * capability probe — the deployment's semantic reach is read from the search's
5
- * own results on the surface, so a seed success is never blocked by a probe.
6
- */
7
-
8
- import assert from "node:assert/strict";
9
- import { afterEach, describe, it } from "node:test";
10
- import { createElement } from "react";
11
- import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
12
- import { createDomHarness, type DomHarness } from "../test-support/dom";
13
- import {
14
- type HttpCall,
15
- type HttpMock,
16
- httpError,
17
- mockFetch,
18
- } from "../test-support/http";
19
- import { useSearchFilterSeed } from "./useSearchFilterSeed";
20
-
21
- let harness: DomHarness | undefined;
22
- let http: HttpMock | undefined;
23
-
24
- afterEach(() => {
25
- harness?.close();
26
- harness = undefined;
27
- http?.restore();
28
- http = undefined;
29
- });
30
-
31
- const COUNTABLE: OrganizeMatchPredicate = {
32
- matchOperator: "And",
33
- literalClauses: [{ field: "From", value: "receipts@shop.example" }],
34
- };
35
-
36
- /**
37
- * A free-text search converts to a body-content clause, which the vector-free
38
- * matcher refuses. The seed must not ask.
39
- */
40
- const UNCOUNTABLE: OrganizeMatchPredicate = {
41
- matchOperator: "And",
42
- literalClauses: [{ field: "HasWords", value: "receipts" }],
43
- };
44
-
45
- function probeFor(predicate: OrganizeMatchPredicate) {
46
- return function Probe() {
47
- const seed = useSearchFilterSeed("acc-1", predicate);
48
- return createElement(
49
- "div",
50
- null,
51
- JSON.stringify({
52
- seedCount: seed.seedCount ?? null,
53
- isPending: seed.isPending,
54
- isError: seed.isError,
55
- uncountable: seed.uncountable,
56
- }),
57
- );
58
- };
59
- }
60
-
61
- const mount = (
62
- responder: (call: HttpCall) => unknown,
63
- predicate: OrganizeMatchPredicate = COUNTABLE,
64
- ): DomHarness => {
65
- http = mockFetch(responder);
66
- harness = createDomHarness();
67
- harness.renderApp(createElement(probeFor(predicate)));
68
- return harness;
69
- };
70
-
71
- const state = (dom: DomHarness) => JSON.parse(dom.text());
72
-
73
- describe("useSearchFilterSeed", () => {
74
- it("seeds the count from the literal predicate in a single preview", async () => {
75
- const dom = mount((call) =>
76
- call.path.endsWith("/organize/preview")
77
- ? { matchedCount: 12, messageIds: [] }
78
- : {},
79
- );
80
- await dom.flush();
81
- await dom.flush();
82
- assert.equal(state(dom).seedCount, 12);
83
- // One preview, carrying the literal clauses and no anchor.
84
- const previews = http?.to("/organize/preview") ?? [];
85
- assert.equal(previews.length, 1);
86
- assert.equal(previews[0].body?.anchorMessageId, undefined);
87
- assert.deepEqual(previews[0].body?.literalClauses, [
88
- { field: "From", value: "receipts@shop.example" },
89
- ]);
90
- });
91
-
92
- it("surfaces the seed error so the caller can offer a retry", async () => {
93
- const dom = mount(() => httpError(500));
94
- await dom.flush();
95
- await dom.flush();
96
- assert.equal(state(dom).isError, true);
97
- });
98
-
99
- it("never asks for a count the matcher cannot produce", async () => {
100
- const dom = mount(
101
- (call) =>
102
- call.path.endsWith("/organize/preview")
103
- ? { matchedCount: 12, messageIds: [] }
104
- : {},
105
- UNCOUNTABLE,
106
- );
107
- await dom.flush();
108
- await dom.flush();
109
- assert.equal(http?.to("/organize/preview").length, 0);
110
- // Not pending and not an error — the editor opens, it just has no number.
111
- assert.deepEqual(state(dom), {
112
- seedCount: null,
113
- isPending: false,
114
- isError: false,
115
- uncountable: true,
116
- });
117
- });
118
- });