@remit/web-client 0.0.56 → 0.0.57
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 +49 -3
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +45 -0
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +138 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +14 -1
- package/src/components/mail/organize/OrganizePanel.tsx +30 -4
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +54 -0
- package/src/components/mail/organize/SomethingElsePanel.tsx +159 -0
- package/src/components/mail/organize/smart-organize.stories.tsx +197 -0
- package/src/lib/organize/mobile-organize-flow.test.ts +96 -0
- package/src/lib/organize/mobile-organize-flow.ts +73 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.57",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
} from "@/lib/selection-sheet-mode";
|
|
54
54
|
import { cn } from "@/lib/utils";
|
|
55
55
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
56
|
+
import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
|
|
56
57
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
57
58
|
import { SelectionToolbar } from "./SelectionToolbar";
|
|
58
59
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
@@ -221,6 +222,12 @@ export const MessageList = ({
|
|
|
221
222
|
const navigate = useNavigate();
|
|
222
223
|
const isDesktop = useIsDesktop();
|
|
223
224
|
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
225
|
+
// Mobile only: which selection-sheet entry opened the guided organize flow
|
|
226
|
+
// (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
|
|
227
|
+
// instead — a sheet is a touch pattern (issue #211).
|
|
228
|
+
const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
|
|
229
|
+
"select-similar" | "something-else" | null
|
|
230
|
+
>(null);
|
|
224
231
|
const isSearching = !!searchQuery?.trim();
|
|
225
232
|
|
|
226
233
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
@@ -275,6 +282,13 @@ export const MessageList = ({
|
|
|
275
282
|
// it back to the count, and across that render the two disagree.
|
|
276
283
|
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
277
284
|
|
|
285
|
+
// When the selection empties — cancel, a completed action, a mailbox switch —
|
|
286
|
+
// the guided organize entry must reset too, so a later re-selection doesn't
|
|
287
|
+
// reopen the flow on a stale entry (issue #211).
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (selectedCount === 0) setMobileOrganizeEntry(null);
|
|
290
|
+
}, [selectedCount]);
|
|
291
|
+
|
|
278
292
|
// Pending delete, awaiting confirmation. `null` means the dialog is closed.
|
|
279
293
|
// `source: "ids"` snapshots the concrete ids at request time so a selection
|
|
280
294
|
// change behind the dialog can't retarget the delete — every keyboard/desktop
|
|
@@ -1227,6 +1241,24 @@ export const MessageList = ({
|
|
|
1227
1241
|
/>
|
|
1228
1242
|
) : undefined;
|
|
1229
1243
|
|
|
1244
|
+
// The guided organize flow (issue #211) overlays the same positioned pane
|
|
1245
|
+
// ancestor as the sheet, so it is rendered from the sheet slot. The sheet
|
|
1246
|
+
// stays mounted underneath as the flow's dimmed backdrop.
|
|
1247
|
+
const mobileOrganizeFlow =
|
|
1248
|
+
mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
|
|
1249
|
+
<MobileOrganizeFlow
|
|
1250
|
+
entry={mobileOrganizeEntry}
|
|
1251
|
+
accountId={accountId}
|
|
1252
|
+
mailboxId={mailboxId}
|
|
1253
|
+
selectedMessageIds={Array.from(selectedIds)}
|
|
1254
|
+
junkMailboxId={junkMailboxId}
|
|
1255
|
+
onClose={() => {
|
|
1256
|
+
setMobileOrganizeEntry(null);
|
|
1257
|
+
exitSelection();
|
|
1258
|
+
}}
|
|
1259
|
+
/>
|
|
1260
|
+
) : undefined;
|
|
1261
|
+
|
|
1230
1262
|
const mobileSelectionSheet = showMobileSheet ? (
|
|
1231
1263
|
<SelectionSheet
|
|
1232
1264
|
count={mobileCount}
|
|
@@ -1239,8 +1271,12 @@ export const MessageList = ({
|
|
|
1239
1271
|
: undefined
|
|
1240
1272
|
}
|
|
1241
1273
|
onMarkRead={handleMarkAsRead}
|
|
1242
|
-
onSelectSimilar={
|
|
1243
|
-
|
|
1274
|
+
onSelectSimilar={
|
|
1275
|
+
accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
|
|
1276
|
+
}
|
|
1277
|
+
onSomethingElse={
|
|
1278
|
+
accountId ? () => setMobileOrganizeEntry("something-else") : undefined
|
|
1279
|
+
}
|
|
1244
1280
|
moveSlot={mobileMoveSlot}
|
|
1245
1281
|
isBusy={mobileIsBusy}
|
|
1246
1282
|
selectAll={mobileSelectAll}
|
|
@@ -1258,6 +1294,16 @@ export const MessageList = ({
|
|
|
1258
1294
|
/>
|
|
1259
1295
|
) : undefined;
|
|
1260
1296
|
|
|
1297
|
+
// Both mobile overlays share the pane's positioned ancestor via the sheet
|
|
1298
|
+
// slot: the peeking sheet, and the guided organize flow above it.
|
|
1299
|
+
const mobileSelectionSurface =
|
|
1300
|
+
mobileSelectionSheet || mobileOrganizeFlow ? (
|
|
1301
|
+
<>
|
|
1302
|
+
{mobileSelectionSheet}
|
|
1303
|
+
{mobileOrganizeFlow}
|
|
1304
|
+
</>
|
|
1305
|
+
) : undefined;
|
|
1306
|
+
|
|
1261
1307
|
const activeSelectionBar = desktopSelectionBar;
|
|
1262
1308
|
|
|
1263
1309
|
// Roving tabindex: exactly one row is in the tab order, so Tab moves focus
|
|
@@ -1401,7 +1447,7 @@ export const MessageList = ({
|
|
|
1401
1447
|
isDesktop={isDesktop}
|
|
1402
1448
|
hideHeader={hideHeader}
|
|
1403
1449
|
selectionBar={activeSelectionBar}
|
|
1404
|
-
selectionSheet={
|
|
1450
|
+
selectionSheet={mobileSelectionSurface}
|
|
1405
1451
|
listBody={listState === "ready" ? virtualBody : undefined}
|
|
1406
1452
|
/>
|
|
1407
1453
|
<ConfirmDialog
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import React, { createElement } from "react";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
7
|
+
import type { OrganizeEntry } from "@/lib/organize/mobile-organize-flow";
|
|
8
|
+
import { MobileOrganizeFlow } from "./MobileOrganizeFlow";
|
|
9
|
+
|
|
10
|
+
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
11
|
+
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
12
|
+
// so this shim only exists for the SSR test harness.
|
|
13
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
14
|
+
|
|
15
|
+
const render = (entry: OrganizeEntry) =>
|
|
16
|
+
renderToString(
|
|
17
|
+
createElement(
|
|
18
|
+
QueryClientProvider,
|
|
19
|
+
{ client: new QueryClient() },
|
|
20
|
+
createElement(
|
|
21
|
+
ErrorBannerProvider,
|
|
22
|
+
null,
|
|
23
|
+
createElement(MobileOrganizeFlow, {
|
|
24
|
+
entry,
|
|
25
|
+
accountId: "acc-1",
|
|
26
|
+
mailboxId: "mbx-inbox",
|
|
27
|
+
selectedMessageIds: ["msg-1", "msg-2"],
|
|
28
|
+
onClose: () => undefined,
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
) as never,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
describe("MobileOrganizeFlow", () => {
|
|
35
|
+
it("select-similar opens on the widening state before the preview resolves", () => {
|
|
36
|
+
const html = render("select-similar");
|
|
37
|
+
assert.match(html, /Finding similar messages/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("something-else opens on the shortcuts + plain-language input", () => {
|
|
41
|
+
const html = render("something-else");
|
|
42
|
+
assert.match(html, /What should Remit do\?/);
|
|
43
|
+
assert.match(html, /Tell Remit what to do/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { BottomSheet, Button } from "@remit/ui";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { useOrganizePreview } from "@/hooks/useOrganizePreview";
|
|
7
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
8
|
+
import { buildMoveTargets } from "@/lib/move-targets";
|
|
9
|
+
import {
|
|
10
|
+
type OrganizeEntry,
|
|
11
|
+
type OrganizeSeed,
|
|
12
|
+
type PreviewStatus,
|
|
13
|
+
resolveOrganizeStage,
|
|
14
|
+
} from "@/lib/organize/mobile-organize-flow";
|
|
15
|
+
import { OrganizePanel } from "./OrganizePanel";
|
|
16
|
+
import { SomethingElsePanel } from "./SomethingElsePanel";
|
|
17
|
+
|
|
18
|
+
interface MobileOrganizeFlowProps {
|
|
19
|
+
entry: OrganizeEntry;
|
|
20
|
+
accountId: string;
|
|
21
|
+
mailboxId: string;
|
|
22
|
+
selectedMessageIds: string[];
|
|
23
|
+
junkMailboxId?: string;
|
|
24
|
+
/** Close the flow and return to the list — dismiss, "Not now", and Done all use it. */
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const previewStatusOf = (
|
|
29
|
+
isError: boolean,
|
|
30
|
+
isPending: boolean,
|
|
31
|
+
matchedCount: number | undefined,
|
|
32
|
+
): PreviewStatus => {
|
|
33
|
+
if (isError) return "error";
|
|
34
|
+
if (isPending) return "pending";
|
|
35
|
+
return matchedCount !== undefined ? "success" : "idle";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The guided select-similar → organize flow, the mobile home for organizing
|
|
40
|
+
* (issue #211). Entered from the selection sheet, it widens the selection once
|
|
41
|
+
* with the read-only matcher (POST /organize/preview), shows a brief widening
|
|
42
|
+
* state, and renders the organize sentence inside a bottom sheet on that
|
|
43
|
+
* widened set — the same {@link OrganizePanel} the desktop dialog uses, so the
|
|
44
|
+
* two never drift. "Something else" collects a folder/scope seed first; a widen
|
|
45
|
+
* that matches nothing falls back to organizing the selection. Desktop keeps
|
|
46
|
+
* its `OrganizeDialog` — this is the touch surface only.
|
|
47
|
+
*/
|
|
48
|
+
export function MobileOrganizeFlow({
|
|
49
|
+
entry,
|
|
50
|
+
accountId,
|
|
51
|
+
mailboxId,
|
|
52
|
+
selectedMessageIds,
|
|
53
|
+
junkMailboxId,
|
|
54
|
+
onClose,
|
|
55
|
+
}: MobileOrganizeFlowProps) {
|
|
56
|
+
const anchorMessageId = selectedMessageIds[0];
|
|
57
|
+
const [seed, setSeed] = useState<OrganizeSeed | undefined>();
|
|
58
|
+
|
|
59
|
+
const { preview, matchedCount, isPending, isError, error } =
|
|
60
|
+
useOrganizePreview(accountId);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!anchorMessageId) return;
|
|
64
|
+
preview({ anchorMessageId, matchOperator: "And", literalClauses: [] });
|
|
65
|
+
}, [anchorMessageId, preview]);
|
|
66
|
+
|
|
67
|
+
const { data: mailboxesData } = useQuery({
|
|
68
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
69
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const folderOptions = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
|
|
75
|
+
id: mailbox.mailboxId,
|
|
76
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
77
|
+
})),
|
|
78
|
+
[mailboxesData?.items],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const stage = resolveOrganizeStage({
|
|
82
|
+
entry,
|
|
83
|
+
hasSeed: seed !== undefined,
|
|
84
|
+
previewStatus: previewStatusOf(isError, isPending, matchedCount),
|
|
85
|
+
matchedCount,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<BottomSheet open onClose={onClose} dismissLabel="Dismiss organize">
|
|
90
|
+
{stage.kind === "something-else" && (
|
|
91
|
+
<SomethingElsePanel
|
|
92
|
+
folderOptions={folderOptions}
|
|
93
|
+
junkMailboxId={junkMailboxId}
|
|
94
|
+
onSeed={setSeed}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{stage.kind === "widening" && <WideningState />}
|
|
99
|
+
|
|
100
|
+
{stage.kind === "error" && (
|
|
101
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
102
|
+
<p className="text-sm font-medium text-danger">
|
|
103
|
+
Couldn't find similar messages
|
|
104
|
+
</p>
|
|
105
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
106
|
+
{error instanceof Error ? error.message : "Please try again."}
|
|
107
|
+
</p>
|
|
108
|
+
<Button variant="ghost" onClick={onClose} className="mt-2">
|
|
109
|
+
Close
|
|
110
|
+
</Button>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{stage.kind === "organize" && (
|
|
115
|
+
<OrganizePanel
|
|
116
|
+
accountId={accountId}
|
|
117
|
+
mailboxId={mailboxId}
|
|
118
|
+
selectedMessageIds={selectedMessageIds}
|
|
119
|
+
anchorMessageId={anchorMessageId}
|
|
120
|
+
matchedCount={stage.matchedCount}
|
|
121
|
+
initialScope={seed?.scope}
|
|
122
|
+
seedMailboxId={seed?.moveMailboxId}
|
|
123
|
+
fallback={stage.fallback}
|
|
124
|
+
onClose={onClose}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
</BottomSheet>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function WideningState() {
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
134
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
135
|
+
<p className="text-sm font-medium text-fg">Finding similar messages…</p>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -14,7 +14,7 @@ import { OrganizePanel } from "./OrganizePanel";
|
|
|
14
14
|
// so this shim only exists for the SSR test harness.
|
|
15
15
|
(globalThis as { React?: typeof React }).React = React;
|
|
16
16
|
|
|
17
|
-
const render = () =>
|
|
17
|
+
const render = (overrides: Partial<Parameters<typeof OrganizePanel>[0]> = {}) =>
|
|
18
18
|
renderToString(
|
|
19
19
|
createElement(
|
|
20
20
|
QueryClientProvider,
|
|
@@ -29,6 +29,7 @@ const render = () =>
|
|
|
29
29
|
anchorMessageId: "msg-1",
|
|
30
30
|
matchedCount: 47,
|
|
31
31
|
onClose: () => undefined,
|
|
32
|
+
...overrides,
|
|
32
33
|
}),
|
|
33
34
|
),
|
|
34
35
|
) as never,
|
|
@@ -54,6 +55,18 @@ describe("OrganizePanel", () => {
|
|
|
54
55
|
assert.match(html, /These and new mail like this/);
|
|
55
56
|
assert.match(html, /Until a date/);
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
it("says it is organizing just the selection when the widen matched nothing (#211 — no dead end)", () => {
|
|
60
|
+
const html = render({ matchedCount: 0, fallback: true });
|
|
61
|
+
assert.match(html, /No similar messages found/);
|
|
62
|
+
assert.match(html, /organizing just your 2 selected/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
|
|
66
|
+
const html = render({ initialScope: "standing" });
|
|
67
|
+
// The standing scope's "Always keep" phrasing only renders when it is active.
|
|
68
|
+
assert.match(html, /Always keep/);
|
|
69
|
+
});
|
|
57
70
|
});
|
|
58
71
|
|
|
59
72
|
// The commit button's `disabled` prop is
|
|
@@ -26,6 +26,22 @@ interface OrganizePanelProps {
|
|
|
26
26
|
anchorMessageId: string;
|
|
27
27
|
/** Similar messages the widen preview matched. */
|
|
28
28
|
matchedCount: number;
|
|
29
|
+
/**
|
|
30
|
+
* Which scope is selected on open. Defaults to `all-like-these`; the
|
|
31
|
+
* zero-match fallback and a "Something else" seed override it.
|
|
32
|
+
*/
|
|
33
|
+
initialScope?: OrganizeScope;
|
|
34
|
+
/**
|
|
35
|
+
* Destination folder to pre-select — a "Something else" shortcut or a
|
|
36
|
+
* plain-language input seeds this (issue #211).
|
|
37
|
+
*/
|
|
38
|
+
seedMailboxId?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The widen matched nothing, so the sentence organizes just the selection
|
|
41
|
+
* rather than dead-ending. Changes the heading and defaults the scope to
|
|
42
|
+
* `just-these`.
|
|
43
|
+
*/
|
|
44
|
+
fallback?: boolean;
|
|
29
45
|
onClose: () => void;
|
|
30
46
|
}
|
|
31
47
|
|
|
@@ -58,10 +74,17 @@ export function OrganizePanel({
|
|
|
58
74
|
selectedMessageIds,
|
|
59
75
|
anchorMessageId,
|
|
60
76
|
matchedCount,
|
|
77
|
+
initialScope,
|
|
78
|
+
seedMailboxId,
|
|
79
|
+
fallback = false,
|
|
61
80
|
onClose,
|
|
62
81
|
}: OrganizePanelProps) {
|
|
63
|
-
const [scope, setScope] = useState<OrganizeScope>(
|
|
64
|
-
|
|
82
|
+
const [scope, setScope] = useState<OrganizeScope>(
|
|
83
|
+
initialScope ?? (fallback ? "just-these" : "all-like-these"),
|
|
84
|
+
);
|
|
85
|
+
const [moveMailboxId, setMoveMailboxId] = useState<string>(
|
|
86
|
+
seedMailboxId ?? "",
|
|
87
|
+
);
|
|
65
88
|
const [name, setName] = useState("");
|
|
66
89
|
const [pickedDate, setPickedDate] = useState("");
|
|
67
90
|
|
|
@@ -155,8 +178,11 @@ export function OrganizePanel({
|
|
|
155
178
|
<div className="border-b border-line px-5 py-3">
|
|
156
179
|
<h2 className="text-sm font-semibold text-fg">Organize</h2>
|
|
157
180
|
<p className="mt-0.5 text-xs text-fg-muted">
|
|
158
|
-
{
|
|
159
|
-
|
|
181
|
+
{fallback
|
|
182
|
+
? `No similar messages found — organizing just your ${selectionCount} selected.`
|
|
183
|
+
: `${matchedCount} similar message${matchedCount === 1 ? "" : "s"} found${
|
|
184
|
+
selectionCount > 0 ? ` from ${selectionCount} selected` : ""
|
|
185
|
+
}.`}
|
|
160
186
|
</p>
|
|
161
187
|
</div>
|
|
162
188
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import React, { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
|
|
6
|
+
import { type FolderOption, SomethingElsePanel } from "./SomethingElsePanel";
|
|
7
|
+
|
|
8
|
+
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
9
|
+
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
10
|
+
// so this shim only exists for the SSR test harness.
|
|
11
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
12
|
+
|
|
13
|
+
const FOLDERS: FolderOption[] = [
|
|
14
|
+
{ id: "mbx-inbox", label: "Inbox" },
|
|
15
|
+
{ id: "mbx-archive", label: "Archive" },
|
|
16
|
+
{ id: "mbx-junk", label: "Spam" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const render = (
|
|
20
|
+
folderOptions: FolderOption[] = FOLDERS,
|
|
21
|
+
junkMailboxId?: string,
|
|
22
|
+
) =>
|
|
23
|
+
renderToString(
|
|
24
|
+
createElement(SomethingElsePanel, {
|
|
25
|
+
folderOptions,
|
|
26
|
+
junkMailboxId,
|
|
27
|
+
onSeed: (_seed: OrganizeSeed) => undefined,
|
|
28
|
+
}) as never,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
describe("SomethingElsePanel", () => {
|
|
32
|
+
it("shows the plain-language prompt and input", () => {
|
|
33
|
+
const html = render();
|
|
34
|
+
assert.match(html, /What should Remit do\?/);
|
|
35
|
+
assert.match(html, /Tell Remit what to do/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("derives shortcuts from the account's real folders", () => {
|
|
39
|
+
const html = render();
|
|
40
|
+
assert.match(html, /Always keep in Inbox/);
|
|
41
|
+
assert.match(html, /File in Archive/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("offers the appointed Junk mailbox by its own label", () => {
|
|
45
|
+
const html = render(FOLDERS, "mbx-junk");
|
|
46
|
+
assert.match(html, /Move to Spam/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("offers no folder shortcuts when the account has none", () => {
|
|
50
|
+
const html = render([]);
|
|
51
|
+
assert.doesNotMatch(html, /Always keep in Inbox/);
|
|
52
|
+
assert.doesNotMatch(html, /File in Archive/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Button, Input } from "@remit/ui";
|
|
2
|
+
import { ArrowUp } from "lucide-react";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
|
|
5
|
+
|
|
6
|
+
export interface FolderOption {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SomethingElsePanelProps {
|
|
12
|
+
/** The account's move destinations, the same set the sentence's folder picker uses. */
|
|
13
|
+
folderOptions: FolderOption[];
|
|
14
|
+
/** The appointed Junk mailbox, offered as a shortcut when present. */
|
|
15
|
+
junkMailboxId?: string;
|
|
16
|
+
/** Chosen shortcut or parsed input — seeds the organize sentence's folder/scope. */
|
|
17
|
+
onSeed: (seed: OrganizeSeed) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Shortcut {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
seed: OrganizeSeed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const findFolder = (
|
|
27
|
+
folderOptions: FolderOption[],
|
|
28
|
+
label: string,
|
|
29
|
+
): FolderOption | undefined =>
|
|
30
|
+
folderOptions.find((folder) => folder.label.toLowerCase() === label);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Shortcuts are derived from the account's real folders — there is no
|
|
34
|
+
* suggestion endpoint, so the panel offers the moves it can actually commit:
|
|
35
|
+
* keep it in the Inbox as a standing rule, file it in Archive, or send it to
|
|
36
|
+
* the appointed Junk mailbox. Each seeds the organize sentence; the user still
|
|
37
|
+
* confirms the scope.
|
|
38
|
+
*/
|
|
39
|
+
const buildShortcuts = (
|
|
40
|
+
folderOptions: FolderOption[],
|
|
41
|
+
junkMailboxId: string | undefined,
|
|
42
|
+
): Shortcut[] => {
|
|
43
|
+
const shortcuts: Shortcut[] = [];
|
|
44
|
+
const inbox = findFolder(folderOptions, "inbox");
|
|
45
|
+
if (inbox) {
|
|
46
|
+
shortcuts.push({
|
|
47
|
+
id: "keep-inbox",
|
|
48
|
+
label: "Always keep in Inbox",
|
|
49
|
+
seed: { moveMailboxId: inbox.id, scope: "standing" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const archive = findFolder(folderOptions, "archive");
|
|
53
|
+
if (archive) {
|
|
54
|
+
shortcuts.push({
|
|
55
|
+
id: "file-archive",
|
|
56
|
+
label: "File in Archive",
|
|
57
|
+
seed: { moveMailboxId: archive.id },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const junk = junkMailboxId
|
|
61
|
+
? folderOptions.find((folder) => folder.id === junkMailboxId)
|
|
62
|
+
: undefined;
|
|
63
|
+
if (junk) {
|
|
64
|
+
shortcuts.push({
|
|
65
|
+
id: "move-junk",
|
|
66
|
+
label: `Move to ${junk.label}`,
|
|
67
|
+
seed: { moveMailboxId: junk.id },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return shortcuts;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads a plain-language instruction into a seed without an NLP endpoint: it
|
|
75
|
+
* matches the typed words against the account's own folder names and reads an
|
|
76
|
+
* "always"/"keep" phrasing as the standing scope. Whatever it can't resolve to
|
|
77
|
+
* a folder still opens the sentence, where the picker is one tap away — never a
|
|
78
|
+
* dead end.
|
|
79
|
+
*/
|
|
80
|
+
const parseInstruction = (
|
|
81
|
+
text: string,
|
|
82
|
+
folderOptions: FolderOption[],
|
|
83
|
+
): OrganizeSeed => {
|
|
84
|
+
const lower = text.toLowerCase();
|
|
85
|
+
const folder = folderOptions.find((option) =>
|
|
86
|
+
lower.includes(option.label.toLowerCase()),
|
|
87
|
+
);
|
|
88
|
+
const standing = /\b(always|keep|future|from now)\b/.test(lower);
|
|
89
|
+
return {
|
|
90
|
+
moveMailboxId: folder?.id,
|
|
91
|
+
scope: standing ? "standing" : undefined,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The "Something else" fallback (issue #211): smart shortcuts plus a
|
|
97
|
+
* plain-language input, both of which seed the organize sentence's folder and
|
|
98
|
+
* scope. Presentational — the flow owns the folder data and what a seed does.
|
|
99
|
+
*/
|
|
100
|
+
export function SomethingElsePanel({
|
|
101
|
+
folderOptions,
|
|
102
|
+
junkMailboxId,
|
|
103
|
+
onSeed,
|
|
104
|
+
}: SomethingElsePanelProps) {
|
|
105
|
+
const [text, setText] = useState("");
|
|
106
|
+
const shortcuts = useMemo(
|
|
107
|
+
() => buildShortcuts(folderOptions, junkMailboxId),
|
|
108
|
+
[folderOptions, junkMailboxId],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const submit = () => {
|
|
112
|
+
const trimmed = text.trim();
|
|
113
|
+
if (!trimmed) return;
|
|
114
|
+
onSeed(parseInstruction(trimmed, folderOptions));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex min-h-0 flex-col">
|
|
119
|
+
<div className="border-b border-line px-5 py-3">
|
|
120
|
+
<h2 className="text-sm font-semibold text-fg">What should Remit do?</h2>
|
|
121
|
+
<p className="mt-0.5 text-xs text-fg-muted">
|
|
122
|
+
Pick a shortcut or say it in your own words.
|
|
123
|
+
</p>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-5 py-4">
|
|
127
|
+
{shortcuts.map((shortcut) => (
|
|
128
|
+
<Button
|
|
129
|
+
key={shortcut.id}
|
|
130
|
+
variant="secondary"
|
|
131
|
+
onClick={() => onSeed(shortcut.seed)}
|
|
132
|
+
className="h-12 w-full justify-start px-4"
|
|
133
|
+
>
|
|
134
|
+
{shortcut.label}
|
|
135
|
+
</Button>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className="flex items-center gap-2 border-t border-line px-5 py-3">
|
|
140
|
+
<Input
|
|
141
|
+
value={text}
|
|
142
|
+
onChange={(event) => setText(event.target.value)}
|
|
143
|
+
onKeyDown={(event) => event.key === "Enter" && submit()}
|
|
144
|
+
placeholder="Tell Remit what to do…"
|
|
145
|
+
aria-label="Tell Remit what to do"
|
|
146
|
+
className="flex-1"
|
|
147
|
+
/>
|
|
148
|
+
<Button
|
|
149
|
+
variant="primary"
|
|
150
|
+
aria-label="Send"
|
|
151
|
+
onClick={submit}
|
|
152
|
+
disabled={!text.trim()}
|
|
153
|
+
icon={<ArrowUp className="size-4" />}
|
|
154
|
+
className="size-9 shrink-0 px-0"
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
|
|
3
|
+
import { BottomSheet } from "@remit/ui";
|
|
4
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
8
|
+
import { OrganizePanel } from "./OrganizePanel";
|
|
9
|
+
import type { FolderOption } from "./SomethingElsePanel";
|
|
10
|
+
import { SomethingElsePanel } from "./SomethingElsePanel";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `Flows/Smart Organize` — the guided select-similar → organize flow (issue
|
|
14
|
+
* #211), rendered from the real web-client components, not a prototype copy:
|
|
15
|
+
* the same {@link OrganizePanel} the desktop dialog and the mobile sheet use,
|
|
16
|
+
* and the same {@link SomethingElsePanel} the flow seeds from. The prototype's
|
|
17
|
+
* interactive `Inbox` / `Walkthrough` stories live alongside these in the
|
|
18
|
+
* workbench; these show each in-sheet panel in isolation so they can't drift
|
|
19
|
+
* from what ships.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const ACCOUNT_ID = "acc-1";
|
|
23
|
+
|
|
24
|
+
const makeMailbox = (
|
|
25
|
+
mailboxId: string,
|
|
26
|
+
fullPath: string,
|
|
27
|
+
): RemitImapMailboxResponse => ({
|
|
28
|
+
mailboxId,
|
|
29
|
+
accountId: ACCOUNT_ID,
|
|
30
|
+
namespaceType: "personal",
|
|
31
|
+
namespacePrefix: "",
|
|
32
|
+
hierarchyDelimiter: "/",
|
|
33
|
+
fullPath,
|
|
34
|
+
messageCount: 0,
|
|
35
|
+
unseenCount: 0,
|
|
36
|
+
deletedCount: 0,
|
|
37
|
+
lastSyncUid: 0,
|
|
38
|
+
highWaterMarkUid: 0,
|
|
39
|
+
lastMessageSyncAt: 0,
|
|
40
|
+
createdAt: 0,
|
|
41
|
+
updatedAt: 0,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const MAILBOXES: RemitImapMailboxResponse[] = [
|
|
45
|
+
makeMailbox("mbx-inbox", "INBOX"),
|
|
46
|
+
makeMailbox("mbx-archive", "Archive"),
|
|
47
|
+
makeMailbox("mbx-receipts", "Receipts"),
|
|
48
|
+
makeMailbox("mbx-travel", "Travel"),
|
|
49
|
+
makeMailbox("mbx-junk", "Junk"),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const FOLDER_OPTIONS: FolderOption[] = [
|
|
53
|
+
{ id: "mbx-inbox", label: "Inbox" },
|
|
54
|
+
{ id: "mbx-archive", label: "Archive" },
|
|
55
|
+
{ id: "mbx-receipts", label: "Receipts" },
|
|
56
|
+
{ id: "mbx-junk", label: "Junk" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A QueryClient pre-seeded with the account's mailboxes, so the sentence's
|
|
61
|
+
* folder picker renders its real options without a network round-trip.
|
|
62
|
+
*/
|
|
63
|
+
function seededClient(): QueryClient {
|
|
64
|
+
const client = new QueryClient({
|
|
65
|
+
defaultOptions: { queries: { retry: false } },
|
|
66
|
+
});
|
|
67
|
+
client.setQueryData(
|
|
68
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
|
|
69
|
+
{ items: MAILBOXES },
|
|
70
|
+
);
|
|
71
|
+
return client;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Phone frame + a bottom sheet holding the panel, mirroring the mobile home. */
|
|
75
|
+
function SheetStage({ children }: { children: ReactNode }) {
|
|
76
|
+
return (
|
|
77
|
+
<QueryClientProvider client={seededClient()}>
|
|
78
|
+
<ErrorBannerProvider>
|
|
79
|
+
<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">
|
|
80
|
+
<div className="divide-y divide-line opacity-50">
|
|
81
|
+
{Array.from({ length: 8 }).map((_, index) => (
|
|
82
|
+
<div
|
|
83
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static backdrop skeleton, no ids
|
|
84
|
+
key={index}
|
|
85
|
+
className="flex items-start gap-3 px-row-inset py-2.5"
|
|
86
|
+
>
|
|
87
|
+
<div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
|
|
88
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
89
|
+
<div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
|
|
90
|
+
<div className="h-2 w-2/3 rounded bg-surface-sunken" />
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
<BottomSheet open onClose={() => undefined}>
|
|
96
|
+
{children}
|
|
97
|
+
</BottomSheet>
|
|
98
|
+
</div>
|
|
99
|
+
</ErrorBannerProvider>
|
|
100
|
+
</QueryClientProvider>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const meta: Meta = {
|
|
105
|
+
title: "Flows/Smart Organize",
|
|
106
|
+
parameters: { layout: "fullscreen" },
|
|
107
|
+
};
|
|
108
|
+
export default meta;
|
|
109
|
+
|
|
110
|
+
type Story = StoryObj;
|
|
111
|
+
|
|
112
|
+
/** The organize sentence on a widened selection — folder picker + four scopes. */
|
|
113
|
+
export const Organize: Story = {
|
|
114
|
+
render: () => (
|
|
115
|
+
<SheetStage>
|
|
116
|
+
<OrganizePanel
|
|
117
|
+
accountId={ACCOUNT_ID}
|
|
118
|
+
mailboxId="mbx-inbox"
|
|
119
|
+
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
120
|
+
anchorMessageId="msg-1"
|
|
121
|
+
matchedCount={47}
|
|
122
|
+
onClose={() => undefined}
|
|
123
|
+
/>
|
|
124
|
+
</SheetStage>
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A larger widen — the same sentence over a broad match set, as when the anchor
|
|
130
|
+
* is a common sender.
|
|
131
|
+
*/
|
|
132
|
+
export const FromSearch: Story = {
|
|
133
|
+
name: "From Search",
|
|
134
|
+
render: () => (
|
|
135
|
+
<SheetStage>
|
|
136
|
+
<OrganizePanel
|
|
137
|
+
accountId={ACCOUNT_ID}
|
|
138
|
+
mailboxId="mbx-inbox"
|
|
139
|
+
selectedMessageIds={["msg-1"]}
|
|
140
|
+
anchorMessageId="msg-1"
|
|
141
|
+
matchedCount={412}
|
|
142
|
+
seedMailboxId="mbx-archive"
|
|
143
|
+
onClose={() => undefined}
|
|
144
|
+
/>
|
|
145
|
+
</SheetStage>
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** The standing scope pre-selected — the "Always keep…" sentence + filter name. */
|
|
150
|
+
export const AlwaysRule: Story = {
|
|
151
|
+
name: "Always Rule",
|
|
152
|
+
render: () => (
|
|
153
|
+
<SheetStage>
|
|
154
|
+
<OrganizePanel
|
|
155
|
+
accountId={ACCOUNT_ID}
|
|
156
|
+
mailboxId="mbx-inbox"
|
|
157
|
+
selectedMessageIds={["msg-1", "msg-2"]}
|
|
158
|
+
anchorMessageId="msg-1"
|
|
159
|
+
matchedCount={47}
|
|
160
|
+
initialScope="standing"
|
|
161
|
+
seedMailboxId="mbx-travel"
|
|
162
|
+
onClose={() => undefined}
|
|
163
|
+
/>
|
|
164
|
+
</SheetStage>
|
|
165
|
+
),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** The widen matched nothing — the sentence organizes just the selection. */
|
|
169
|
+
export const NoSimilarFound: Story = {
|
|
170
|
+
name: "No Similar Found",
|
|
171
|
+
render: () => (
|
|
172
|
+
<SheetStage>
|
|
173
|
+
<OrganizePanel
|
|
174
|
+
accountId={ACCOUNT_ID}
|
|
175
|
+
mailboxId="mbx-inbox"
|
|
176
|
+
selectedMessageIds={["msg-1", "msg-2"]}
|
|
177
|
+
anchorMessageId="msg-1"
|
|
178
|
+
matchedCount={0}
|
|
179
|
+
fallback
|
|
180
|
+
onClose={() => undefined}
|
|
181
|
+
/>
|
|
182
|
+
</SheetStage>
|
|
183
|
+
),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** The "Something else" fallback: shortcuts derived from folders + an input. */
|
|
187
|
+
export const SomethingElse: Story = {
|
|
188
|
+
render: () => (
|
|
189
|
+
<SheetStage>
|
|
190
|
+
<SomethingElsePanel
|
|
191
|
+
folderOptions={FOLDER_OPTIONS}
|
|
192
|
+
junkMailboxId="mbx-junk"
|
|
193
|
+
onSeed={() => undefined}
|
|
194
|
+
/>
|
|
195
|
+
</SheetStage>
|
|
196
|
+
),
|
|
197
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
};
|