@remit/web-client 0.0.64 → 0.0.66
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 +2 -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 +57 -104
- package/src/hooks/useRulePreview.ts +86 -0
- package/src/lib/organize/rule-model.test.ts +297 -0
- package/src/lib/organize/rule-model.ts +205 -0
- package/src/lib/organize/sender-fallback.test.ts +64 -1
- package/src/lib/organize/sender-fallback.ts +38 -5
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +0 -113
- package/src/components/mail/organize/OrganizePanel.render.test.ts +0 -151
- package/src/components/mail/organize/OrganizePanel.tsx +0 -420
- package/src/lib/organize/organize-copy.test.ts +0 -134
- package/src/lib/organize/organize-copy.ts +0 -90
|
@@ -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,22 +155,20 @@ export const AlwaysRule: Story = {
|
|
|
177
155
|
),
|
|
178
156
|
};
|
|
179
157
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
158
|
+
/**
|
|
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).
|
|
162
|
+
*/
|
|
163
|
+
export const SemanticUnavailable: Story = {
|
|
164
|
+
name: "Semantic Unavailable",
|
|
183
165
|
render: () => (
|
|
184
166
|
<SheetStage>
|
|
185
|
-
<
|
|
167
|
+
<OrganizeRuleEditor
|
|
186
168
|
accountId={ACCOUNT_ID}
|
|
187
|
-
mailboxId="mbx-inbox"
|
|
188
169
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
matchOperator: "And",
|
|
192
|
-
literalClauses: [],
|
|
193
|
-
}}
|
|
194
|
-
matchedCount={0}
|
|
195
|
-
fallback
|
|
170
|
+
seedCount={0}
|
|
171
|
+
semanticUnavailable
|
|
196
172
|
onClose={() => undefined}
|
|
197
173
|
/>
|
|
198
174
|
</SheetStage>
|
|
@@ -200,25 +176,21 @@ export const NoSimilarFound: Story = {
|
|
|
200
176
|
};
|
|
201
177
|
|
|
202
178
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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.
|
|
206
183
|
*/
|
|
207
|
-
export const
|
|
208
|
-
name: "
|
|
184
|
+
export const SenderFallback: Story = {
|
|
185
|
+
name: "Sender Fallback",
|
|
209
186
|
render: () => (
|
|
210
187
|
<SheetStage>
|
|
211
|
-
<
|
|
188
|
+
<OrganizeRuleEditor
|
|
212
189
|
accountId={ACCOUNT_ID}
|
|
213
|
-
mailboxId="mbx-inbox"
|
|
214
190
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
215
|
-
|
|
216
|
-
anchorMessageId: "msg-1",
|
|
217
|
-
matchOperator: "And",
|
|
218
|
-
literalClauses: [],
|
|
219
|
-
}}
|
|
220
|
-
matchedCount={0}
|
|
191
|
+
seedCount={128}
|
|
221
192
|
semanticUnavailable
|
|
193
|
+
senders={["npm@github.com", "noreply@medium.com"]}
|
|
222
194
|
onClose={() => undefined}
|
|
223
195
|
/>
|
|
224
196
|
</SheetStage>
|
|
@@ -226,59 +198,40 @@ export const SemanticUnavailable: Story = {
|
|
|
226
198
|
};
|
|
227
199
|
|
|
228
200
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* set and keeps working on future mail.
|
|
201
|
+
* The sender fallback where every selected sender shares one registrable domain
|
|
202
|
+
* (RFC 038 D2): the per-address `From` chips collapse to a single `FromDomain`
|
|
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`.
|
|
234
205
|
*/
|
|
235
|
-
export const
|
|
236
|
-
name: "Sender Fallback",
|
|
206
|
+
export const SenderFallbackDomain: Story = {
|
|
207
|
+
name: "Sender Fallback (domain)",
|
|
237
208
|
render: () => (
|
|
238
209
|
<SheetStage>
|
|
239
|
-
<
|
|
210
|
+
<OrganizeRuleEditor
|
|
240
211
|
accountId={ACCOUNT_ID}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
matchPredicate={{
|
|
244
|
-
matchOperator: "Or",
|
|
245
|
-
literalClauses: [
|
|
246
|
-
{ field: "From", value: "npm@github.com" },
|
|
247
|
-
{ field: "From", value: "notifications@github.com" },
|
|
248
|
-
],
|
|
249
|
-
}}
|
|
250
|
-
matchedCount={128}
|
|
212
|
+
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
213
|
+
seedCount={342}
|
|
251
214
|
semanticUnavailable
|
|
252
|
-
senders={[
|
|
215
|
+
senders={[
|
|
216
|
+
"npm@github.com",
|
|
217
|
+
"notifications@github.com",
|
|
218
|
+
"ci@sub.github.com",
|
|
219
|
+
]}
|
|
253
220
|
onClose={() => undefined}
|
|
254
221
|
/>
|
|
255
222
|
</SheetStage>
|
|
256
223
|
),
|
|
257
224
|
};
|
|
258
225
|
|
|
259
|
-
/**
|
|
260
|
-
* The sender fallback with a standing rule pre-selected — the "Always keep mail
|
|
261
|
-
* from these senders" sentence, the filter that will match future mail at index
|
|
262
|
-
* time.
|
|
263
|
-
*/
|
|
226
|
+
/** The sender fallback with a standing rule pre-selected. */
|
|
264
227
|
export const SenderFallbackStanding: Story = {
|
|
265
228
|
name: "Sender Fallback (standing)",
|
|
266
229
|
render: () => (
|
|
267
230
|
<SheetStage>
|
|
268
|
-
<
|
|
231
|
+
<OrganizeRuleEditor
|
|
269
232
|
accountId={ACCOUNT_ID}
|
|
270
|
-
mailboxId="mbx-inbox"
|
|
271
233
|
selectedMessageIds={["msg-1", "msg-2", "msg-3", "msg-4"]}
|
|
272
|
-
|
|
273
|
-
matchOperator: "Or",
|
|
274
|
-
literalClauses: [
|
|
275
|
-
{ field: "From", value: "npm@github.com" },
|
|
276
|
-
{ field: "From", value: "notifications@github.com" },
|
|
277
|
-
{ field: "From", value: "noreply@medium.com" },
|
|
278
|
-
{ field: "From", value: "digest@substack.com" },
|
|
279
|
-
],
|
|
280
|
-
}}
|
|
281
|
-
matchedCount={412}
|
|
234
|
+
seedCount={412}
|
|
282
235
|
semanticUnavailable
|
|
283
236
|
senders={[
|
|
284
237
|
"npm@github.com",
|
|
@@ -286,7 +239,7 @@ export const SenderFallbackStanding: Story = {
|
|
|
286
239
|
"noreply@medium.com",
|
|
287
240
|
"digest@substack.com",
|
|
288
241
|
]}
|
|
289
|
-
|
|
242
|
+
seedScope="standing"
|
|
290
243
|
seedMailboxId="mbx-archive"
|
|
291
244
|
onClose={() => undefined}
|
|
292
245
|
/>
|