@remit/web-client 0.0.65 → 0.0.67
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/MessageList.tsx +0 -2
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -1
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -10
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -1
- package/src/components/mail/organize/OrganizeDialog.tsx +7 -12
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +358 -0
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +361 -0
- package/src/components/mail/organize/smart-organize.stories.tsx +39 -119
- package/src/components/settings/FilterEditor.render.test.ts +281 -0
- package/src/components/settings/FilterEditor.tsx +338 -0
- package/src/components/settings/FilterEditorSurface.tsx +52 -0
- package/src/components/settings/FiltersList.render.test.ts +27 -1
- package/src/components/settings/FiltersList.tsx +33 -5
- package/src/components/settings/settings-filter.stories.tsx +165 -0
- package/src/hooks/useRulePreview.ts +91 -0
- package/src/hooks/useUpdateFilter.ts +56 -0
- package/src/lib/organize/filter-edit-model.test.ts +175 -0
- package/src/lib/organize/filter-edit-model.ts +106 -0
- package/src/lib/organize/rule-model.test.ts +297 -0
- package/src/lib/organize/rule-model.ts +205 -0
- package/src/routes/settings/filters.tsx +26 -1
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +0 -113
- package/src/components/mail/organize/OrganizePanel.render.test.ts +0 -170
- package/src/components/mail/organize/OrganizePanel.tsx +0 -420
- package/src/lib/organize/organize-copy.test.ts +0 -142
- package/src/lib/organize/organize-copy.ts +0 -95
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
type ClauseEditState,
|
|
5
|
+
type ClauseField,
|
|
6
|
+
type FilterRule,
|
|
7
|
+
FilterRuleEditor,
|
|
8
|
+
type FolderOption,
|
|
9
|
+
type MatchOperator,
|
|
10
|
+
type RuleScope,
|
|
11
|
+
} from "@remit/ui";
|
|
12
|
+
import { useQuery } from "@tanstack/react-query";
|
|
13
|
+
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
14
|
+
import { useMemo, useRef, useState } from "react";
|
|
15
|
+
import { useCreateFilter } from "@/hooks/useFilters";
|
|
16
|
+
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
17
|
+
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
18
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
19
|
+
import { buildMoveTargets } from "@/lib/move-targets";
|
|
20
|
+
import {
|
|
21
|
+
buildInitialRule,
|
|
22
|
+
normalizeClauseValue,
|
|
23
|
+
rulePredicate,
|
|
24
|
+
ruleToDraft,
|
|
25
|
+
SUPPORTED_CLAUSE_FIELDS,
|
|
26
|
+
} from "@/lib/organize/rule-model";
|
|
27
|
+
|
|
28
|
+
interface OrganizeRuleEditorProps {
|
|
29
|
+
accountId: string;
|
|
30
|
+
selectedMessageIds: string[];
|
|
31
|
+
/** The widen probe's matched total — seeds the live count without a re-fetch. */
|
|
32
|
+
seedCount: number;
|
|
33
|
+
/**
|
|
34
|
+
* The deployment ships no vector pipeline, so the widen cannot run. The rule
|
|
35
|
+
* opens on the sender-fallback `From` chips instead of the anchor.
|
|
36
|
+
*/
|
|
37
|
+
semanticUnavailable?: boolean;
|
|
38
|
+
/** Distinct sender addresses, for the fallback clauses and the progress copy. */
|
|
39
|
+
senders?: string[];
|
|
40
|
+
/** A folder a "Something else" shortcut pre-picked. */
|
|
41
|
+
seedMailboxId?: string;
|
|
42
|
+
/** A scope a "Something else" shortcut pre-picked. */
|
|
43
|
+
seedScope?: RuleScope;
|
|
44
|
+
onClose: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Organize surface as the chip editor (RFC 038 D1). The rule is rendered and
|
|
49
|
+
* edited over the existing preview/apply endpoints: clause chips, a
|
|
50
|
+
* match-operator toggle, a move action, and a scope that maps one-time apply to
|
|
51
|
+
* a back-apply job and standing/until to a `Filter`. The count is live and the
|
|
52
|
+
* commit gate holds apply until it settles, so the set the editor shows is the
|
|
53
|
+
* set a commit acts on. Rendered inside the desktop dialog and the mobile sheet
|
|
54
|
+
* alike, so the two cannot drift.
|
|
55
|
+
*/
|
|
56
|
+
export function OrganizeRuleEditor({
|
|
57
|
+
accountId,
|
|
58
|
+
selectedMessageIds,
|
|
59
|
+
seedCount,
|
|
60
|
+
semanticUnavailable = false,
|
|
61
|
+
senders = [],
|
|
62
|
+
seedMailboxId,
|
|
63
|
+
seedScope,
|
|
64
|
+
onClose,
|
|
65
|
+
}: OrganizeRuleEditorProps) {
|
|
66
|
+
const anchorMessageId = selectedMessageIds[0];
|
|
67
|
+
const senderFallback = semanticUnavailable && senders.length > 0;
|
|
68
|
+
|
|
69
|
+
const [rule, setRule] = useState<FilterRule>(() =>
|
|
70
|
+
buildInitialRule({
|
|
71
|
+
anchorMessageId,
|
|
72
|
+
semanticUnavailable,
|
|
73
|
+
senders,
|
|
74
|
+
selectionCount: selectedMessageIds.length,
|
|
75
|
+
seedMailboxId,
|
|
76
|
+
seedScope,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
80
|
+
const nextClauseId = useRef(0);
|
|
81
|
+
|
|
82
|
+
const { data: mailboxesData } = useQuery({
|
|
83
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
84
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const folders: FolderOption[] = useMemo(
|
|
88
|
+
() =>
|
|
89
|
+
buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
|
|
90
|
+
id: mailbox.mailboxId,
|
|
91
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
92
|
+
})),
|
|
93
|
+
[mailboxesData?.items],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const preview = useRulePreview(
|
|
97
|
+
accountId,
|
|
98
|
+
rulePredicate(rule, anchorMessageId),
|
|
99
|
+
seedCount,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const organizeJob = useOrganizeJob(accountId);
|
|
103
|
+
const createFilter = useCreateFilter(accountId);
|
|
104
|
+
|
|
105
|
+
const startAddClause = () =>
|
|
106
|
+
setClauseEdit({
|
|
107
|
+
mode: "add",
|
|
108
|
+
draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const startEditClause = (clauseId: string) => {
|
|
112
|
+
const clause = rule.clauses.find((entry) => entry.id === clauseId);
|
|
113
|
+
if (!clause) return;
|
|
114
|
+
setClauseEdit({
|
|
115
|
+
mode: "edit",
|
|
116
|
+
clauseId,
|
|
117
|
+
draft: { field: clause.field, value: clause.value },
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const changeDraftField = (field: ClauseField) =>
|
|
122
|
+
setClauseEdit((edit) =>
|
|
123
|
+
edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const changeDraftValue = (value: string) =>
|
|
127
|
+
setClauseEdit((edit) =>
|
|
128
|
+
edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const submitClause = () => {
|
|
132
|
+
if (!clauseEdit) return;
|
|
133
|
+
const field = clauseEdit.draft.field;
|
|
134
|
+
const value = normalizeClauseValue(field, clauseEdit.draft.value);
|
|
135
|
+
if (value === "") return;
|
|
136
|
+
setRule((current) => {
|
|
137
|
+
if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
|
|
138
|
+
return {
|
|
139
|
+
...current,
|
|
140
|
+
clauses: current.clauses.map((clause) =>
|
|
141
|
+
clause.id === clauseEdit.clauseId
|
|
142
|
+
? { id: clause.id, field, value }
|
|
143
|
+
: clause,
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
nextClauseId.current += 1;
|
|
148
|
+
return {
|
|
149
|
+
...current,
|
|
150
|
+
clauses: [
|
|
151
|
+
...current.clauses,
|
|
152
|
+
{ id: `clause-${nextClauseId.current}`, field, value },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
setClauseEdit(undefined);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const removeClause = (clauseId: string) =>
|
|
160
|
+
setRule((current) => ({
|
|
161
|
+
...current,
|
|
162
|
+
clauses: current.clauses.filter((clause) => clause.id !== clauseId),
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const addWiden = () =>
|
|
166
|
+
setRule((current) => ({
|
|
167
|
+
...current,
|
|
168
|
+
widen: { anchorCount: Math.max(selectedMessageIds.length, 1) },
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
const removeWiden = () =>
|
|
172
|
+
setRule((current) => ({ ...current, widen: undefined }));
|
|
173
|
+
|
|
174
|
+
const changeMatchOperator = (matchOperator: MatchOperator) =>
|
|
175
|
+
setRule((current) => ({ ...current, matchOperator }));
|
|
176
|
+
|
|
177
|
+
const changeMove = (mailboxId: string) =>
|
|
178
|
+
setRule((current) => ({
|
|
179
|
+
...current,
|
|
180
|
+
moveMailboxId: mailboxId || undefined,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
const changeScope = (scope: RuleScope) =>
|
|
184
|
+
setRule((current) => ({ ...current, scope }));
|
|
185
|
+
|
|
186
|
+
const changeName = (name: string) =>
|
|
187
|
+
setRule((current) => ({ ...current, name }));
|
|
188
|
+
|
|
189
|
+
const changeUntil = (until: string) =>
|
|
190
|
+
setRule((current) => ({ ...current, until }));
|
|
191
|
+
|
|
192
|
+
const commit = () => {
|
|
193
|
+
const draft = ruleToDraft(rule, anchorMessageId);
|
|
194
|
+
if (rule.scope === "once") {
|
|
195
|
+
organizeJob.start(draft);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
createFilter.createFilter(
|
|
199
|
+
draft,
|
|
200
|
+
rule.scope === "standing" ? "standing" : "temporary",
|
|
201
|
+
(rule.name ?? "").trim(),
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
|
|
206
|
+
return (
|
|
207
|
+
<JobProgress
|
|
208
|
+
progress={organizeJob.progress}
|
|
209
|
+
isDone={organizeJob.isDone}
|
|
210
|
+
senderFallback={senderFallback}
|
|
211
|
+
onClose={onClose}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (createFilter.isPending) {
|
|
217
|
+
return <SavingState />;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (createFilter.isSuccess) {
|
|
221
|
+
return <FilterSaved onClose={onClose} />;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (createFilter.isError) {
|
|
225
|
+
return <CommitError onRetry={createFilter.reset} onClose={onClose} />;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<FilterRuleEditor
|
|
230
|
+
rule={rule}
|
|
231
|
+
folders={folders}
|
|
232
|
+
preview={preview}
|
|
233
|
+
semanticAvailable={!semanticUnavailable}
|
|
234
|
+
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
235
|
+
clauseEdit={clauseEdit}
|
|
236
|
+
onStartAddClause={startAddClause}
|
|
237
|
+
onStartEditClause={startEditClause}
|
|
238
|
+
onRemoveClause={removeClause}
|
|
239
|
+
onChangeDraftField={changeDraftField}
|
|
240
|
+
onChangeDraftValue={changeDraftValue}
|
|
241
|
+
onSubmitClause={submitClause}
|
|
242
|
+
onCancelClause={() => setClauseEdit(undefined)}
|
|
243
|
+
onAddWiden={addWiden}
|
|
244
|
+
onRemoveWiden={removeWiden}
|
|
245
|
+
onChangeMatchOperator={changeMatchOperator}
|
|
246
|
+
onChangeMove={changeMove}
|
|
247
|
+
onChangeScope={changeScope}
|
|
248
|
+
onChangeName={changeName}
|
|
249
|
+
onChangeUntil={changeUntil}
|
|
250
|
+
onCommit={commit}
|
|
251
|
+
onCancel={onClose}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function JobProgress({
|
|
257
|
+
progress,
|
|
258
|
+
isDone,
|
|
259
|
+
senderFallback,
|
|
260
|
+
onClose,
|
|
261
|
+
}: {
|
|
262
|
+
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
263
|
+
isDone: boolean;
|
|
264
|
+
senderFallback: boolean;
|
|
265
|
+
onClose: () => void;
|
|
266
|
+
}) {
|
|
267
|
+
const failed = progress.state === "Failed";
|
|
268
|
+
return (
|
|
269
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
270
|
+
{!isDone ? (
|
|
271
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
272
|
+
) : failed ? (
|
|
273
|
+
<span className="text-sm font-semibold text-danger">
|
|
274
|
+
Organize failed
|
|
275
|
+
</span>
|
|
276
|
+
) : (
|
|
277
|
+
<CheckCircle2 className="size-8 text-positive" />
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{!isDone && (
|
|
281
|
+
<p className="text-sm font-medium text-fg">
|
|
282
|
+
{senderFallback
|
|
283
|
+
? "Organizing mail from these senders…"
|
|
284
|
+
: "Organizing similar mail…"}
|
|
285
|
+
</p>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{isDone && !failed && (
|
|
289
|
+
<div className="text-sm text-fg">
|
|
290
|
+
<p className="font-medium">Done</p>
|
|
291
|
+
<p className="mt-1 text-xs text-fg-muted">
|
|
292
|
+
{progress.appliedCount} of {progress.matchedCount} moved
|
|
293
|
+
{progress.failedCount > 0
|
|
294
|
+
? ` · ${progress.failedCount} failed`
|
|
295
|
+
: ""}
|
|
296
|
+
.
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{isDone && failed && (
|
|
302
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
303
|
+
{progress.errorMessage || "Something went wrong. Please try again."}
|
|
304
|
+
</p>
|
|
305
|
+
)}
|
|
306
|
+
|
|
307
|
+
{isDone && (
|
|
308
|
+
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
309
|
+
Done
|
|
310
|
+
</Button>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function SavingState() {
|
|
317
|
+
return (
|
|
318
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
319
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
320
|
+
<p className="text-sm font-medium text-fg">Saving rule…</p>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function FilterSaved({ onClose }: { onClose: () => void }) {
|
|
326
|
+
return (
|
|
327
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
328
|
+
<CheckCircle2 className="size-8 text-positive" />
|
|
329
|
+
<p className="text-sm font-medium text-fg">Filter saved</p>
|
|
330
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
331
|
+
You can see it, and when it expires, under Settings › Filters.
|
|
332
|
+
</p>
|
|
333
|
+
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
334
|
+
Done
|
|
335
|
+
</Button>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function CommitError({
|
|
341
|
+
onRetry,
|
|
342
|
+
onClose,
|
|
343
|
+
}: {
|
|
344
|
+
onRetry: () => void;
|
|
345
|
+
onClose: () => void;
|
|
346
|
+
}) {
|
|
347
|
+
return (
|
|
348
|
+
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
349
|
+
<p className="text-sm font-medium text-danger">Couldn't save the rule</p>
|
|
350
|
+
<p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
|
|
351
|
+
<div className="mt-2 flex gap-2">
|
|
352
|
+
<Button variant="primary" onClick={onRetry}>
|
|
353
|
+
Try again
|
|
354
|
+
</Button>
|
|
355
|
+
<Button variant="ghost" onClick={onClose}>
|
|
356
|
+
Not now
|
|
357
|
+
</Button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
@@ -5,18 +5,17 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
|
5
5
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
6
|
import type { ReactNode } from "react";
|
|
7
7
|
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
8
|
-
import {
|
|
8
|
+
import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
|
|
9
9
|
import type { FolderOption } from "./SomethingElsePanel";
|
|
10
10
|
import { SomethingElsePanel } from "./SomethingElsePanel";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* `Flows/Smart Organize` — the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* from what ships.
|
|
13
|
+
* `Flows/Smart Organize` — the Organize surface as the chip editor (RFC 038 D1),
|
|
14
|
+
* rendered from the real web-client component the desktop dialog and the mobile
|
|
15
|
+
* sheet both mount, not a prototype copy. Each story opens the editor on a
|
|
16
|
+
* seeded state — a widen-anchored rule, the sender-fallback clauses (#251), a
|
|
17
|
+
* pre-seeded standing rule, and the capability-absent cases — so the surface
|
|
18
|
+
* can't drift from what ships.
|
|
20
19
|
*/
|
|
21
20
|
|
|
22
21
|
const ACCOUNT_ID = "acc-1";
|
|
@@ -57,8 +56,8 @@ const FOLDER_OPTIONS: FolderOption[] = [
|
|
|
57
56
|
];
|
|
58
57
|
|
|
59
58
|
/**
|
|
60
|
-
* A QueryClient pre-seeded with the account's mailboxes, so the
|
|
61
|
-
*
|
|
59
|
+
* A QueryClient pre-seeded with the account's mailboxes, so the editor's folder
|
|
60
|
+
* picker renders its real options without a network round-trip.
|
|
62
61
|
*/
|
|
63
62
|
function seededClient(): QueryClient {
|
|
64
63
|
const client = new QueryClient({
|
|
@@ -71,7 +70,7 @@ function seededClient(): QueryClient {
|
|
|
71
70
|
return client;
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
/** Phone frame + a bottom sheet holding the
|
|
73
|
+
/** Phone frame + a bottom sheet holding the editor, mirroring the mobile home. */
|
|
75
74
|
function SheetStage({ children }: { children: ReactNode }) {
|
|
76
75
|
return (
|
|
77
76
|
<QueryClientProvider client={seededClient()}>
|
|
@@ -109,44 +108,29 @@ export default meta;
|
|
|
109
108
|
|
|
110
109
|
type Story = StoryObj;
|
|
111
110
|
|
|
112
|
-
/** The
|
|
111
|
+
/** The rule editor on a widened selection — the widen chip and a live count. */
|
|
113
112
|
export const Organize: Story = {
|
|
114
113
|
render: () => (
|
|
115
114
|
<SheetStage>
|
|
116
|
-
<
|
|
115
|
+
<OrganizeRuleEditor
|
|
117
116
|
accountId={ACCOUNT_ID}
|
|
118
|
-
mailboxId="mbx-inbox"
|
|
119
117
|
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
120
|
-
|
|
121
|
-
anchorMessageId: "msg-1",
|
|
122
|
-
matchOperator: "And",
|
|
123
|
-
literalClauses: [],
|
|
124
|
-
}}
|
|
125
|
-
matchedCount={47}
|
|
118
|
+
seedCount={47}
|
|
126
119
|
onClose={() => undefined}
|
|
127
120
|
/>
|
|
128
121
|
</SheetStage>
|
|
129
122
|
),
|
|
130
123
|
};
|
|
131
124
|
|
|
132
|
-
/**
|
|
133
|
-
* A larger widen — the same sentence over a broad match set, as when the anchor
|
|
134
|
-
* is a common sender.
|
|
135
|
-
*/
|
|
125
|
+
/** A larger widen — the same editor over a broad match set. */
|
|
136
126
|
export const FromSearch: Story = {
|
|
137
127
|
name: "From Search",
|
|
138
128
|
render: () => (
|
|
139
129
|
<SheetStage>
|
|
140
|
-
<
|
|
130
|
+
<OrganizeRuleEditor
|
|
141
131
|
accountId={ACCOUNT_ID}
|
|
142
|
-
mailboxId="mbx-inbox"
|
|
143
132
|
selectedMessageIds={["msg-1"]}
|
|
144
|
-
|
|
145
|
-
anchorMessageId: "msg-1",
|
|
146
|
-
matchOperator: "And",
|
|
147
|
-
literalClauses: [],
|
|
148
|
-
}}
|
|
149
|
-
matchedCount={412}
|
|
133
|
+
seedCount={412}
|
|
150
134
|
seedMailboxId="mbx-archive"
|
|
151
135
|
onClose={() => undefined}
|
|
152
136
|
/>
|
|
@@ -154,22 +138,16 @@ export const FromSearch: Story = {
|
|
|
154
138
|
),
|
|
155
139
|
};
|
|
156
140
|
|
|
157
|
-
/** The standing scope pre-selected — the
|
|
141
|
+
/** The standing scope pre-selected — the rule that keeps working on new mail. */
|
|
158
142
|
export const AlwaysRule: Story = {
|
|
159
143
|
name: "Always Rule",
|
|
160
144
|
render: () => (
|
|
161
145
|
<SheetStage>
|
|
162
|
-
<
|
|
146
|
+
<OrganizeRuleEditor
|
|
163
147
|
accountId={ACCOUNT_ID}
|
|
164
|
-
mailboxId="mbx-inbox"
|
|
165
148
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
matchOperator: "And",
|
|
169
|
-
literalClauses: [],
|
|
170
|
-
}}
|
|
171
|
-
matchedCount={47}
|
|
172
|
-
initialScope="standing"
|
|
149
|
+
seedCount={47}
|
|
150
|
+
seedScope="standing"
|
|
173
151
|
seedMailboxId="mbx-travel"
|
|
174
152
|
onClose={() => undefined}
|
|
175
153
|
/>
|
|
@@ -177,47 +155,19 @@ export const AlwaysRule: Story = {
|
|
|
177
155
|
),
|
|
178
156
|
};
|
|
179
157
|
|
|
180
|
-
/** The widen matched nothing — the sentence organizes just the selection. */
|
|
181
|
-
export const NoSimilarFound: Story = {
|
|
182
|
-
name: "No Similar Found",
|
|
183
|
-
render: () => (
|
|
184
|
-
<SheetStage>
|
|
185
|
-
<OrganizePanel
|
|
186
|
-
accountId={ACCOUNT_ID}
|
|
187
|
-
mailboxId="mbx-inbox"
|
|
188
|
-
selectedMessageIds={["msg-1", "msg-2"]}
|
|
189
|
-
matchPredicate={{
|
|
190
|
-
anchorMessageId: "msg-1",
|
|
191
|
-
matchOperator: "And",
|
|
192
|
-
literalClauses: [],
|
|
193
|
-
}}
|
|
194
|
-
matchedCount={0}
|
|
195
|
-
fallback
|
|
196
|
-
onClose={() => undefined}
|
|
197
|
-
/>
|
|
198
|
-
</SheetStage>
|
|
199
|
-
),
|
|
200
|
-
};
|
|
201
|
-
|
|
202
158
|
/**
|
|
203
|
-
* The deployment ships no vector pipeline
|
|
204
|
-
* the
|
|
205
|
-
*
|
|
159
|
+
* The deployment ships no vector pipeline and the selection has no senders to
|
|
160
|
+
* match on — the widen is not offered and the rule opens empty, awaiting a
|
|
161
|
+
* clause (#226/#201).
|
|
206
162
|
*/
|
|
207
163
|
export const SemanticUnavailable: Story = {
|
|
208
164
|
name: "Semantic Unavailable",
|
|
209
165
|
render: () => (
|
|
210
166
|
<SheetStage>
|
|
211
|
-
<
|
|
167
|
+
<OrganizeRuleEditor
|
|
212
168
|
accountId={ACCOUNT_ID}
|
|
213
|
-
mailboxId="mbx-inbox"
|
|
214
169
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
215
|
-
|
|
216
|
-
anchorMessageId: "msg-1",
|
|
217
|
-
matchOperator: "And",
|
|
218
|
-
literalClauses: [],
|
|
219
|
-
}}
|
|
220
|
-
matchedCount={0}
|
|
170
|
+
seedCount={0}
|
|
221
171
|
semanticUnavailable
|
|
222
172
|
onClose={() => undefined}
|
|
223
173
|
/>
|
|
@@ -226,29 +176,19 @@ export const SemanticUnavailable: Story = {
|
|
|
226
176
|
};
|
|
227
177
|
|
|
228
178
|
/**
|
|
229
|
-
* No vector pipeline, but the selection has senders to match on — the widen
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* (including the standing filter) carries those clauses, so it reaches the
|
|
234
|
-
* widened set and keeps working on future mail.
|
|
179
|
+
* No vector pipeline, but the selection has senders to match on — the widen fell
|
|
180
|
+
* back to the sender `From` clauses (#251). The senders span different domains,
|
|
181
|
+
* so the fallback keeps one editable `From` chip per address, combined with
|
|
182
|
+
* "or". Every commit carries exactly these clauses.
|
|
235
183
|
*/
|
|
236
184
|
export const SenderFallback: Story = {
|
|
237
185
|
name: "Sender Fallback",
|
|
238
186
|
render: () => (
|
|
239
187
|
<SheetStage>
|
|
240
|
-
<
|
|
188
|
+
<OrganizeRuleEditor
|
|
241
189
|
accountId={ACCOUNT_ID}
|
|
242
|
-
mailboxId="mbx-inbox"
|
|
243
190
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
244
|
-
|
|
245
|
-
matchOperator: "Or",
|
|
246
|
-
literalClauses: [
|
|
247
|
-
{ field: "From", value: "npm@github.com" },
|
|
248
|
-
{ field: "From", value: "noreply@medium.com" },
|
|
249
|
-
],
|
|
250
|
-
}}
|
|
251
|
-
matchedCount={128}
|
|
191
|
+
seedCount={128}
|
|
252
192
|
semanticUnavailable
|
|
253
193
|
senders={["npm@github.com", "noreply@medium.com"]}
|
|
254
194
|
onClose={() => undefined}
|
|
@@ -260,23 +200,17 @@ export const SenderFallback: Story = {
|
|
|
260
200
|
/**
|
|
261
201
|
* The sender fallback where every selected sender shares one registrable domain
|
|
262
202
|
* (RFC 038 D2): the per-address `From` chips collapse to a single `FromDomain`
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* `github.com.evil.example`.
|
|
203
|
+
* chip — "anyone at github.com" — rather than listing addresses. Matches
|
|
204
|
+
* `sub.github.com` too, never a look-alike like `github.com.evil.example`.
|
|
266
205
|
*/
|
|
267
206
|
export const SenderFallbackDomain: Story = {
|
|
268
207
|
name: "Sender Fallback (domain)",
|
|
269
208
|
render: () => (
|
|
270
209
|
<SheetStage>
|
|
271
|
-
<
|
|
210
|
+
<OrganizeRuleEditor
|
|
272
211
|
accountId={ACCOUNT_ID}
|
|
273
|
-
mailboxId="mbx-inbox"
|
|
274
212
|
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
275
|
-
|
|
276
|
-
matchOperator: "Or",
|
|
277
|
-
literalClauses: [{ field: "FromDomain", value: "github.com" }],
|
|
278
|
-
}}
|
|
279
|
-
matchedCount={342}
|
|
213
|
+
seedCount={342}
|
|
280
214
|
semanticUnavailable
|
|
281
215
|
senders={[
|
|
282
216
|
"npm@github.com",
|
|
@@ -289,29 +223,15 @@ export const SenderFallbackDomain: Story = {
|
|
|
289
223
|
),
|
|
290
224
|
};
|
|
291
225
|
|
|
292
|
-
/**
|
|
293
|
-
* The sender fallback with a standing rule pre-selected — the "Always keep mail
|
|
294
|
-
* from these senders" sentence, the filter that will match future mail at index
|
|
295
|
-
* time.
|
|
296
|
-
*/
|
|
226
|
+
/** The sender fallback with a standing rule pre-selected. */
|
|
297
227
|
export const SenderFallbackStanding: Story = {
|
|
298
228
|
name: "Sender Fallback (standing)",
|
|
299
229
|
render: () => (
|
|
300
230
|
<SheetStage>
|
|
301
|
-
<
|
|
231
|
+
<OrganizeRuleEditor
|
|
302
232
|
accountId={ACCOUNT_ID}
|
|
303
|
-
mailboxId="mbx-inbox"
|
|
304
233
|
selectedMessageIds={["msg-1", "msg-2", "msg-3", "msg-4"]}
|
|
305
|
-
|
|
306
|
-
matchOperator: "Or",
|
|
307
|
-
literalClauses: [
|
|
308
|
-
{ field: "From", value: "npm@github.com" },
|
|
309
|
-
{ field: "From", value: "notifications@github.com" },
|
|
310
|
-
{ field: "From", value: "noreply@medium.com" },
|
|
311
|
-
{ field: "From", value: "digest@substack.com" },
|
|
312
|
-
],
|
|
313
|
-
}}
|
|
314
|
-
matchedCount={412}
|
|
234
|
+
seedCount={412}
|
|
315
235
|
semanticUnavailable
|
|
316
236
|
senders={[
|
|
317
237
|
"npm@github.com",
|
|
@@ -319,7 +239,7 @@ export const SenderFallbackStanding: Story = {
|
|
|
319
239
|
"noreply@medium.com",
|
|
320
240
|
"digest@substack.com",
|
|
321
241
|
]}
|
|
322
|
-
|
|
242
|
+
seedScope="standing"
|
|
323
243
|
seedMailboxId="mbx-archive"
|
|
324
244
|
onClose={() => undefined}
|
|
325
245
|
/>
|