@remit/web-client 0.0.55 → 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.selection.test.ts +6 -5
- package/src/components/mail/MessageList.tsx +138 -54
- 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/src/lib/selection-sheet-mode.test.ts +105 -0
- package/src/lib/selection-sheet-mode.ts +49 -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": {
|
|
@@ -70,13 +70,14 @@ describe("MessageList escalated actions", () => {
|
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it("keeps mark-read and the move slot on an escalated selection", () => {
|
|
73
|
+
// The mobile sheet always carries the mark-read verb (it hides it itself
|
|
74
|
+
// while counting or busy); an escalated selection must never lose it.
|
|
75
|
+
assert.match(source, /onMarkRead=\{handleMarkAsRead\}/);
|
|
76
|
+
// The move slot is offered for a bounded selection or an escalated one —
|
|
77
|
+
// #114's rule that an escalated selection is never delete-only.
|
|
73
78
|
assert.match(
|
|
74
79
|
source,
|
|
75
|
-
/
|
|
76
|
-
);
|
|
77
|
-
assert.match(
|
|
78
|
-
source,
|
|
79
|
-
/moveSlot=\{\s*escalation\.phase\.kind !== "counting"/,
|
|
80
|
+
/onMoveMessages \|\| escalation\.phase\.kind === "escalated"/,
|
|
80
81
|
);
|
|
81
82
|
});
|
|
82
83
|
|
|
@@ -3,15 +3,17 @@ import {
|
|
|
3
3
|
Banner,
|
|
4
4
|
type Density,
|
|
5
5
|
MessageListPane,
|
|
6
|
-
|
|
6
|
+
SELECTION_SHEET_TEASER_HEIGHT,
|
|
7
|
+
SelectionSheet,
|
|
7
8
|
} from "@remit/ui";
|
|
8
9
|
import { useBlocker, useNavigate } from "@tanstack/react-router";
|
|
9
10
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
10
|
-
import { Search
|
|
11
|
+
import { Search } from "lucide-react";
|
|
11
12
|
import type { RefObject } from "react";
|
|
12
13
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
14
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
14
15
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
16
|
+
import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
15
17
|
import {
|
|
16
18
|
type EscalatedAction,
|
|
17
19
|
type EscalationSearchQuery,
|
|
@@ -45,8 +47,13 @@ import {
|
|
|
45
47
|
deriveIsMultiSelectMode,
|
|
46
48
|
shouldExitSelectionOnNavigate,
|
|
47
49
|
} from "@/lib/selection-mode";
|
|
50
|
+
import {
|
|
51
|
+
resolveSelectionSheetMode,
|
|
52
|
+
shouldShowSelectionSheet,
|
|
53
|
+
} from "@/lib/selection-sheet-mode";
|
|
48
54
|
import { cn } from "@/lib/utils";
|
|
49
55
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
56
|
+
import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
|
|
50
57
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
51
58
|
import { SelectionToolbar } from "./SelectionToolbar";
|
|
52
59
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
@@ -215,6 +222,12 @@ export const MessageList = ({
|
|
|
215
222
|
const navigate = useNavigate();
|
|
216
223
|
const isDesktop = useIsDesktop();
|
|
217
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);
|
|
218
231
|
const isSearching = !!searchQuery?.trim();
|
|
219
232
|
|
|
220
233
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
@@ -244,6 +257,10 @@ export const MessageList = ({
|
|
|
244
257
|
// Swipe-to-read toggle hook
|
|
245
258
|
const { toggleReadFor } = useToggleReadFor({ mailboxId });
|
|
246
259
|
|
|
260
|
+
// The Junk quick action moves the selection to the account's appointed Junk
|
|
261
|
+
// mailbox — the message-flags API has no `$Junk` field, so "junk" is a move.
|
|
262
|
+
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
263
|
+
|
|
247
264
|
// Selection state
|
|
248
265
|
const {
|
|
249
266
|
selectedIds,
|
|
@@ -265,6 +282,13 @@ export const MessageList = ({
|
|
|
265
282
|
// it back to the count, and across that render the two disagree.
|
|
266
283
|
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
267
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
|
+
|
|
268
292
|
// Pending delete, awaiting confirmation. `null` means the dialog is closed.
|
|
269
293
|
// `source: "ids"` snapshots the concrete ids at request time so a selection
|
|
270
294
|
// change behind the dialog can't retarget the delete — every keyboard/desktop
|
|
@@ -742,6 +766,14 @@ export const MessageList = ({
|
|
|
742
766
|
],
|
|
743
767
|
);
|
|
744
768
|
|
|
769
|
+
// Junk quick action (mobile sheet): move the selection to the appointed Junk
|
|
770
|
+
// mailbox. Reuses the same bounded/escalated move path as any other move, so
|
|
771
|
+
// the chunked run and the cross-account guard apply unchanged.
|
|
772
|
+
const handleJunk = useCallback(() => {
|
|
773
|
+
if (!junkMailboxId) return;
|
|
774
|
+
handleMoveSelected(junkMailboxId);
|
|
775
|
+
}, [junkMailboxId, handleMoveSelected]);
|
|
776
|
+
|
|
745
777
|
// Cross-account guard: every selected thread row must belong to the
|
|
746
778
|
// same account as the current mailbox. The list is already scoped to
|
|
747
779
|
// one mailbox so in practice this is always single-account, but we
|
|
@@ -1174,61 +1206,105 @@ export const MessageList = ({
|
|
|
1174
1206
|
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1175
1207
|
: undefined;
|
|
1176
1208
|
|
|
1177
|
-
// Mobile
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
<button
|
|
1210
|
-
type="button"
|
|
1211
|
-
onClick={() => setOrganizeOpen(true)}
|
|
1212
|
-
className="min-h-11 min-w-11 inline-flex shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
|
|
1213
|
-
aria-label="Organize similar messages"
|
|
1214
|
-
>
|
|
1215
|
-
<Sparkles className="size-4" />
|
|
1216
|
-
</button>
|
|
1217
|
-
)}
|
|
1218
|
-
<MoveToTrigger
|
|
1219
|
-
accountId={accountId}
|
|
1220
|
-
currentMailboxId={mailboxId}
|
|
1221
|
-
onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
|
|
1222
|
-
disabledHint={moveDisabledHint}
|
|
1223
|
-
label="Move selected messages"
|
|
1224
|
-
/>
|
|
1225
|
-
</>
|
|
1226
|
-
) : undefined
|
|
1227
|
-
}
|
|
1209
|
+
// Mobile: which content the peeking sheet routes to, from the escalation
|
|
1210
|
+
// machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
|
|
1211
|
+
// predicate still paging) replace the idle quick actions with status and
|
|
1212
|
+
// progress; `escalated` keeps the verbs over the whole predicate.
|
|
1213
|
+
const mobileMode = resolveSelectionSheetMode({
|
|
1214
|
+
isRunning: escalation.isRunning,
|
|
1215
|
+
isCounting: escalation.phase.kind === "counting",
|
|
1216
|
+
isEscalated: escalation.phase.kind === "escalated",
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
// The sheet is the sole mobile selection surface. It rises at 2+ selected
|
|
1220
|
+
// (the prototype threshold) or in any non-idle escalation state — a single
|
|
1221
|
+
// selected row enters selection mode without raising it, matching today.
|
|
1222
|
+
const showMobileSheet =
|
|
1223
|
+
!isDesktop &&
|
|
1224
|
+
isMultiSelectMode &&
|
|
1225
|
+
shouldShowSelectionSheet(
|
|
1226
|
+
mobileCount,
|
|
1227
|
+
mobileMode,
|
|
1228
|
+
mobileNotice !== undefined,
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
const mobileMoveSlot =
|
|
1232
|
+
(onMoveMessages || escalation.phase.kind === "escalated") &&
|
|
1233
|
+
accountId &&
|
|
1234
|
+
mailboxId ? (
|
|
1235
|
+
<MoveToTrigger
|
|
1236
|
+
accountId={accountId}
|
|
1237
|
+
currentMailboxId={mailboxId}
|
|
1238
|
+
onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
|
|
1239
|
+
disabledHint={moveDisabledHint}
|
|
1240
|
+
label="Move selected messages"
|
|
1228
1241
|
/>
|
|
1229
1242
|
) : undefined;
|
|
1230
1243
|
|
|
1231
|
-
|
|
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
|
+
|
|
1262
|
+
const mobileSelectionSheet = showMobileSheet ? (
|
|
1263
|
+
<SelectionSheet
|
|
1264
|
+
count={mobileCount}
|
|
1265
|
+
mode={mobileMode}
|
|
1266
|
+
onCancel={handleMobileCancel}
|
|
1267
|
+
onDelete={handleMobileDelete}
|
|
1268
|
+
onJunk={
|
|
1269
|
+
junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
|
|
1270
|
+
? handleJunk
|
|
1271
|
+
: undefined
|
|
1272
|
+
}
|
|
1273
|
+
onMarkRead={handleMarkAsRead}
|
|
1274
|
+
onSelectSimilar={
|
|
1275
|
+
accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
|
|
1276
|
+
}
|
|
1277
|
+
onSomethingElse={
|
|
1278
|
+
accountId ? () => setMobileOrganizeEntry("something-else") : undefined
|
|
1279
|
+
}
|
|
1280
|
+
moveSlot={mobileMoveSlot}
|
|
1281
|
+
isBusy={mobileIsBusy}
|
|
1282
|
+
selectAll={mobileSelectAll}
|
|
1283
|
+
statusLabel={mobileStatusLabel}
|
|
1284
|
+
progress={
|
|
1285
|
+
escalation.runningAction && escalation.progress
|
|
1286
|
+
? {
|
|
1287
|
+
value: escalation.progress.done,
|
|
1288
|
+
max: escalation.progress.total,
|
|
1289
|
+
tone: bulkActionProgressTone(escalation.runningAction.kind),
|
|
1290
|
+
}
|
|
1291
|
+
: undefined
|
|
1292
|
+
}
|
|
1293
|
+
notice={mobileNotice}
|
|
1294
|
+
/>
|
|
1295
|
+
) : undefined;
|
|
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
|
+
|
|
1307
|
+
const activeSelectionBar = desktopSelectionBar;
|
|
1232
1308
|
|
|
1233
1309
|
// Roving tabindex: exactly one row is in the tab order, so Tab moves focus
|
|
1234
1310
|
// into the list at the cursor and Shift+Tab moves back out to the side panel
|
|
@@ -1257,6 +1333,13 @@ export const MessageList = ({
|
|
|
1257
1333
|
aria-multiselectable
|
|
1258
1334
|
aria-label={listTitle}
|
|
1259
1335
|
className="flex-1 overflow-y-auto"
|
|
1336
|
+
// Pad the list so its last rows clear the peeking teaser (~56px)
|
|
1337
|
+
// rather than hiding behind it.
|
|
1338
|
+
style={
|
|
1339
|
+
showMobileSheet
|
|
1340
|
+
? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
|
|
1341
|
+
: undefined
|
|
1342
|
+
}
|
|
1260
1343
|
>
|
|
1261
1344
|
{/* Virtualizer scaffolding — presentational so the listbox sees the
|
|
1262
1345
|
rows as its options rather than these positioning wrappers. */}
|
|
@@ -1364,6 +1447,7 @@ export const MessageList = ({
|
|
|
1364
1447
|
isDesktop={isDesktop}
|
|
1365
1448
|
hideHeader={hideHeader}
|
|
1366
1449
|
selectionBar={activeSelectionBar}
|
|
1450
|
+
selectionSheet={mobileSelectionSurface}
|
|
1367
1451
|
listBody={listState === "ready" ? virtualBody : undefined}
|
|
1368
1452
|
/>
|
|
1369
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
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
resolveSelectionSheetMode,
|
|
5
|
+
SELECTION_SHEET_MIN_COUNT,
|
|
6
|
+
shouldShowSelectionSheet,
|
|
7
|
+
} from "./selection-sheet-mode";
|
|
8
|
+
|
|
9
|
+
describe("resolveSelectionSheetMode", () => {
|
|
10
|
+
test("is idle for a plain bounded selection", () => {
|
|
11
|
+
assert.equal(
|
|
12
|
+
resolveSelectionSheetMode({
|
|
13
|
+
isRunning: false,
|
|
14
|
+
isCounting: false,
|
|
15
|
+
isEscalated: false,
|
|
16
|
+
}),
|
|
17
|
+
"idle",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("is escalated once the selection is the search predicate", () => {
|
|
22
|
+
assert.equal(
|
|
23
|
+
resolveSelectionSheetMode({
|
|
24
|
+
isRunning: false,
|
|
25
|
+
isCounting: false,
|
|
26
|
+
isEscalated: true,
|
|
27
|
+
}),
|
|
28
|
+
"escalated",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("is counting while the predicate is still paging to a total", () => {
|
|
33
|
+
assert.equal(
|
|
34
|
+
resolveSelectionSheetMode({
|
|
35
|
+
isRunning: false,
|
|
36
|
+
isCounting: true,
|
|
37
|
+
isEscalated: false,
|
|
38
|
+
}),
|
|
39
|
+
"counting",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("running wins over escalated — a chunked run is both", () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
resolveSelectionSheetMode({
|
|
46
|
+
isRunning: true,
|
|
47
|
+
isCounting: false,
|
|
48
|
+
isEscalated: true,
|
|
49
|
+
}),
|
|
50
|
+
"running",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("running wins over counting", () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
resolveSelectionSheetMode({
|
|
57
|
+
isRunning: true,
|
|
58
|
+
isCounting: true,
|
|
59
|
+
isEscalated: false,
|
|
60
|
+
}),
|
|
61
|
+
"running",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("counting wins over escalated", () => {
|
|
66
|
+
assert.equal(
|
|
67
|
+
resolveSelectionSheetMode({
|
|
68
|
+
isRunning: false,
|
|
69
|
+
isCounting: true,
|
|
70
|
+
isEscalated: true,
|
|
71
|
+
}),
|
|
72
|
+
"counting",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("shouldShowSelectionSheet", () => {
|
|
78
|
+
test("hides at a single selected row while idle", () => {
|
|
79
|
+
assert.equal(shouldShowSelectionSheet(1, "idle"), false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("shows at the two-row threshold", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
shouldShowSelectionSheet(SELECTION_SHEET_MIN_COUNT, "idle"),
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("shows for any non-idle state even below the threshold", () => {
|
|
90
|
+
assert.equal(shouldShowSelectionSheet(0, "counting"), true);
|
|
91
|
+
assert.equal(shouldShowSelectionSheet(1, "running"), true);
|
|
92
|
+
assert.equal(shouldShowSelectionSheet(0, "escalated"), true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("shows a pending notice at a single idle row so it is never dropped", () => {
|
|
96
|
+
// A bulk delete that leaves one message behind returns to idle at count 1
|
|
97
|
+
// with a "1 couldn't be deleted / Retry" notice; the escalation offer can
|
|
98
|
+
// likewise sit at one loaded row. The sheet must mount to surface either.
|
|
99
|
+
assert.equal(shouldShowSelectionSheet(1, "idle", true), true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("still hides a single idle row with no notice", () => {
|
|
103
|
+
assert.equal(shouldShowSelectionSheet(1, "idle", false), false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SelectionSheetMode } from "@remit/ui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Routes the mobile selection sheet to one of its four content states from the
|
|
5
|
+
* escalation machinery's flags. Pure so the routing — which is what decides
|
|
6
|
+
* whether the sheet shows idle quick actions or the counting / running /
|
|
7
|
+
* escalated status — is testable without the sheet or a DOM.
|
|
8
|
+
*
|
|
9
|
+
* Precedence, highest first:
|
|
10
|
+
* 1. `running` — a chunked delete/move/mark-read is in flight; progress owns
|
|
11
|
+
* the sheet even over an escalated selection (an escalated run is both).
|
|
12
|
+
* 2. `counting` — a search predicate is still paging to its total.
|
|
13
|
+
* 3. `escalated` — the selection is the search predicate, not a loaded id set.
|
|
14
|
+
* 4. `idle` — a bounded selection with the quick actions and smart-flow rows.
|
|
15
|
+
*/
|
|
16
|
+
export const resolveSelectionSheetMode = (input: {
|
|
17
|
+
isRunning: boolean;
|
|
18
|
+
isCounting: boolean;
|
|
19
|
+
isEscalated: boolean;
|
|
20
|
+
}): SelectionSheetMode => {
|
|
21
|
+
if (input.isRunning) return "running";
|
|
22
|
+
if (input.isCounting) return "counting";
|
|
23
|
+
if (input.isEscalated) return "escalated";
|
|
24
|
+
return "idle";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The count threshold the peeking teaser rises at — two or more selected, the
|
|
29
|
+
* prototype's threshold. Below it selection mode is still entered (rows show
|
|
30
|
+
* their checkboxes) but no sheet appears, matching today's single-select
|
|
31
|
+
* behaviour of leaving the list chrome alone.
|
|
32
|
+
*/
|
|
33
|
+
export const SELECTION_SHEET_MIN_COUNT = 2;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the mobile sheet should be mounted: two or more rows selected, or any
|
|
37
|
+
* non-idle escalation state (counting, running, escalated), or a pending notice
|
|
38
|
+
* that has to be surfaced. The last case is why a single-row selection can still
|
|
39
|
+
* raise the sheet — a bulk run that leaves exactly one message behind returns to
|
|
40
|
+
* idle at count 1 with a "1 couldn't be deleted / Retry" notice, and a search
|
|
41
|
+
* with one loaded row plus more matches carries the escalation offer at count 1.
|
|
42
|
+
* Suppressing the sheet there would drop the notice with no way to retry.
|
|
43
|
+
*/
|
|
44
|
+
export const shouldShowSelectionSheet = (
|
|
45
|
+
count: number,
|
|
46
|
+
mode: SelectionSheetMode,
|
|
47
|
+
hasNotice = false,
|
|
48
|
+
): boolean =>
|
|
49
|
+
count >= SELECTION_SHEET_MIN_COUNT || mode !== "idle" || hasNotice;
|