@remit/web-client 0.0.56 → 0.0.58
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/MailListHeader.tsx +26 -4
- package/src/components/mail/MailViewChrome.tsx +8 -0
- package/src/components/mail/MailboxPane.tsx +5 -0
- package/src/components/mail/MessageList.selection.test.ts +54 -0
- package/src/components/mail/MessageList.tsx +136 -66
- package/src/components/mail/SelectionToolbar.render.test.ts +129 -0
- package/src/components/mail/SelectionToolbar.stories.tsx +194 -0
- package/src/components/mail/SelectionToolbar.tsx +187 -73
- 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/hooks/useEscalatedActions.ts +2 -2
- package/src/lib/organize/mobile-organize-flow.test.ts +96 -0
- package/src/lib/organize/mobile-organize-flow.ts +73 -0
- package/src/lib/search-surface.test.ts +74 -0
- package/src/lib/search-surface.ts +34 -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.58",
|
|
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": {
|
|
@@ -46,6 +46,7 @@ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
|
46
46
|
import { useMailContext } from "@/lib/mail-context";
|
|
47
47
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
48
48
|
import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
|
|
49
|
+
import { showInlineSearchResults } from "@/lib/search-surface";
|
|
49
50
|
import {
|
|
50
51
|
parseSearchTokens,
|
|
51
52
|
removeSearchToken,
|
|
@@ -76,6 +77,17 @@ interface MailListHeaderProps {
|
|
|
76
77
|
*/
|
|
77
78
|
searchResultsLabel?: string;
|
|
78
79
|
relatedResultsLabel?: string;
|
|
80
|
+
/**
|
|
81
|
+
* The body renders the committed search itself as a selectable list — the
|
|
82
|
+
* mailbox route's `MessageList` filters to the search results and hosts the
|
|
83
|
+
* multi-select toolbar and the "Select all N matching" escalation (#212).
|
|
84
|
+
* When set, a committed query keeps the body on tablet/desktop instead of
|
|
85
|
+
* swapping in the read-only two-engine `SearchResults` panel; the panel still
|
|
86
|
+
* shows while the query is being typed (uncommitted). Views whose body is not
|
|
87
|
+
* a search-result list (the brief, flagged, global search) leave this unset
|
|
88
|
+
* and keep the panel for every query.
|
|
89
|
+
*/
|
|
90
|
+
searchResultsInBody?: boolean;
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
export function MailListHeader({
|
|
@@ -91,6 +103,7 @@ export function MailListHeader({
|
|
|
91
103
|
onSelectSearchResult,
|
|
92
104
|
searchResultsLabel = "Top matches",
|
|
93
105
|
relatedResultsLabel = "Related",
|
|
106
|
+
searchResultsInBody = false,
|
|
94
107
|
}: MailListHeaderProps) {
|
|
95
108
|
const {
|
|
96
109
|
accounts,
|
|
@@ -209,10 +222,19 @@ export function MailListHeader({
|
|
|
209
222
|
);
|
|
210
223
|
}
|
|
211
224
|
|
|
212
|
-
// Tablet + desktop keep the inline toolbar search;
|
|
213
|
-
// list-pane body swaps to the same sectioned results the phone
|
|
214
|
-
// under the same FilterSheet.
|
|
215
|
-
|
|
225
|
+
// Tablet + desktop keep the inline toolbar search; while a query is being
|
|
226
|
+
// typed the list-pane body swaps to the same sectioned results the phone
|
|
227
|
+
// takeover shows, under the same FilterSheet. A view whose own body renders
|
|
228
|
+
// the committed search as a selectable list (`searchResultsInBody`, the
|
|
229
|
+
// mailbox route) keeps the panel only until the query commits to the URL,
|
|
230
|
+
// then hands back to its `MessageList` so the multi-select toolbar and the
|
|
231
|
+
// escalation are reachable (#212). Clearing the query restores the normal list.
|
|
232
|
+
const showInlineResults = showInlineSearchResults({
|
|
233
|
+
tier,
|
|
234
|
+
hasLiveInput: hasQuery,
|
|
235
|
+
hasCommittedQuery: searchQuery.trim().length > 0,
|
|
236
|
+
bodyRendersCommittedResults: searchResultsInBody,
|
|
237
|
+
});
|
|
216
238
|
const handleSelectInlineResult = (result: SearchResult) => {
|
|
217
239
|
setRecentSearches(saveRecentSearch(searchInput));
|
|
218
240
|
onSelectSearchResult?.(result);
|
|
@@ -44,6 +44,12 @@ interface MailViewChromeProps {
|
|
|
44
44
|
/** Section headings; see `MailListHeader`. */
|
|
45
45
|
searchResultsLabel?: string;
|
|
46
46
|
relatedResultsLabel?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The body renders the committed search as a selectable list itself; see
|
|
49
|
+
* `MailListHeader`. The mailbox route sets this so a committed search yields
|
|
50
|
+
* its multi-select `MessageList` (#212); flagged leaves it unset.
|
|
51
|
+
*/
|
|
52
|
+
searchResultsInBody?: boolean;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export function MailViewChrome({
|
|
@@ -65,6 +71,7 @@ export function MailViewChrome({
|
|
|
65
71
|
onSelectSearchResult,
|
|
66
72
|
searchResultsLabel,
|
|
67
73
|
relatedResultsLabel,
|
|
74
|
+
searchResultsInBody,
|
|
68
75
|
}: MailViewChromeProps) {
|
|
69
76
|
const [expanded, setExpanded] = useState(false);
|
|
70
77
|
|
|
@@ -95,6 +102,7 @@ export function MailViewChrome({
|
|
|
95
102
|
onSelectSearchResult={onSelectSearchResult}
|
|
96
103
|
searchResultsLabel={searchResultsLabel}
|
|
97
104
|
relatedResultsLabel={relatedResultsLabel}
|
|
105
|
+
searchResultsInBody={searchResultsInBody}
|
|
98
106
|
>
|
|
99
107
|
<FilterSheet {...filterConfig}>{children}</FilterSheet>
|
|
100
108
|
</MailListHeader>
|
|
@@ -985,6 +985,11 @@ function MailboxList() {
|
|
|
985
985
|
relatedResults={relatedResults}
|
|
986
986
|
relatedLoading={relatedLoading}
|
|
987
987
|
onSelectSearchResult={handleSelectSearchResult}
|
|
988
|
+
// A committed mailbox search renders in the body's own `MessageList`
|
|
989
|
+
// (its threads filter to the results), so the multi-select toolbar and
|
|
990
|
+
// the "Select all N matching" escalation are reachable on desktop (#212).
|
|
991
|
+
// The typing/uncommitted state still shows the two-engine panel.
|
|
992
|
+
searchResultsInBody
|
|
988
993
|
>
|
|
989
994
|
{body}
|
|
990
995
|
</MailViewChrome>
|
|
@@ -93,6 +93,60 @@ describe("MessageList escalated actions", () => {
|
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Advanced selection is no longer mobile-only (#212): the escalation engine is
|
|
98
|
+
* opened to desktop, and the desktop `SelectionToolbar` renders the same
|
|
99
|
+
* offer → counting → escalated → progress → notice states the mobile sheet
|
|
100
|
+
* carries, from one shared set of derivations so the two surfaces never drift.
|
|
101
|
+
*/
|
|
102
|
+
describe("MessageList escalation reaches desktop (#212)", () => {
|
|
103
|
+
it("no longer gates the escalation engine on the mobile viewport", () => {
|
|
104
|
+
assert.match(
|
|
105
|
+
source,
|
|
106
|
+
/const escalationEnabled = isSearching && !!searchPredicate;/,
|
|
107
|
+
);
|
|
108
|
+
assert.doesNotMatch(
|
|
109
|
+
source,
|
|
110
|
+
/!isDesktop && isSearching && !!searchPredicate/,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("feeds the desktop toolbar the shared escalation state", () => {
|
|
115
|
+
const toolbar = source.match(/<SelectionToolbar[\s\S]*?\/>/)?.[0] ?? "";
|
|
116
|
+
assert.match(toolbar, /statusLabel=\{selectionStatusLabel\}/);
|
|
117
|
+
assert.match(toolbar, /notice=\{escalationNotice\}/);
|
|
118
|
+
assert.match(toolbar, /progress=\{selectionProgress\}/);
|
|
119
|
+
// Desktop wires select-all only while searching — the mobile sheet carries
|
|
120
|
+
// it for any bounded selection.
|
|
121
|
+
assert.match(
|
|
122
|
+
toolbar,
|
|
123
|
+
/selectAll=\{escalationEnabled \? selectionSelectAll : undefined\}/,
|
|
124
|
+
);
|
|
125
|
+
assert.match(
|
|
126
|
+
toolbar,
|
|
127
|
+
/isCounting=\{escalation\.phase\.kind === "counting"\}/,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("drives both surfaces from the same derivations", () => {
|
|
132
|
+
// The sheet and the toolbar read the same status label, select-all control
|
|
133
|
+
// and progress — a second copy for desktop is exactly the drift this
|
|
134
|
+
// guards against.
|
|
135
|
+
for (const shared of [
|
|
136
|
+
"selectionStatusLabel",
|
|
137
|
+
"selectionSelectAll",
|
|
138
|
+
"selectionProgress",
|
|
139
|
+
"selectionCount",
|
|
140
|
+
]) {
|
|
141
|
+
const uses = source.match(new RegExp(`\\b${shared}\\b`, "g")) ?? [];
|
|
142
|
+
assert.ok(
|
|
143
|
+
uses.length >= 3,
|
|
144
|
+
`${shared} should be defined once and read by both surfaces`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
96
150
|
/**
|
|
97
151
|
* A bounded confirm-delete used to open the surviving neighbour by writing
|
|
98
152
|
* `selectedMessageId` into the URL. On desktop that fills the reading pane
|
|
@@ -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
|
|
@@ -288,12 +302,12 @@ export const MessageList = ({
|
|
|
288
302
|
null,
|
|
289
303
|
);
|
|
290
304
|
|
|
291
|
-
// Search-scoped escalated selection + chunked bulk
|
|
292
|
-
//
|
|
293
|
-
// `orderedIds` below feeds `allLoadedSelected`; declared after
|
|
294
|
-
// its callback deps stay simple — see the
|
|
295
|
-
// block.
|
|
296
|
-
const escalationEnabled =
|
|
305
|
+
// Search-scoped escalated selection + chunked bulk actions (issues #92, #212):
|
|
306
|
+
// available on both surfaces, and only while search has more matches than
|
|
307
|
+
// are loaded. `orderedIds` below feeds `allLoadedSelected`; declared after
|
|
308
|
+
// this hook so its callback deps stay simple — see the
|
|
309
|
+
// `orderedIds`/`handleRowSelect` block.
|
|
310
|
+
const escalationEnabled = isSearching && !!searchPredicate;
|
|
297
311
|
const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
|
|
298
312
|
const escalation = useEscalatedActions({
|
|
299
313
|
mailboxId,
|
|
@@ -503,9 +517,9 @@ export const MessageList = ({
|
|
|
503
517
|
[onDeleteMessages, focusedMessageId],
|
|
504
518
|
);
|
|
505
519
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
520
|
+
// Open the delete confirmation for the escalated predicate. `escalation.phase`
|
|
521
|
+
// must already be "escalated" — the selection bar's onDelete (both surfaces)
|
|
522
|
+
// only wires this up in that state.
|
|
509
523
|
const requestEscalatedDelete = useCallback(() => {
|
|
510
524
|
if (escalation.phase.kind !== "escalated") return;
|
|
511
525
|
focusBeforeConfirmRef.current = focusedMessageId ?? null;
|
|
@@ -810,9 +824,10 @@ export const MessageList = ({
|
|
|
810
824
|
[isDesktop, select],
|
|
811
825
|
);
|
|
812
826
|
|
|
813
|
-
//
|
|
814
|
-
// hand `requestDelete`, so it opens the predicate
|
|
815
|
-
|
|
827
|
+
// The selection bar's Trash tap (both surfaces): an escalated selection has
|
|
828
|
+
// no materialized ids to hand `requestDelete`, so it opens the predicate
|
|
829
|
+
// confirmation instead.
|
|
830
|
+
const handleSelectionDelete = useCallback(() => {
|
|
816
831
|
if (escalation.phase.kind === "escalated") {
|
|
817
832
|
requestEscalatedDelete();
|
|
818
833
|
return;
|
|
@@ -820,12 +835,12 @@ export const MessageList = ({
|
|
|
820
835
|
handleDelete();
|
|
821
836
|
}, [escalation.phase, requestEscalatedDelete, handleDelete]);
|
|
822
837
|
|
|
823
|
-
//
|
|
824
|
-
// "cancel selection" (issue #92 — the review flagged
|
|
825
|
-
// ambiguous once a
|
|
826
|
-
// the next page boundary; an escalated-but-idle selection
|
|
827
|
-
// bounded on the way out, same as tapping "Clear selection"
|
|
828
|
-
const
|
|
838
|
+
// The selection bar's X (both surfaces): means "stop what's happening"
|
|
839
|
+
// throughout, not just "cancel selection" (issue #92 — the review flagged
|
|
840
|
+
// the X reading as ambiguous once a run is going). Counting and a chunked
|
|
841
|
+
// run both stop at the next page boundary; an escalated-but-idle selection
|
|
842
|
+
// drops back to bounded on the way out, same as tapping "Clear selection".
|
|
843
|
+
const handleSelectionCancel = useCallback(() => {
|
|
829
844
|
if (escalation.isRunning || escalation.phase.kind === "counting") {
|
|
830
845
|
escalation.stop();
|
|
831
846
|
return;
|
|
@@ -1080,24 +1095,6 @@ export const MessageList = ({
|
|
|
1080
1095
|
// Single flat section — the mailbox view doesn't group by date.
|
|
1081
1096
|
const sections = [{ id: "inbox", threads: [] }];
|
|
1082
1097
|
|
|
1083
|
-
// Desktop selection toolbar replaces the pane header when any rows are selected.
|
|
1084
|
-
const desktopSelectionBar =
|
|
1085
|
-
hasSelection && isDesktop ? (
|
|
1086
|
-
<SelectionToolbar
|
|
1087
|
-
selectedCount={selectedCount}
|
|
1088
|
-
onDelete={handleDelete}
|
|
1089
|
-
onClearSelection={exitSelection}
|
|
1090
|
-
onMarkAsRead={handleMarkAsRead}
|
|
1091
|
-
onMove={onMoveMessages ? handleMoveSelected : undefined}
|
|
1092
|
-
onOrganize={() => setOrganizeOpen(true)}
|
|
1093
|
-
isDeleting={isDeleting}
|
|
1094
|
-
isMoving={isMoving}
|
|
1095
|
-
accountId={accountId}
|
|
1096
|
-
currentMailboxId={mailboxId}
|
|
1097
|
-
moveDisabledHint={moveDisabledHint}
|
|
1098
|
-
/>
|
|
1099
|
-
) : undefined;
|
|
1100
|
-
|
|
1101
1098
|
// Tier one of the two-tier select-all (issue #92, following Gmail web):
|
|
1102
1099
|
// every loaded row is checked. Computed against actual membership, not a
|
|
1103
1100
|
// count comparison, so a transient mismatch (mid-render, before the
|
|
@@ -1117,13 +1114,15 @@ export const MessageList = ({
|
|
|
1117
1114
|
escalation.phase.kind === "idle" &&
|
|
1118
1115
|
!escalation.isRunning;
|
|
1119
1116
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1117
|
+
// The escalation-derived surface state — viewport-independent, fed to both
|
|
1118
|
+
// the mobile sheet and the desktop toolbar so the two never diverge (#212).
|
|
1119
|
+
const selectionIsBusy = isDeleting || isMoving || escalation.isRunning;
|
|
1120
|
+
const selectionCount =
|
|
1122
1121
|
escalation.phase.kind === "escalated"
|
|
1123
1122
|
? escalation.phase.total
|
|
1124
1123
|
: selectedCount;
|
|
1125
1124
|
|
|
1126
|
-
const
|
|
1125
|
+
const selectionStatusLabel = escalation.runningAction
|
|
1127
1126
|
? bulkActionProgressLabel(
|
|
1128
1127
|
escalation.runningAction.kind,
|
|
1129
1128
|
escalation.progress?.done ?? 0,
|
|
@@ -1137,7 +1136,10 @@ export const MessageList = ({
|
|
|
1137
1136
|
? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
|
|
1138
1137
|
: undefined;
|
|
1139
1138
|
|
|
1140
|
-
|
|
1139
|
+
// The select-all-loaded control. The mobile sheet carries it for any bounded
|
|
1140
|
+
// selection; the desktop toolbar only wires it while searching (below), where
|
|
1141
|
+
// escalating past the loaded page is possible.
|
|
1142
|
+
const selectionSelectAll =
|
|
1141
1143
|
escalation.phase.kind !== "escalated" && orderedIds.length > 0
|
|
1142
1144
|
? {
|
|
1143
1145
|
checked: allLoadedSelected,
|
|
@@ -1146,11 +1148,22 @@ export const MessageList = ({
|
|
|
1146
1148
|
}
|
|
1147
1149
|
: undefined;
|
|
1148
1150
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
+
const selectionProgress =
|
|
1152
|
+
escalation.runningAction && escalation.progress
|
|
1153
|
+
? {
|
|
1154
|
+
value: escalation.progress.done,
|
|
1155
|
+
max: escalation.progress.total,
|
|
1156
|
+
tone: bulkActionProgressTone(escalation.runningAction.kind),
|
|
1157
|
+
}
|
|
1158
|
+
: undefined;
|
|
1159
|
+
|
|
1160
|
+
// At most one escalation notice at a time, ranked by how actionable it is:
|
|
1161
|
+
// an in-progress counting/escalated state and its own action always wins;
|
|
1151
1162
|
// otherwise a fresh escalation offer; otherwise a just-finished partial
|
|
1152
|
-
// failure's Retry
|
|
1153
|
-
|
|
1163
|
+
// failure's Retry. The (rare) cross-account move hint is layered on per
|
|
1164
|
+
// surface below — the desktop toolbar shows it inline, the mobile sheet
|
|
1165
|
+
// folds it in as the lowest-priority notice.
|
|
1166
|
+
const escalationNotice =
|
|
1154
1167
|
escalation.phase.kind === "counting"
|
|
1155
1168
|
? {
|
|
1156
1169
|
tone: "info" as const,
|
|
@@ -1188,9 +1201,15 @@ export const MessageList = ({
|
|
|
1188
1201
|
onClick: handleRetryFailed,
|
|
1189
1202
|
},
|
|
1190
1203
|
}
|
|
1191
|
-
:
|
|
1192
|
-
|
|
1193
|
-
|
|
1204
|
+
: undefined;
|
|
1205
|
+
|
|
1206
|
+
// The mobile sheet has no inline hint slot, so the cross-account move
|
|
1207
|
+
// restriction rides in as the lowest-priority notice.
|
|
1208
|
+
const mobileNotice =
|
|
1209
|
+
escalationNotice ??
|
|
1210
|
+
(moveDisabledHint
|
|
1211
|
+
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1212
|
+
: undefined);
|
|
1194
1213
|
|
|
1195
1214
|
// Mobile: which content the peeking sheet routes to, from the escalation
|
|
1196
1215
|
// machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
|
|
@@ -1209,7 +1228,7 @@ export const MessageList = ({
|
|
|
1209
1228
|
!isDesktop &&
|
|
1210
1229
|
isMultiSelectMode &&
|
|
1211
1230
|
shouldShowSelectionSheet(
|
|
1212
|
-
|
|
1231
|
+
selectionCount,
|
|
1213
1232
|
mobileMode,
|
|
1214
1233
|
mobileNotice !== undefined,
|
|
1215
1234
|
);
|
|
@@ -1227,37 +1246,88 @@ export const MessageList = ({
|
|
|
1227
1246
|
/>
|
|
1228
1247
|
) : undefined;
|
|
1229
1248
|
|
|
1249
|
+
// The guided organize flow (issue #211) overlays the same positioned pane
|
|
1250
|
+
// ancestor as the sheet, so it is rendered from the sheet slot. The sheet
|
|
1251
|
+
// stays mounted underneath as the flow's dimmed backdrop.
|
|
1252
|
+
const mobileOrganizeFlow =
|
|
1253
|
+
mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
|
|
1254
|
+
<MobileOrganizeFlow
|
|
1255
|
+
entry={mobileOrganizeEntry}
|
|
1256
|
+
accountId={accountId}
|
|
1257
|
+
mailboxId={mailboxId}
|
|
1258
|
+
selectedMessageIds={Array.from(selectedIds)}
|
|
1259
|
+
junkMailboxId={junkMailboxId}
|
|
1260
|
+
onClose={() => {
|
|
1261
|
+
setMobileOrganizeEntry(null);
|
|
1262
|
+
exitSelection();
|
|
1263
|
+
}}
|
|
1264
|
+
/>
|
|
1265
|
+
) : undefined;
|
|
1266
|
+
|
|
1230
1267
|
const mobileSelectionSheet = showMobileSheet ? (
|
|
1231
1268
|
<SelectionSheet
|
|
1232
|
-
count={
|
|
1269
|
+
count={selectionCount}
|
|
1233
1270
|
mode={mobileMode}
|
|
1234
|
-
onCancel={
|
|
1235
|
-
onDelete={
|
|
1271
|
+
onCancel={handleSelectionCancel}
|
|
1272
|
+
onDelete={handleSelectionDelete}
|
|
1236
1273
|
onJunk={
|
|
1237
1274
|
junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
|
|
1238
1275
|
? handleJunk
|
|
1239
1276
|
: undefined
|
|
1240
1277
|
}
|
|
1241
1278
|
onMarkRead={handleMarkAsRead}
|
|
1242
|
-
onSelectSimilar={
|
|
1243
|
-
|
|
1244
|
-
moveSlot={mobileMoveSlot}
|
|
1245
|
-
isBusy={mobileIsBusy}
|
|
1246
|
-
selectAll={mobileSelectAll}
|
|
1247
|
-
statusLabel={mobileStatusLabel}
|
|
1248
|
-
progress={
|
|
1249
|
-
escalation.runningAction && escalation.progress
|
|
1250
|
-
? {
|
|
1251
|
-
value: escalation.progress.done,
|
|
1252
|
-
max: escalation.progress.total,
|
|
1253
|
-
tone: bulkActionProgressTone(escalation.runningAction.kind),
|
|
1254
|
-
}
|
|
1255
|
-
: undefined
|
|
1279
|
+
onSelectSimilar={
|
|
1280
|
+
accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
|
|
1256
1281
|
}
|
|
1282
|
+
onSomethingElse={
|
|
1283
|
+
accountId ? () => setMobileOrganizeEntry("something-else") : undefined
|
|
1284
|
+
}
|
|
1285
|
+
moveSlot={mobileMoveSlot}
|
|
1286
|
+
isBusy={selectionIsBusy}
|
|
1287
|
+
selectAll={selectionSelectAll}
|
|
1288
|
+
statusLabel={selectionStatusLabel}
|
|
1289
|
+
progress={selectionProgress}
|
|
1257
1290
|
notice={mobileNotice}
|
|
1258
1291
|
/>
|
|
1259
1292
|
) : undefined;
|
|
1260
1293
|
|
|
1294
|
+
// Both mobile overlays share the pane's positioned ancestor via the sheet
|
|
1295
|
+
// slot: the peeking sheet, and the guided organize flow above it.
|
|
1296
|
+
const mobileSelectionSurface =
|
|
1297
|
+
mobileSelectionSheet || mobileOrganizeFlow ? (
|
|
1298
|
+
<>
|
|
1299
|
+
{mobileSelectionSheet}
|
|
1300
|
+
{mobileOrganizeFlow}
|
|
1301
|
+
</>
|
|
1302
|
+
) : undefined;
|
|
1303
|
+
|
|
1304
|
+
// Desktop selection toolbar replaces the pane header when any rows are
|
|
1305
|
+
// selected. It gains the same search-escalation states the mobile sheet
|
|
1306
|
+
// carries (issue #212) — the offer, counting, the escalated predicate and a
|
|
1307
|
+
// chunked run's progress — from the shared derivations above; the
|
|
1308
|
+
// cross-account move hint stays inline (the toolbar has a slot for it).
|
|
1309
|
+
const desktopSelectionBar =
|
|
1310
|
+
hasSelection && isDesktop ? (
|
|
1311
|
+
<SelectionToolbar
|
|
1312
|
+
selectedCount={selectionCount}
|
|
1313
|
+
onDelete={handleSelectionDelete}
|
|
1314
|
+
onClearSelection={handleSelectionCancel}
|
|
1315
|
+
onMarkAsRead={handleMarkAsRead}
|
|
1316
|
+
onMove={onMoveMessages ? handleMoveSelected : undefined}
|
|
1317
|
+
onOrganize={() => setOrganizeOpen(true)}
|
|
1318
|
+
isDeleting={isDeleting}
|
|
1319
|
+
isMoving={isMoving}
|
|
1320
|
+
accountId={accountId}
|
|
1321
|
+
currentMailboxId={mailboxId}
|
|
1322
|
+
moveDisabledHint={moveDisabledHint}
|
|
1323
|
+
selectAll={escalationEnabled ? selectionSelectAll : undefined}
|
|
1324
|
+
statusLabel={selectionStatusLabel}
|
|
1325
|
+
isCounting={escalation.phase.kind === "counting"}
|
|
1326
|
+
progress={selectionProgress}
|
|
1327
|
+
notice={escalationNotice}
|
|
1328
|
+
/>
|
|
1329
|
+
) : undefined;
|
|
1330
|
+
|
|
1261
1331
|
const activeSelectionBar = desktopSelectionBar;
|
|
1262
1332
|
|
|
1263
1333
|
// Roving tabindex: exactly one row is in the tab order, so Tab moves focus
|
|
@@ -1401,7 +1471,7 @@ export const MessageList = ({
|
|
|
1401
1471
|
isDesktop={isDesktop}
|
|
1402
1472
|
hideHeader={hideHeader}
|
|
1403
1473
|
selectionBar={activeSelectionBar}
|
|
1404
|
-
selectionSheet={
|
|
1474
|
+
selectionSheet={mobileSelectionSurface}
|
|
1405
1475
|
listBody={listState === "ready" ? virtualBody : undefined}
|
|
1406
1476
|
/>
|
|
1407
1477
|
<ConfirmDialog
|
|
@@ -143,3 +143,132 @@ describe("SelectionToolbar", () => {
|
|
|
143
143
|
assert.equal(organized, 1);
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The search-escalation states desktop gains in #212: the same offer →
|
|
149
|
+
* counting → escalated → chunked-run flow the mobile sheet carries, driven by
|
|
150
|
+
* the shared derivations in `MessageList`. These assert the toolbar routes each
|
|
151
|
+
* state, never that it re-implements the engine.
|
|
152
|
+
*/
|
|
153
|
+
describe("SelectionToolbar — search escalation", () => {
|
|
154
|
+
it("renders the select-all-loaded checkbox only when the control is wired", () => {
|
|
155
|
+
assert.equal(mount().query('[aria-label="Select all"]'), null);
|
|
156
|
+
harness?.close();
|
|
157
|
+
harness = undefined;
|
|
158
|
+
const dom = mount({
|
|
159
|
+
selectAll: {
|
|
160
|
+
checked: false,
|
|
161
|
+
indeterminate: true,
|
|
162
|
+
onChange: () => undefined,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
assert.ok(dom.query('[aria-label="Select all"]'));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("names the escalated scope through statusLabel instead of a bare count", () => {
|
|
169
|
+
const dom = mount({
|
|
170
|
+
selectedCount: 3412,
|
|
171
|
+
statusLabel: 'All 3,412 matching "npm" selected',
|
|
172
|
+
});
|
|
173
|
+
assert.match(dom.text(), /All 3,412 matching "npm" selected/);
|
|
174
|
+
assert.doesNotMatch(dom.text(), /messages selected/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("hides Delete while the total is still counting, and offers Stop", () => {
|
|
178
|
+
let stopped = 0;
|
|
179
|
+
const dom = mount({
|
|
180
|
+
isCounting: true,
|
|
181
|
+
statusLabel: "Counting… 1,900 so far",
|
|
182
|
+
notice: {
|
|
183
|
+
tone: "info",
|
|
184
|
+
text: "",
|
|
185
|
+
action: {
|
|
186
|
+
label: "Stop",
|
|
187
|
+
onClick: () => {
|
|
188
|
+
stopped += 1;
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
assert.equal(dom.query('[aria-label="Delete selected messages"]'), null);
|
|
194
|
+
dom.click(dom.byText("button", "Stop"));
|
|
195
|
+
assert.equal(stopped, 1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("offers the escalation control naming the scope, and escalating fires it", () => {
|
|
199
|
+
let escalated = 0;
|
|
200
|
+
const dom = mount({
|
|
201
|
+
selectAll: { checked: true, onChange: () => undefined },
|
|
202
|
+
notice: {
|
|
203
|
+
tone: "info",
|
|
204
|
+
text: "",
|
|
205
|
+
action: {
|
|
206
|
+
label: 'Select all matching "npm"',
|
|
207
|
+
onClick: () => {
|
|
208
|
+
escalated += 1;
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
dom.click(dom.byText("button", 'Select all matching "npm"'));
|
|
214
|
+
assert.equal(escalated, 1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("keeps every verb over an escalated selection — not delete-only (#114)", () => {
|
|
218
|
+
const dom = mount({
|
|
219
|
+
selectedCount: 3412,
|
|
220
|
+
statusLabel: 'All 3,412 matching "npm" selected',
|
|
221
|
+
onMarkAsRead: () => undefined,
|
|
222
|
+
onMove: () => undefined,
|
|
223
|
+
onOrganize: () => undefined,
|
|
224
|
+
accountId: "acc-1",
|
|
225
|
+
currentMailboxId: "mbx-inbox",
|
|
226
|
+
notice: {
|
|
227
|
+
tone: "info",
|
|
228
|
+
text: "",
|
|
229
|
+
action: { label: "Clear selection", onClick: () => undefined },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
assert.ok(dom.query('[aria-label="Mark as read"]'));
|
|
233
|
+
assert.ok(dom.query('[aria-label="Move selected messages"]'));
|
|
234
|
+
assert.ok(dom.query('[aria-label="Delete selected messages"]'));
|
|
235
|
+
// Organize has no escalated-predicate path, so it withdraws once the
|
|
236
|
+
// selection names a scope through statusLabel.
|
|
237
|
+
assert.equal(dom.query('[aria-label="Organize similar messages"]'), null);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("shows a progress bar mid-run and takes the verbs off screen", () => {
|
|
241
|
+
const dom = mount({
|
|
242
|
+
selectedCount: 3412,
|
|
243
|
+
statusLabel: "Moving 1,200 of 3,412…",
|
|
244
|
+
onMarkAsRead: () => undefined,
|
|
245
|
+
progress: { value: 1200, max: 3412, tone: "info" },
|
|
246
|
+
});
|
|
247
|
+
assert.ok(dom.query('[role="progressbar"]'));
|
|
248
|
+
assert.equal(dom.query('[aria-label="Delete selected messages"]'), null);
|
|
249
|
+
assert.equal(dom.query('[aria-label="Mark as read"]'), null);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("names the succeeded count and retries exactly what is left over", () => {
|
|
253
|
+
let retried = 0;
|
|
254
|
+
const dom = mount({
|
|
255
|
+
selectedCount: 340,
|
|
256
|
+
notice: {
|
|
257
|
+
tone: "danger",
|
|
258
|
+
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
259
|
+
action: {
|
|
260
|
+
label: "Retry 340",
|
|
261
|
+
onClick: () => {
|
|
262
|
+
retried += 1;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
assert.match(
|
|
268
|
+
dom.text(),
|
|
269
|
+
/3,072 moved to Trash\. 340 couldn't be deleted\./,
|
|
270
|
+
);
|
|
271
|
+
dom.click(dom.byText("button", "Retry 340"));
|
|
272
|
+
assert.equal(retried, 1);
|
|
273
|
+
});
|
|
274
|
+
});
|