@remit/web-client 0.0.54 → 0.0.56
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 +92 -54
- package/src/hooks/useFilters.test.ts +113 -0
- package/src/hooks/useFilters.ts +12 -6
- 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.56",
|
|
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,6 +47,10 @@ 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";
|
|
50
56
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
@@ -244,6 +250,10 @@ export const MessageList = ({
|
|
|
244
250
|
// Swipe-to-read toggle hook
|
|
245
251
|
const { toggleReadFor } = useToggleReadFor({ mailboxId });
|
|
246
252
|
|
|
253
|
+
// The Junk quick action moves the selection to the account's appointed Junk
|
|
254
|
+
// mailbox — the message-flags API has no `$Junk` field, so "junk" is a move.
|
|
255
|
+
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
256
|
+
|
|
247
257
|
// Selection state
|
|
248
258
|
const {
|
|
249
259
|
selectedIds,
|
|
@@ -742,6 +752,14 @@ export const MessageList = ({
|
|
|
742
752
|
],
|
|
743
753
|
);
|
|
744
754
|
|
|
755
|
+
// Junk quick action (mobile sheet): move the selection to the appointed Junk
|
|
756
|
+
// mailbox. Reuses the same bounded/escalated move path as any other move, so
|
|
757
|
+
// the chunked run and the cross-account guard apply unchanged.
|
|
758
|
+
const handleJunk = useCallback(() => {
|
|
759
|
+
if (!junkMailboxId) return;
|
|
760
|
+
handleMoveSelected(junkMailboxId);
|
|
761
|
+
}, [junkMailboxId, handleMoveSelected]);
|
|
762
|
+
|
|
745
763
|
// Cross-account guard: every selected thread row must belong to the
|
|
746
764
|
// same account as the current mailbox. The list is already scoped to
|
|
747
765
|
// one mailbox so in practice this is always single-account, but we
|
|
@@ -1174,61 +1192,73 @@ export const MessageList = ({
|
|
|
1174
1192
|
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1175
1193
|
: undefined;
|
|
1176
1194
|
|
|
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
|
-
}
|
|
1195
|
+
// Mobile: which content the peeking sheet routes to, from the escalation
|
|
1196
|
+
// machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
|
|
1197
|
+
// predicate still paging) replace the idle quick actions with status and
|
|
1198
|
+
// progress; `escalated` keeps the verbs over the whole predicate.
|
|
1199
|
+
const mobileMode = resolveSelectionSheetMode({
|
|
1200
|
+
isRunning: escalation.isRunning,
|
|
1201
|
+
isCounting: escalation.phase.kind === "counting",
|
|
1202
|
+
isEscalated: escalation.phase.kind === "escalated",
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
// The sheet is the sole mobile selection surface. It rises at 2+ selected
|
|
1206
|
+
// (the prototype threshold) or in any non-idle escalation state — a single
|
|
1207
|
+
// selected row enters selection mode without raising it, matching today.
|
|
1208
|
+
const showMobileSheet =
|
|
1209
|
+
!isDesktop &&
|
|
1210
|
+
isMultiSelectMode &&
|
|
1211
|
+
shouldShowSelectionSheet(
|
|
1212
|
+
mobileCount,
|
|
1213
|
+
mobileMode,
|
|
1214
|
+
mobileNotice !== undefined,
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
const mobileMoveSlot =
|
|
1218
|
+
(onMoveMessages || escalation.phase.kind === "escalated") &&
|
|
1219
|
+
accountId &&
|
|
1220
|
+
mailboxId ? (
|
|
1221
|
+
<MoveToTrigger
|
|
1222
|
+
accountId={accountId}
|
|
1223
|
+
currentMailboxId={mailboxId}
|
|
1224
|
+
onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
|
|
1225
|
+
disabledHint={moveDisabledHint}
|
|
1226
|
+
label="Move selected messages"
|
|
1228
1227
|
/>
|
|
1229
1228
|
) : undefined;
|
|
1230
1229
|
|
|
1231
|
-
const
|
|
1230
|
+
const mobileSelectionSheet = showMobileSheet ? (
|
|
1231
|
+
<SelectionSheet
|
|
1232
|
+
count={mobileCount}
|
|
1233
|
+
mode={mobileMode}
|
|
1234
|
+
onCancel={handleMobileCancel}
|
|
1235
|
+
onDelete={handleMobileDelete}
|
|
1236
|
+
onJunk={
|
|
1237
|
+
junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
|
|
1238
|
+
? handleJunk
|
|
1239
|
+
: undefined
|
|
1240
|
+
}
|
|
1241
|
+
onMarkRead={handleMarkAsRead}
|
|
1242
|
+
onSelectSimilar={accountId ? () => setOrganizeOpen(true) : undefined}
|
|
1243
|
+
onSomethingElse={accountId ? () => setOrganizeOpen(true) : undefined}
|
|
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
|
|
1256
|
+
}
|
|
1257
|
+
notice={mobileNotice}
|
|
1258
|
+
/>
|
|
1259
|
+
) : undefined;
|
|
1260
|
+
|
|
1261
|
+
const activeSelectionBar = desktopSelectionBar;
|
|
1232
1262
|
|
|
1233
1263
|
// Roving tabindex: exactly one row is in the tab order, so Tab moves focus
|
|
1234
1264
|
// into the list at the cursor and Shift+Tab moves back out to the side panel
|
|
@@ -1257,6 +1287,13 @@ export const MessageList = ({
|
|
|
1257
1287
|
aria-multiselectable
|
|
1258
1288
|
aria-label={listTitle}
|
|
1259
1289
|
className="flex-1 overflow-y-auto"
|
|
1290
|
+
// Pad the list so its last rows clear the peeking teaser (~56px)
|
|
1291
|
+
// rather than hiding behind it.
|
|
1292
|
+
style={
|
|
1293
|
+
showMobileSheet
|
|
1294
|
+
? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
|
|
1295
|
+
: undefined
|
|
1296
|
+
}
|
|
1260
1297
|
>
|
|
1261
1298
|
{/* Virtualizer scaffolding — presentational so the listbox sees the
|
|
1262
1299
|
rows as its options rather than these positioning wrappers. */}
|
|
@@ -1364,6 +1401,7 @@ export const MessageList = ({
|
|
|
1364
1401
|
isDesktop={isDesktop}
|
|
1365
1402
|
hideHeader={hideHeader}
|
|
1366
1403
|
selectionBar={activeSelectionBar}
|
|
1404
|
+
selectionSheet={mobileSelectionSheet}
|
|
1367
1405
|
listBody={listState === "ready" ? virtualBody : undefined}
|
|
1368
1406
|
/>
|
|
1369
1407
|
<ConfirmDialog
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFilters — the settings-page filter list read and the create/delete
|
|
3
|
+
* invalidation contract.
|
|
4
|
+
*
|
|
5
|
+
* `useFilterList` is exercised the same way `useRescueCandidates` is: a
|
|
6
|
+
* QueryClient is pre-seeded under the key the hook generates and the hook is
|
|
7
|
+
* rendered synchronously via renderToString, so the queryFn never fires and
|
|
8
|
+
* mapping drift breaks the assertions.
|
|
9
|
+
*
|
|
10
|
+
* Create and delete both invalidate `buildFilterListKey` on success; a drift
|
|
11
|
+
* between that key and the one the list subscribes to would leave the settings
|
|
12
|
+
* list stale after a filter is created or deleted, so the contract is pinned
|
|
13
|
+
* directly against the generated SDK key (the pattern `buildMailboxListKey`
|
|
14
|
+
* uses for trigger-sync).
|
|
15
|
+
*/
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { describe, test } from "node:test";
|
|
18
|
+
import { filterOperationsListFiltersQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
19
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
20
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
21
|
+
import { createElement } from "react";
|
|
22
|
+
import { renderToString } from "react-dom/server";
|
|
23
|
+
import { buildFilterListKey, useFilterList } from "./useFilters.js";
|
|
24
|
+
|
|
25
|
+
const ACCOUNT_ID = "acc-1";
|
|
26
|
+
|
|
27
|
+
const filter = (
|
|
28
|
+
overrides: Partial<RemitImapFilterResponse> & { filterId: string },
|
|
29
|
+
): RemitImapFilterResponse =>
|
|
30
|
+
({
|
|
31
|
+
accountConfigId: "cfg-1",
|
|
32
|
+
name: "Travel",
|
|
33
|
+
scope: "Standing",
|
|
34
|
+
state: "Active",
|
|
35
|
+
hasAnchor: false,
|
|
36
|
+
ruleChangedAt: 0,
|
|
37
|
+
matchOperator: "And",
|
|
38
|
+
literalClauses: [],
|
|
39
|
+
actionLabelId: "None",
|
|
40
|
+
actionMailboxId: "None",
|
|
41
|
+
createdAt: 0,
|
|
42
|
+
updatedAt: 0,
|
|
43
|
+
...overrides,
|
|
44
|
+
}) as RemitImapFilterResponse;
|
|
45
|
+
|
|
46
|
+
function renderFilterList(
|
|
47
|
+
accountId: string | undefined,
|
|
48
|
+
seed?: RemitImapFilterResponse[],
|
|
49
|
+
): ReturnType<typeof useFilterList> {
|
|
50
|
+
const client = new QueryClient({
|
|
51
|
+
defaultOptions: { queries: { retry: false } },
|
|
52
|
+
});
|
|
53
|
+
if (accountId && seed) {
|
|
54
|
+
client.setQueryData(buildFilterListKey(accountId), { items: seed });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let captured = {} as ReturnType<typeof useFilterList>;
|
|
58
|
+
function Capture() {
|
|
59
|
+
captured = useFilterList(accountId);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
renderToString(
|
|
64
|
+
createElement(
|
|
65
|
+
QueryClientProvider,
|
|
66
|
+
{ client },
|
|
67
|
+
createElement(Capture),
|
|
68
|
+
) as never,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return captured;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("buildFilterListKey", () => {
|
|
75
|
+
test("matches the generated SDK key create/delete invalidate", () => {
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
buildFilterListKey(ACCOUNT_ID),
|
|
78
|
+
filterOperationsListFiltersQueryKey({ path: { accountId: ACCOUNT_ID } }),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("is distinct per account, so one account's list is not invalidated by another's write", () => {
|
|
83
|
+
assert.notDeepEqual(
|
|
84
|
+
buildFilterListKey("acc-1"),
|
|
85
|
+
buildFilterListKey("acc-2"),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("useFilterList", () => {
|
|
91
|
+
test("returns the account's filters from the cache", () => {
|
|
92
|
+
const { filters } = renderFilterList(ACCOUNT_ID, [
|
|
93
|
+
filter({ filterId: "f1", name: "Travel" }),
|
|
94
|
+
filter({ filterId: "f2", name: "Receipts", scope: "Temporary" }),
|
|
95
|
+
]);
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
filters.map((f) => f.name),
|
|
98
|
+
["Travel", "Receipts"],
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns an empty list, not undefined, before any data lands", () => {
|
|
103
|
+
const { filters } = renderFilterList(ACCOUNT_ID);
|
|
104
|
+
assert.deepEqual(filters, []);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("stays empty and disabled when no account is selected", () => {
|
|
108
|
+
const { filters, isPending } = renderFilterList(undefined);
|
|
109
|
+
assert.deepEqual(filters, []);
|
|
110
|
+
// `enabled: false` keeps the query from fetching for an absent account.
|
|
111
|
+
assert.equal(isPending, true);
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/hooks/useFilters.ts
CHANGED
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
type OrganizeScope,
|
|
13
13
|
} from "@/lib/organize/organize-model";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The query key the filter list reads and both mutations invalidate on success.
|
|
17
|
+
* Extracted so create/delete invalidate exactly the key `useFilterList`
|
|
18
|
+
* subscribes to — a drift here would leave the settings list stale after a
|
|
19
|
+
* filter is created or deleted (the same contract `buildMailboxListKey` pins
|
|
20
|
+
* for trigger-sync).
|
|
21
|
+
*/
|
|
22
|
+
export const buildFilterListKey = (accountId: string) =>
|
|
23
|
+
filterOperationsListFiltersQueryKey({ path: { accountId } });
|
|
24
|
+
|
|
15
25
|
/**
|
|
16
26
|
* List the account's standing filters (Standing + Temporary). Expired
|
|
17
27
|
* Temporary filters stay in the list — they are shown distinctly, never hidden
|
|
@@ -46,9 +56,7 @@ export const useCreateFilter = (accountId: string | undefined) => {
|
|
|
46
56
|
onSuccess: () => {
|
|
47
57
|
if (!accountId) return;
|
|
48
58
|
queryClient.invalidateQueries({
|
|
49
|
-
queryKey:
|
|
50
|
-
path: { accountId },
|
|
51
|
-
}),
|
|
59
|
+
queryKey: buildFilterListKey(accountId),
|
|
52
60
|
});
|
|
53
61
|
},
|
|
54
62
|
});
|
|
@@ -86,9 +94,7 @@ export const useDeleteFilter = (accountId: string | undefined) => {
|
|
|
86
94
|
onSuccess: () => {
|
|
87
95
|
if (!accountId) return;
|
|
88
96
|
queryClient.invalidateQueries({
|
|
89
|
-
queryKey:
|
|
90
|
-
path: { accountId },
|
|
91
|
-
}),
|
|
97
|
+
queryKey: buildFilterListKey(accountId),
|
|
92
98
|
});
|
|
93
99
|
},
|
|
94
100
|
});
|
|
@@ -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;
|