@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.
- package/package.json +1 -1
- package/src/components/mail/DailyBrief.selection.test.ts +6 -2
- package/src/components/mail/DailyBrief.tsx +57 -112
- package/src/components/mail/FlaggedList.tsx +42 -0
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/MessageList.selection.test.ts +10 -5
- package/src/components/mail/MessageList.tsx +78 -82
- package/src/components/mail/SelectionWizardHost.tsx +721 -64
- package/src/components/mail/ThreadListInteraction.tsx +0 -9
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/components/settings/FilterEditor.tsx +1 -1
- package/src/hooks/useCreateMailbox.ts +16 -4
- package/src/hooks/useFilters.ts +30 -1
- package/src/hooks/useMatchSample.ts +63 -0
- package/src/hooks/useRulePreview.ts +24 -5
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/mail-context.ts +0 -9
- package/src/lib/organize/organize-model.test.ts +110 -0
- package/src/lib/organize/organize-model.ts +69 -0
- package/src/lib/organize/rule-model.ts +2 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +65 -2
- package/src/routes/mail.tsx +3 -8
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
- package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
- package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
- package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
- package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
- package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
- package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
- 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
|
-
});
|