@remit/web-client 0.0.94 → 0.0.96
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/BriefPane.selection.test.ts +0 -1
- package/src/components/mail/BriefPane.tsx +7 -17
- package/src/components/mail/DailyBrief.selection.test.ts +21 -3
- package/src/components/mail/DailyBrief.tsx +17 -27
- package/src/components/mail/FlaggedList.tsx +24 -19
- package/src/components/mail/FlaggedPane.tsx +9 -23
- package/src/components/mail/MailboxPane.tsx +24 -11
- package/src/components/mail/MessageList.selection.test.ts +44 -30
- package/src/components/mail/MessageList.tsx +117 -339
- package/src/components/mail/SelectionWizardHost.tsx +187 -39
- package/src/components/mail/ThreadListInteraction.test.ts +74 -4
- package/src/components/mail/ThreadListInteraction.tsx +58 -50
- package/src/components/mail/selection-verbs.test.ts +278 -0
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
- package/src/hooks/useEscalatedActions.ts +2 -2
- package/src/hooks/useInitialSyncProgress.render.test.ts +112 -85
- package/src/hooks/useMatchSample.ts +44 -3
- package/src/lib/bulk-action-copy.test.ts +0 -19
- package/src/lib/bulk-action-copy.ts +0 -8
- package/src/lib/bulk-actions.test.ts +0 -60
- package/src/lib/bulk-actions.ts +0 -26
- package/src/lib/format.test.ts +0 -27
- package/src/lib/format.ts +4 -14
- package/src/lib/organize/organize-model.ts +8 -2
- package/src/lib/wizard-history.ts +42 -2
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* are not rendered: focus stops moving, the highlight disappears, and the next
|
|
17
17
|
* verb acts on a message the user cannot see.
|
|
18
18
|
*/
|
|
19
|
-
import { SelectionTopBar } from "@remit/ui";
|
|
19
|
+
import { SelectionTopBar, type Verb } from "@remit/ui";
|
|
20
20
|
import {
|
|
21
21
|
createContext,
|
|
22
22
|
type ReactNode,
|
|
@@ -52,8 +52,12 @@ interface ThreadListInteractionValue {
|
|
|
52
52
|
selectedIds: Set<string>;
|
|
53
53
|
selectedCount: number;
|
|
54
54
|
exitSelection: () => void;
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Runs a verb over the current selection by opening the wizard on it — the
|
|
57
|
+
* one route every selection action takes, whether the bar or the keyboard
|
|
58
|
+
* asked for it.
|
|
59
|
+
*/
|
|
60
|
+
startSelectionVerb: (verb: Verb) => void;
|
|
57
61
|
/** Rendered rows in display order — the same order a shift-range spans. */
|
|
58
62
|
orderedIds: string[];
|
|
59
63
|
/** Whether every rendered row is selected, for a select-all control. */
|
|
@@ -144,6 +148,15 @@ interface ThreadListInteractionProps {
|
|
|
144
148
|
onOpen: (messageId: string, options?: OpenMessageOptions) => void;
|
|
145
149
|
/** Deletes a set of messages. Absent disables the delete key for this list. */
|
|
146
150
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
151
|
+
/**
|
|
152
|
+
* Runs a verb over the selection — which means opening the wizard on it, the
|
|
153
|
+
* one place a bulk action is reviewed before it reaches the mail server
|
|
154
|
+
* (#477 1.4). The provider never runs one itself, so no surface can grow a
|
|
155
|
+
* second unreviewed route to the same verb.
|
|
156
|
+
*/
|
|
157
|
+
onSelectionVerb: (verb: Verb) => void;
|
|
158
|
+
/** The wizard owns the screen, so this list's keyboard layer stands down. */
|
|
159
|
+
wizardOpen?: boolean;
|
|
147
160
|
isDeleting?: boolean;
|
|
148
161
|
commandsRef?: RefObject<MessageListCommands | null>;
|
|
149
162
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
@@ -154,6 +167,8 @@ export function ThreadListInteraction({
|
|
|
154
167
|
selectedMessageId,
|
|
155
168
|
onOpen,
|
|
156
169
|
onDeleteMessages,
|
|
170
|
+
onSelectionVerb,
|
|
171
|
+
wizardOpen = false,
|
|
157
172
|
isDeleting = false,
|
|
158
173
|
commandsRef,
|
|
159
174
|
onTriageContextChange,
|
|
@@ -237,36 +252,32 @@ export function ThreadListInteraction({
|
|
|
237
252
|
open: followOpen,
|
|
238
253
|
});
|
|
239
254
|
|
|
240
|
-
// Pending move-to-Trash
|
|
241
|
-
// request time so a
|
|
242
|
-
//
|
|
255
|
+
// Pending move-to-Trash for the row under the cursor, awaiting confirmation.
|
|
256
|
+
// The id is snapshotted at request time so a cursor move behind the dialog
|
|
257
|
+
// cannot retarget it. A delete over a selection is a bulk action and walks the
|
|
258
|
+
// wizard instead — the same contract the mailbox list's delete has.
|
|
243
259
|
const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
|
|
244
260
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
selectedCount,
|
|
266
|
-
selectedIds,
|
|
267
|
-
focusedMessageId,
|
|
268
|
-
requestDeleteIds,
|
|
269
|
-
]);
|
|
261
|
+
// A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
|
|
262
|
+
// selection every verb opens the wizard, so the keyboard cannot reach a bulk
|
|
263
|
+
// action the bar would have reviewed. Over a bare cursor only Delete is this
|
|
264
|
+
// list's, and it keeps its confirmation.
|
|
265
|
+
const requestVerb = useCallback(
|
|
266
|
+
(verb: Verb): boolean => {
|
|
267
|
+
// The confirmation is already asking about a delete: the keypress belongs
|
|
268
|
+
// to it. Claiming the press here is what stops a second Delete from
|
|
269
|
+
// reaching an unconfirmed delete.
|
|
270
|
+
if (pendingDelete !== null) return true;
|
|
271
|
+
if (selectedCount > 0) {
|
|
272
|
+
onSelectionVerb(verb);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
if (verb !== "delete" || !focusedMessageId) return false;
|
|
276
|
+
setPendingDelete([focusedMessageId]);
|
|
277
|
+
return true;
|
|
278
|
+
},
|
|
279
|
+
[pendingDelete, selectedCount, onSelectionVerb, focusedMessageId],
|
|
280
|
+
);
|
|
270
281
|
|
|
271
282
|
const confirmDelete = useCallback(() => {
|
|
272
283
|
if (pendingDelete === null) return;
|
|
@@ -302,7 +313,7 @@ export function ThreadListInteraction({
|
|
|
302
313
|
extendSelectUp: cursor.extendRangeUp,
|
|
303
314
|
selectAll: cursor.selectAllLoaded,
|
|
304
315
|
clearSelection: clearSelectionCommand,
|
|
305
|
-
|
|
316
|
+
requestVerb,
|
|
306
317
|
// The brief and Flagged have no density switch; the key stays inert here
|
|
307
318
|
// rather than moving a control these views do not offer.
|
|
308
319
|
toggleDensity: () => undefined,
|
|
@@ -323,7 +334,7 @@ export function ThreadListInteraction({
|
|
|
323
334
|
cursor.selectAllLoaded,
|
|
324
335
|
openFocused,
|
|
325
336
|
clearSelectionCommand,
|
|
326
|
-
|
|
337
|
+
requestVerb,
|
|
327
338
|
]);
|
|
328
339
|
|
|
329
340
|
const selectedIdList = useMemo(() => Array.from(selectedIds), [selectedIds]);
|
|
@@ -334,9 +345,11 @@ export function ThreadListInteraction({
|
|
|
334
345
|
selectedIds: selectedIdList,
|
|
335
346
|
orderedIds,
|
|
336
347
|
hasList,
|
|
337
|
-
// The dialog
|
|
338
|
-
// suspends rather than acting behind
|
|
339
|
-
|
|
348
|
+
// The dialog and the wizard each own the keyboard while they are up, so
|
|
349
|
+
// the triage layer suspends rather than acting behind them: a second
|
|
350
|
+
// Delete must not reach a delete, and no shortcut may start a second flow
|
|
351
|
+
// behind the screen already asking about one.
|
|
352
|
+
blocksKeyboard: confirmOpen || wizardOpen,
|
|
340
353
|
});
|
|
341
354
|
}, [
|
|
342
355
|
onTriageContextChange,
|
|
@@ -345,6 +358,7 @@ export function ThreadListInteraction({
|
|
|
345
358
|
orderedIds,
|
|
346
359
|
hasList,
|
|
347
360
|
confirmOpen,
|
|
361
|
+
wizardOpen,
|
|
348
362
|
]);
|
|
349
363
|
|
|
350
364
|
const tabStop = tabStopId(orderedIds, focusedMessageId);
|
|
@@ -354,7 +368,7 @@ export function ThreadListInteraction({
|
|
|
354
368
|
selectedIds,
|
|
355
369
|
selectedCount,
|
|
356
370
|
exitSelection,
|
|
357
|
-
|
|
371
|
+
startSelectionVerb: onSelectionVerb,
|
|
358
372
|
orderedIds,
|
|
359
373
|
allSelected,
|
|
360
374
|
toggleAllLoaded,
|
|
@@ -376,7 +390,7 @@ export function ThreadListInteraction({
|
|
|
376
390
|
selectedIds,
|
|
377
391
|
selectedCount,
|
|
378
392
|
exitSelection,
|
|
379
|
-
|
|
393
|
+
onSelectionVerb,
|
|
380
394
|
orderedIds,
|
|
381
395
|
allSelected,
|
|
382
396
|
toggleAllLoaded,
|
|
@@ -416,13 +430,14 @@ export function ThreadListInteraction({
|
|
|
416
430
|
interface ThreadListSelectionBarProps {
|
|
417
431
|
/** The view's own name, used until the enclosing header supplies one. */
|
|
418
432
|
title: string;
|
|
419
|
-
onMarkAsRead?: (messageIds: string[]) => void;
|
|
420
433
|
isDeleting?: boolean;
|
|
421
434
|
}
|
|
422
435
|
|
|
423
436
|
/**
|
|
424
437
|
* The starred list's header, which is also its selection bar — the same
|
|
425
|
-
* surface the mailbox list and the brief raise
|
|
438
|
+
* surface the mailbox list and the brief raise, with the same rule: every verb
|
|
439
|
+
* on it opens the wizard, and the review screen there is what names the action
|
|
440
|
+
* before it reaches the mail server (#477 1.4).
|
|
426
441
|
*
|
|
427
442
|
* Move is not offered here: starred mail spans accounts and mailboxes, and a
|
|
428
443
|
* move picker needs one account and one source folder to be honest about where
|
|
@@ -430,25 +445,18 @@ interface ThreadListSelectionBarProps {
|
|
|
430
445
|
*/
|
|
431
446
|
export function ThreadListSelectionBar({
|
|
432
447
|
title,
|
|
433
|
-
onMarkAsRead,
|
|
434
448
|
isDeleting,
|
|
435
449
|
}: ThreadListSelectionBarProps) {
|
|
436
450
|
const chrome = useListHeaderChrome();
|
|
437
451
|
const {
|
|
438
|
-
selectedIds,
|
|
439
452
|
selectedCount,
|
|
440
453
|
exitSelection,
|
|
441
|
-
|
|
454
|
+
startSelectionVerb,
|
|
442
455
|
orderedIds,
|
|
443
456
|
allSelected,
|
|
444
457
|
toggleAllLoaded,
|
|
445
458
|
} = useThreadListSelection();
|
|
446
459
|
|
|
447
|
-
const handleMarkAsRead = useCallback(() => {
|
|
448
|
-
onMarkAsRead?.(Array.from(selectedIds));
|
|
449
|
-
exitSelection();
|
|
450
|
-
}, [onMarkAsRead, selectedIds, exitSelection]);
|
|
451
|
-
|
|
452
460
|
return (
|
|
453
461
|
<SelectionTopBar
|
|
454
462
|
title={chrome.title || title}
|
|
@@ -459,8 +467,8 @@ export function ThreadListSelectionBar({
|
|
|
459
467
|
idleSlot={chrome.makeFilterSlot}
|
|
460
468
|
count={selectedCount}
|
|
461
469
|
onCancel={exitSelection}
|
|
462
|
-
onDelete={
|
|
463
|
-
onMarkRead={
|
|
470
|
+
onDelete={() => startSelectionVerb("delete")}
|
|
471
|
+
onMarkRead={() => startSelectionVerb("markRead")}
|
|
464
472
|
isBusy={isDeleting}
|
|
465
473
|
selectAll={
|
|
466
474
|
orderedIds.length > 0
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One invariant, over every surface: a verb aimed at a selection reaches the
|
|
9
|
+
* mail server only through the wizard, whose review screen names what it covers
|
|
10
|
+
* (#477 1.4 and its FAQ, #508).
|
|
11
|
+
*
|
|
12
|
+
* There are two ways to ask for one — the selection bar and the keyboard — and
|
|
13
|
+
* three surfaces that raise a bar. Checked here rather than by a spec per
|
|
14
|
+
* surface, because what has repeatedly gone wrong is not a broken route but a
|
|
15
|
+
* missing one: a fourth surface, or a sixth verb, quietly keeping a direct
|
|
16
|
+
* handler while every other route was moved. That is invisible in review and
|
|
17
|
+
* indistinguishable from correct until someone selects 3,412 messages and
|
|
18
|
+
* presses a key.
|
|
19
|
+
*
|
|
20
|
+
* These read the source rather than rendering, for the reason
|
|
21
|
+
* `MessageList.selection.test.ts` gives: these components wire the DOM, the
|
|
22
|
+
* router and several data hooks together, and the rule is about which function
|
|
23
|
+
* a prop names.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const read = (file: string): string =>
|
|
28
|
+
readFileSync(resolve(here, file), "utf8");
|
|
29
|
+
|
|
30
|
+
/** Every verb the bar can carry — the props `SelectionTopBar` accepts. */
|
|
31
|
+
const BAR_VERB_PROPS = [
|
|
32
|
+
"onDelete",
|
|
33
|
+
"onMove",
|
|
34
|
+
"onOrganize",
|
|
35
|
+
"onJunk",
|
|
36
|
+
"onMarkRead",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How a surface is allowed to answer a verb prop. `startWizard`/`wizard.start`/
|
|
41
|
+
* `startSelectionVerb` all open the wizard on the verb; `organizeSelection` and
|
|
42
|
+
* `startFromSearch` open it on the search entry, which is the same wizard
|
|
43
|
+
* reached by its other door (#477 1.8). Anything else is a route around the
|
|
44
|
+
* review screen.
|
|
45
|
+
*/
|
|
46
|
+
const OPENS_THE_WIZARD =
|
|
47
|
+
/^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
|
|
48
|
+
|
|
49
|
+
const surfaceFiles = (): string[] =>
|
|
50
|
+
readdirSync(here)
|
|
51
|
+
.filter((file) => file.endsWith(".tsx"))
|
|
52
|
+
.filter((file) => /<SelectionTopBar/.test(read(file)));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The bar element as written, to its own closing tag. Found by matching tags
|
|
56
|
+
* rather than by stopping at the first `/>` on a line: a nested self-closing
|
|
57
|
+
* element matches that too, so the scan would end early and silently skip
|
|
58
|
+
* whatever a surface happened to write after it.
|
|
59
|
+
*/
|
|
60
|
+
const barMarkup = (source: string): string => {
|
|
61
|
+
const start = source.indexOf("<SelectionTopBar");
|
|
62
|
+
if (start === -1) return "";
|
|
63
|
+
let depth = 0;
|
|
64
|
+
for (let i = start; i < source.length; i++) {
|
|
65
|
+
if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
|
|
66
|
+
else if (source.startsWith("/>", i)) {
|
|
67
|
+
depth--;
|
|
68
|
+
if (depth === 0) return source.slice(start, i + 2);
|
|
69
|
+
i++;
|
|
70
|
+
} else if (source.startsWith("</", i)) depth--;
|
|
71
|
+
}
|
|
72
|
+
return "";
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The body of `const <name> = useCallback(…)`, or of an inline arrow value. */
|
|
76
|
+
const bracedFrom = (source: string, open: number): string => {
|
|
77
|
+
let depth = 0;
|
|
78
|
+
for (let i = open; i < source.length; i++) {
|
|
79
|
+
if (source[i] === "{") depth++;
|
|
80
|
+
else if (source[i] === "}") {
|
|
81
|
+
depth--;
|
|
82
|
+
if (depth === 0) return source.slice(open, i + 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return source.slice(open);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const namedCallbackBody = (source: string, name: string): string => {
|
|
89
|
+
const at = source.indexOf(`const ${name} = useCallback(`);
|
|
90
|
+
if (at === -1) return "";
|
|
91
|
+
const open = source.indexOf("{", at);
|
|
92
|
+
return open === -1 ? "" : bracedFrom(source, open);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The triage handlers a pane wires into the keyboard layer, by name. */
|
|
96
|
+
const declaredHandlers = (source: string): string[] => {
|
|
97
|
+
const at = source.indexOf("handlers: {");
|
|
98
|
+
if (at === -1) return [];
|
|
99
|
+
return Array.from(
|
|
100
|
+
bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
|
|
101
|
+
(match) => match[1],
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What a pane does for one keyboard verb: the handler it wired into the triage
|
|
107
|
+
* layer, plus any callback that handler defers to for its target. A handler
|
|
108
|
+
* that offers the list the press and then falls through to a helper picking the
|
|
109
|
+
* selection is exactly as wrong as one picking it inline, so both are read.
|
|
110
|
+
*/
|
|
111
|
+
const paneVerbBody = (source: string, handler: string): string => {
|
|
112
|
+
const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
|
|
113
|
+
if (at === -1) return "";
|
|
114
|
+
const value =
|
|
115
|
+
source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
|
|
116
|
+
const body = value.startsWith("(")
|
|
117
|
+
? bracedFrom(source, source.indexOf("{", at))
|
|
118
|
+
: namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
|
|
119
|
+
const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
|
|
120
|
+
namedCallbackBody(source, match[1]),
|
|
121
|
+
);
|
|
122
|
+
return [body, ...helpers].join("\n");
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a verb prop is wired to, with the whitespace, the guard a verb is
|
|
127
|
+
* offered behind, and the arrow taken off — what is left is the call itself.
|
|
128
|
+
*/
|
|
129
|
+
const verbHandler = (bar: string, prop: string): string | undefined => {
|
|
130
|
+
const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
|
|
131
|
+
if (assigned === undefined) return undefined;
|
|
132
|
+
return assigned
|
|
133
|
+
.replace(/\s+/g, " ")
|
|
134
|
+
.replace(/^.*? \? /, "")
|
|
135
|
+
.replace(/ : undefined$/, "")
|
|
136
|
+
.replace(/^\(\)\s*=>\s*/, "")
|
|
137
|
+
.trim();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
describe("every verb on a selection bar opens the wizard", () => {
|
|
141
|
+
it("covers the surfaces that raise one", () => {
|
|
142
|
+
assert.deepEqual(surfaceFiles().sort(), [
|
|
143
|
+
"DailyBrief.tsx",
|
|
144
|
+
"MessageList.tsx",
|
|
145
|
+
"ThreadListInteraction.tsx",
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
for (const file of surfaceFiles()) {
|
|
150
|
+
it(`${file} routes each verb it offers through the wizard`, () => {
|
|
151
|
+
const bar = barMarkup(read(file));
|
|
152
|
+
assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
|
|
153
|
+
const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
|
|
154
|
+
assert.ok(offered.length > 0, `${file} offers at least one verb`);
|
|
155
|
+
for (const prop of offered) {
|
|
156
|
+
const handler = verbHandler(bar, prop);
|
|
157
|
+
assert.ok(handler, `${file}'s ${prop} is wired to something`);
|
|
158
|
+
assert.match(
|
|
159
|
+
handler,
|
|
160
|
+
OPENS_THE_WIZARD,
|
|
161
|
+
`${file}'s ${prop} must open the wizard, not run the verb`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The keyboard's half. A list offers the pane one seam — `requestVerb` — and
|
|
170
|
+
* claims the press whenever it has a selection, so a pane's own handler only
|
|
171
|
+
* ever sees a verb aimed at the bare cursor. A pane that runs a verb over
|
|
172
|
+
* `selectedIds` without offering the list the press first is the hole this
|
|
173
|
+
* binds shut.
|
|
174
|
+
*/
|
|
175
|
+
describe("every keyboard verb over a selection goes through the list", () => {
|
|
176
|
+
const LISTS = ["MessageList.tsx", "ThreadListInteraction.tsx"];
|
|
177
|
+
const PANES = ["MailboxPane.tsx", "BriefPane.tsx", "FlaggedPane.tsx"];
|
|
178
|
+
/**
|
|
179
|
+
* The triage handlers that can be aimed at a selection, each with the wizard
|
|
180
|
+
* verb it has to route to. Star is a knowing carve-out: it is not one of the
|
|
181
|
+
* five verbs the wizard walks, it is not on `SelectionTopBar`, and it sets a
|
|
182
|
+
* flag on mail already in front of the user that the same key unsets — so
|
|
183
|
+
* there is nothing for a review screen to name. `LabelApplyTrigger` applies
|
|
184
|
+
* labels to the selection directly for the same reason, and because labels
|
|
185
|
+
* are out of scope for the epic that put the wizard in front of the rest
|
|
186
|
+
* (#477 §6). Mute and block are per-sender and reach no selection at all.
|
|
187
|
+
*/
|
|
188
|
+
const PANE_VERB_ROUTE: Record<string, string> = {
|
|
189
|
+
delete: "delete",
|
|
190
|
+
toggleRead: "markRead",
|
|
191
|
+
markJunk: "junk",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
for (const file of LISTS) {
|
|
195
|
+
it(`${file} publishes the one seam and claims a selection`, () => {
|
|
196
|
+
const source = read(file);
|
|
197
|
+
assert.match(source, /requestVerb,/);
|
|
198
|
+
assert.match(
|
|
199
|
+
source,
|
|
200
|
+
/if \(verb !== "delete"/,
|
|
201
|
+
"only delete is the list's over a bare cursor",
|
|
202
|
+
);
|
|
203
|
+
assert.doesNotMatch(
|
|
204
|
+
source,
|
|
205
|
+
/requestDelete:/,
|
|
206
|
+
"one seam, so no verb can have a second one",
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it(`${file} stands its keyboard down while the wizard is up`, () => {
|
|
211
|
+
assert.match(
|
|
212
|
+
read(file),
|
|
213
|
+
/blocksKeyboard: confirmOpen \|\| wizard/,
|
|
214
|
+
"a shortcut must not act behind the screen already asking",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Only the handlers a pane really declares get a case, so a pane that never
|
|
220
|
+
// wired one cannot pass by skipping — the roll-call below is what notices a
|
|
221
|
+
// handler nobody routed.
|
|
222
|
+
for (const file of PANES) {
|
|
223
|
+
for (const handler of declaredHandlers(read(file))) {
|
|
224
|
+
const verb = PANE_VERB_ROUTE[handler];
|
|
225
|
+
if (verb === undefined) continue;
|
|
226
|
+
|
|
227
|
+
it(`${file}'s ${handler} offers the list ${verb} first`, () => {
|
|
228
|
+
const body = paneVerbBody(read(file), handler);
|
|
229
|
+
assert.notEqual(body, "", `${handler}'s body was found`);
|
|
230
|
+
assert.match(
|
|
231
|
+
body,
|
|
232
|
+
new RegExp(`requestVerb\\("${verb}"\\)`),
|
|
233
|
+
`${handler} must hand ${verb} to the list before running it`,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it(`${file}'s ${handler} aims at the cursor once the list declines`, () => {
|
|
238
|
+
const body = paneVerbBody(read(file), handler);
|
|
239
|
+
assert.notEqual(body, "", `${handler}'s body was found`);
|
|
240
|
+
// The list declines only when its commands are gone, which an
|
|
241
|
+
// escalated selection outlives — so a fallback that could still see
|
|
242
|
+
// a selection would run the verb over a stale snapshot of rows that
|
|
243
|
+
// are no longer loaded, with no wizard in front of it.
|
|
244
|
+
assert.doesNotMatch(
|
|
245
|
+
body,
|
|
246
|
+
/selectedIds/i,
|
|
247
|
+
`${handler} falls back to the focused row, never to a selection`,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
it("checks the routed handler on every pane that declares one", () => {
|
|
254
|
+
// The roll-call. Each pair above is generated from what a pane declares, so
|
|
255
|
+
// this is what says the generation reached everything it should have — a
|
|
256
|
+
// handler that stops being declared, or a body the reader stops finding,
|
|
257
|
+
// shows up here as a missing pair rather than as a case that passed by
|
|
258
|
+
// doing nothing.
|
|
259
|
+
const checked = PANES.flatMap((file) =>
|
|
260
|
+
declaredHandlers(read(file))
|
|
261
|
+
.filter((handler) => handler in PANE_VERB_ROUTE)
|
|
262
|
+
.map((handler) => `${file}:${handler}`),
|
|
263
|
+
);
|
|
264
|
+
assert.deepEqual(checked.sort(), [
|
|
265
|
+
"BriefPane.tsx:delete",
|
|
266
|
+
"BriefPane.tsx:toggleRead",
|
|
267
|
+
"FlaggedPane.tsx:delete",
|
|
268
|
+
"FlaggedPane.tsx:toggleRead",
|
|
269
|
+
"MailboxPane.tsx:delete",
|
|
270
|
+
"MailboxPane.tsx:markJunk",
|
|
271
|
+
"MailboxPane.tsx:toggleRead",
|
|
272
|
+
]);
|
|
273
|
+
for (const entry of checked) {
|
|
274
|
+
const [file, handler] = entry.split(":");
|
|
275
|
+
assert.notEqual(paneVerbBody(read(file), handler), "", entry);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -34,21 +34,6 @@ export const OneMessage: Story = {
|
|
|
34
34
|
},
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
* An escalated (search-predicate) delete: the count was paged once by
|
|
39
|
-
* `countMatches` and the delete re-pages the same predicate independently, so
|
|
40
|
-
* it is not provably the number that gets deleted (#109). "about" and the
|
|
41
|
-
* description say so up front rather than stating an exact number the run may
|
|
42
|
-
* not honour.
|
|
43
|
-
*/
|
|
44
|
-
export const EscalatedEstimate: Story = {
|
|
45
|
-
args: {
|
|
46
|
-
title: "Move about 3,412 messages to Trash?",
|
|
47
|
-
description:
|
|
48
|
-
"This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later.",
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
|
|
52
37
|
/** The mutation is in flight: the confirm button disables rather than
|
|
53
38
|
* allowing a second concurrent delete request. */
|
|
54
39
|
export const Busy: Story = {
|
|
@@ -97,8 +97,8 @@ export interface UseEscalatedActionsResult {
|
|
|
97
97
|
* Runs `action` in chunks. Pass `ids` for a materialized (bounded)
|
|
98
98
|
* selection; omit it to run against the escalated predicate (`phase` must
|
|
99
99
|
* be "escalated"). Resolves once the run ends for any reason — cancelled,
|
|
100
|
-
* errored, or complete — with a `done`/`failedIds` outcome the caller
|
|
101
|
-
* to
|
|
100
|
+
* errored, or complete — with a `done`/`failedIds` outcome the caller reads
|
|
101
|
+
* to decide what is still outstanding.
|
|
102
102
|
* Infrastructure failures are reported through the app's existing
|
|
103
103
|
* escalation seam (`pushError`, which itself escalates a 5xx/exception to
|
|
104
104
|
* the fatal overlay) — not swallowed here.
|