@remit/web-client 0.0.92 → 0.0.93
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 +47 -110
- package/src/components/mail/MessageList.selection.test.ts +10 -5
- package/src/components/mail/MessageList.tsx +78 -82
- package/src/components/mail/SelectionWizardHost.tsx +648 -60
- package/src/components/mail/ThreadListInteraction.tsx +0 -9
- package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
- 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 +23 -3
- 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 -0
- package/src/lib/wizard-history.ts +15 -0
- package/src/routes/mail.tsx +0 -7
- 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/SomethingElsePanel.render.test.ts +0 -54
- package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- 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,96 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
type OrganizeStageInput,
|
|
5
|
-
resolveOrganizeStage,
|
|
6
|
-
} from "./mobile-organize-flow";
|
|
7
|
-
|
|
8
|
-
const base: OrganizeStageInput = {
|
|
9
|
-
entry: "select-similar",
|
|
10
|
-
hasSeed: true,
|
|
11
|
-
previewStatus: "idle",
|
|
12
|
-
matchedCount: undefined,
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
describe("resolveOrganizeStage — the guided mobile flow's state machine", () => {
|
|
16
|
-
it("select-similar shows the widening state before the preview resolves", () => {
|
|
17
|
-
assert.deepEqual(resolveOrganizeStage({ ...base, previewStatus: "idle" }), {
|
|
18
|
-
kind: "widening",
|
|
19
|
-
});
|
|
20
|
-
assert.deepEqual(
|
|
21
|
-
resolveOrganizeStage({ ...base, previewStatus: "pending" }),
|
|
22
|
-
{ kind: "widening" },
|
|
23
|
-
);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("opens the organize sentence on the widened set once the preview matches", () => {
|
|
27
|
-
assert.deepEqual(
|
|
28
|
-
resolveOrganizeStage({
|
|
29
|
-
...base,
|
|
30
|
-
previewStatus: "success",
|
|
31
|
-
matchedCount: 47,
|
|
32
|
-
}),
|
|
33
|
-
{ kind: "organize", matchedCount: 47, fallback: false },
|
|
34
|
-
);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("falls back to organizing the selection when the widen matches nothing — no dead end", () => {
|
|
38
|
-
assert.deepEqual(
|
|
39
|
-
resolveOrganizeStage({
|
|
40
|
-
...base,
|
|
41
|
-
previewStatus: "success",
|
|
42
|
-
matchedCount: 0,
|
|
43
|
-
}),
|
|
44
|
-
{ kind: "organize", matchedCount: 0, fallback: true },
|
|
45
|
-
);
|
|
46
|
-
// A success with an undefined count is treated as zero, not a crash.
|
|
47
|
-
assert.deepEqual(
|
|
48
|
-
resolveOrganizeStage({
|
|
49
|
-
...base,
|
|
50
|
-
previewStatus: "success",
|
|
51
|
-
matchedCount: undefined,
|
|
52
|
-
}),
|
|
53
|
-
{ kind: "organize", matchedCount: 0, fallback: true },
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("surfaces the error branch when the widen fails", () => {
|
|
58
|
-
assert.deepEqual(
|
|
59
|
-
resolveOrganizeStage({ ...base, previewStatus: "error" }),
|
|
60
|
-
{ kind: "error" },
|
|
61
|
-
);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("something-else shows the shortcuts + input until a seed is chosen", () => {
|
|
65
|
-
assert.deepEqual(
|
|
66
|
-
resolveOrganizeStage({
|
|
67
|
-
entry: "something-else",
|
|
68
|
-
hasSeed: false,
|
|
69
|
-
previewStatus: "success",
|
|
70
|
-
matchedCount: 47,
|
|
71
|
-
}),
|
|
72
|
-
{ kind: "something-else" },
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("something-else widens after the seed, then opens the seeded sentence", () => {
|
|
77
|
-
assert.deepEqual(
|
|
78
|
-
resolveOrganizeStage({
|
|
79
|
-
entry: "something-else",
|
|
80
|
-
hasSeed: true,
|
|
81
|
-
previewStatus: "pending",
|
|
82
|
-
matchedCount: undefined,
|
|
83
|
-
}),
|
|
84
|
-
{ kind: "widening" },
|
|
85
|
-
);
|
|
86
|
-
assert.deepEqual(
|
|
87
|
-
resolveOrganizeStage({
|
|
88
|
-
entry: "something-else",
|
|
89
|
-
hasSeed: true,
|
|
90
|
-
previewStatus: "success",
|
|
91
|
-
matchedCount: 12,
|
|
92
|
-
}),
|
|
93
|
-
{ kind: "organize", matchedCount: 12, fallback: false },
|
|
94
|
-
);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import type { OrganizeScope } from "./organize-model";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* How the guided mobile flow was entered from the selection sheet:
|
|
5
|
-
*
|
|
6
|
-
* - `select-similar` — widen straight into the organize sentence.
|
|
7
|
-
* - `something-else` — collect a folder/scope seed from shortcuts or a
|
|
8
|
-
* plain-language input first, then widen into the seeded sentence.
|
|
9
|
-
*/
|
|
10
|
-
export type OrganizeEntry = "select-similar" | "something-else";
|
|
11
|
-
|
|
12
|
-
/** The read-only widen preview's lifecycle (POST /organize/preview). */
|
|
13
|
-
export type PreviewStatus = "idle" | "pending" | "success" | "error";
|
|
14
|
-
|
|
15
|
-
/** A folder/scope the "Something else" panel pre-fills the sentence with. */
|
|
16
|
-
export interface OrganizeSeed {
|
|
17
|
-
moveMailboxId?: string;
|
|
18
|
-
scope?: OrganizeScope;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The stage the guided flow renders, resolved from the entry, whether a
|
|
23
|
-
* "Something else" seed has been chosen yet, and where the widen preview is:
|
|
24
|
-
*
|
|
25
|
-
* - `something-else` — the shortcuts + input, shown until a seed is picked.
|
|
26
|
-
* - `widening` — the brief widening state between the tap and the sentence.
|
|
27
|
-
* - `error` — the widen failed; a dead end only in the sense that it offers a
|
|
28
|
-
* close, never a broken sentence.
|
|
29
|
-
* - `organize` — the committed sentence, on the widened set. `fallback` is true
|
|
30
|
-
* when the widen matched nothing, so the sentence organizes just the
|
|
31
|
-
* selection instead of a dead end (issue #211).
|
|
32
|
-
*/
|
|
33
|
-
export type OrganizeStage =
|
|
34
|
-
| { kind: "something-else" }
|
|
35
|
-
| { kind: "widening" }
|
|
36
|
-
| { kind: "error" }
|
|
37
|
-
| { kind: "organize"; matchedCount: number; fallback: boolean };
|
|
38
|
-
|
|
39
|
-
export interface OrganizeStageInput {
|
|
40
|
-
entry: OrganizeEntry;
|
|
41
|
-
/** "Something else" only: whether the user has chosen a seed yet. */
|
|
42
|
-
hasSeed: boolean;
|
|
43
|
-
previewStatus: PreviewStatus;
|
|
44
|
-
/** The widen's matched total; defined once `previewStatus` is `success`. */
|
|
45
|
-
matchedCount: number | undefined;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The pure state machine behind the guided organize sheet:
|
|
50
|
-
* `idle → widening → organize` for select-similar, with a `something-else`
|
|
51
|
-
* seeding step in front for that entry, an `error` branch when the widen
|
|
52
|
-
* fails, and the zero-match `fallback` that keeps the organize sentence usable
|
|
53
|
-
* on the selection alone. Kept pure so every transition is testable without a
|
|
54
|
-
* DOM, React Query, or a network — the component only reads the stage back.
|
|
55
|
-
*/
|
|
56
|
-
export const resolveOrganizeStage = ({
|
|
57
|
-
entry,
|
|
58
|
-
hasSeed,
|
|
59
|
-
previewStatus,
|
|
60
|
-
matchedCount,
|
|
61
|
-
}: OrganizeStageInput): OrganizeStage => {
|
|
62
|
-
if (entry === "something-else" && !hasSeed) {
|
|
63
|
-
return { kind: "something-else" };
|
|
64
|
-
}
|
|
65
|
-
if (previewStatus === "error") {
|
|
66
|
-
return { kind: "error" };
|
|
67
|
-
}
|
|
68
|
-
if (previewStatus !== "success") {
|
|
69
|
-
return { kind: "widening" };
|
|
70
|
-
}
|
|
71
|
-
const matched = matchedCount ?? 0;
|
|
72
|
-
return { kind: "organize", matchedCount: matched, fallback: matched === 0 };
|
|
73
|
-
};
|