@remit/web-client 0.0.94 → 0.0.95
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/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
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
deriveSenderClauses,
|
|
9
9
|
dominantSender,
|
|
10
10
|
type MatchCount,
|
|
11
|
+
type MatchDoor,
|
|
11
12
|
type MatchMode,
|
|
12
13
|
type MoveMailboxOption,
|
|
13
14
|
type RuleClause,
|
|
@@ -36,15 +37,16 @@ import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
|
36
37
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
37
38
|
import {
|
|
38
39
|
type EscalatedAction,
|
|
40
|
+
type EscalationSearchQuery,
|
|
39
41
|
useEscalatedActions,
|
|
40
42
|
} from "@/hooks/useEscalatedActions";
|
|
41
43
|
import { useCreateFilter } from "@/hooks/useFilters";
|
|
42
|
-
import { useMatchSample } from "@/hooks/useMatchSample";
|
|
44
|
+
import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
|
|
43
45
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
44
46
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
45
47
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
46
48
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
47
|
-
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
49
|
+
import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
48
50
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
49
51
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
50
52
|
import { useMailContext } from "@/lib/mail-context";
|
|
@@ -87,6 +89,28 @@ interface SelectionWizardSessionProps extends SelectionWizardHostProps {
|
|
|
87
89
|
searchConversion?: SearchConversion;
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The selection the list escalated to a predicate — every message matching the
|
|
94
|
+
* query it is showing, rather than the rows on screen (#508). The wizard walks
|
|
95
|
+
* it exactly as it walks a ticked selection: the same screens, the same review
|
|
96
|
+
* before anything is sent. What differs is that the match has no member list,
|
|
97
|
+
* so the count and the sample both come from the server that resolved it.
|
|
98
|
+
*/
|
|
99
|
+
export interface EscalatedSelection {
|
|
100
|
+
/** What the predicate covers, in the words the list escalated it with. */
|
|
101
|
+
scope: string;
|
|
102
|
+
/** The server's count of the whole match, which the run is offered against. */
|
|
103
|
+
total: number;
|
|
104
|
+
/** The predicate itself, which the sample and the run both re-resolve. */
|
|
105
|
+
searchQuery: EscalationSearchQuery;
|
|
106
|
+
/**
|
|
107
|
+
* Runs one verb over the whole predicate, paging it server-side. The chunked
|
|
108
|
+
* runner the list already owns: the run screen drives it, and the review
|
|
109
|
+
* screen is what now stands in front of it.
|
|
110
|
+
*/
|
|
111
|
+
run: (action: EscalatedAction) => Promise<BulkRunOutcome>;
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
export interface SelectionWizardHostProps {
|
|
91
115
|
/** What the bar was pressed for. Every verb walks the same wizard. */
|
|
92
116
|
verb: Verb;
|
|
@@ -97,6 +121,16 @@ export interface SelectionWizardHostProps {
|
|
|
97
121
|
selection: readonly WizardSelectionMessage[];
|
|
98
122
|
/** The ticked rows span more than one account, so no rule can be created. */
|
|
99
123
|
crossAccount?: boolean;
|
|
124
|
+
/** Present when the selection is a predicate rather than the ticked rows. */
|
|
125
|
+
escalated?: EscalatedSelection;
|
|
126
|
+
/**
|
|
127
|
+
* How far the escalated runner has got. Read live and passed apart from
|
|
128
|
+
* {@link EscalatedSelection}, which the wizard holds for the whole walk: the
|
|
129
|
+
* list's escalation returns to idle when a run ends, so a progress reading
|
|
130
|
+
* taken off the held predicate would be frozen at `undefined` for every
|
|
131
|
+
* retry after the first and the bar would never move.
|
|
132
|
+
*/
|
|
133
|
+
escalatedProgress?: BulkActionProgress;
|
|
100
134
|
/** The wizard is done with the selection, and the list drops it. */
|
|
101
135
|
onFinished: () => void;
|
|
102
136
|
}
|
|
@@ -168,6 +202,26 @@ const bulkActionFor = (
|
|
|
168
202
|
const widenedRunsAsJob = (verb: Verb): boolean =>
|
|
169
203
|
verb === "move" || verb === "junk" || verb === "organize";
|
|
170
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The commit pressed with nowhere for the mail to go. A verb that files mail
|
|
207
|
+
* needs a destination, and reaching the run screen without one is a failure the
|
|
208
|
+
* screen states along with the way out of it — never a control that does
|
|
209
|
+
* nothing.
|
|
210
|
+
*/
|
|
211
|
+
const noDestinationOutcome = (
|
|
212
|
+
verb: Verb,
|
|
213
|
+
ids: readonly string[],
|
|
214
|
+
): BulkRunOutcome => ({
|
|
215
|
+
done: 0,
|
|
216
|
+
failedIds: [...ids],
|
|
217
|
+
cancelled: false,
|
|
218
|
+
error: new Error(
|
|
219
|
+
verb === "junk"
|
|
220
|
+
? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
|
|
221
|
+
: "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
|
|
222
|
+
),
|
|
223
|
+
});
|
|
224
|
+
|
|
171
225
|
/**
|
|
172
226
|
* Where the wizard meets the app (#483). The steps and their bodies belong to
|
|
173
227
|
* `@remit/ui`; everything that talks to a server is here: the widen, the live
|
|
@@ -190,6 +244,8 @@ function SelectionWizardSession({
|
|
|
190
244
|
mailboxId,
|
|
191
245
|
selection,
|
|
192
246
|
crossAccount = false,
|
|
247
|
+
escalated: escalatedSelection,
|
|
248
|
+
escalatedProgress,
|
|
193
249
|
searchConversion,
|
|
194
250
|
onFinished,
|
|
195
251
|
step,
|
|
@@ -201,10 +257,20 @@ function SelectionWizardSession({
|
|
|
201
257
|
// still settling underneath cannot rewrite the notice a user is reading while
|
|
202
258
|
// the clauses beside it stay as they were seeded.
|
|
203
259
|
const [conversion] = useState(() => searchConversion);
|
|
260
|
+
// The predicate this walk is for, held for the walk. The list's escalation
|
|
261
|
+
// returns to idle the moment a run ends, and a wizard reading it live would
|
|
262
|
+
// lose the match its run screen is reporting on — and would offer a retry over
|
|
263
|
+
// the loaded rows instead of over the predicate.
|
|
264
|
+
const [escalated] = useState(() => escalatedSelection);
|
|
204
265
|
const fromSearch = conversion !== undefined;
|
|
205
|
-
|
|
266
|
+
// The door the wizard is on. An escalated predicate is not one of them and is
|
|
267
|
+
// not held here: it is a property of the selection the list handed over, so
|
|
268
|
+
// the mode below is derived rather than settable. No screen can escalate a
|
|
269
|
+
// selection, and the commit path can be typed to the doors.
|
|
270
|
+
const [door, setDoor] = useState<MatchDoor>(
|
|
206
271
|
fromSearch ? "properties" : "selected",
|
|
207
272
|
);
|
|
273
|
+
const mode: MatchMode = escalated ? "escalated" : door;
|
|
208
274
|
const [draft, setDraft] = useState<WizardDraft>(() =>
|
|
209
275
|
conversion
|
|
210
276
|
? {
|
|
@@ -243,7 +309,13 @@ function SelectionWizardSession({
|
|
|
243
309
|
const subjects = useSelectedSubjects(messageIds);
|
|
244
310
|
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
245
311
|
|
|
246
|
-
|
|
312
|
+
// No door is offered over an escalated predicate, so nothing here has a widen
|
|
313
|
+
// to probe for.
|
|
314
|
+
const widen = useOrganizeWiden(
|
|
315
|
+
escalated ? undefined : accountId,
|
|
316
|
+
anchorMessageId,
|
|
317
|
+
senders,
|
|
318
|
+
);
|
|
247
319
|
const { preview: probeWiden } = widen;
|
|
248
320
|
// The similar door has to know before it is pressed whether it can run, so
|
|
249
321
|
// the probe fires with the wizard rather than with the door (#477 3.6).
|
|
@@ -251,7 +323,10 @@ function SelectionWizardSession({
|
|
|
251
323
|
probeWiden();
|
|
252
324
|
}, [probeWiden]);
|
|
253
325
|
|
|
254
|
-
|
|
326
|
+
// A door the wizard resolves itself, through the preview endpoint. The
|
|
327
|
+
// escalated predicate is resolved by the list before the wizard opens, and
|
|
328
|
+
// the ticked list is its own answer, so neither is previewed.
|
|
329
|
+
const previewed = mode === "similar" || mode === "properties";
|
|
255
330
|
const literalPredicate: OrganizeMatchPredicate = useMemo(
|
|
256
331
|
() => ({
|
|
257
332
|
matchOperator: draft.matchOperator === "all" ? "And" : "Or",
|
|
@@ -268,14 +343,21 @@ function SelectionWizardSession({
|
|
|
268
343
|
// in the browser (#477 5.3). The ticked list is its own count, so no request
|
|
269
344
|
// is made for it.
|
|
270
345
|
const { count: previewCount, matchedIds } = useRulePreview(
|
|
271
|
-
|
|
346
|
+
previewed ? accountId : undefined,
|
|
272
347
|
predicate,
|
|
273
348
|
);
|
|
274
|
-
const matchSample = useMatchSample(
|
|
349
|
+
const matchSample = useMatchSample(previewed ? matchedIds : []);
|
|
350
|
+
// The escalated match's own members, read from the search that resolved it.
|
|
351
|
+
const escalatedSample = useSearchMatchSample(
|
|
352
|
+
escalated ? mailboxId : undefined,
|
|
353
|
+
escalated?.searchQuery,
|
|
354
|
+
);
|
|
275
355
|
|
|
276
|
-
const count: MatchCount =
|
|
277
|
-
?
|
|
278
|
-
:
|
|
356
|
+
const count: MatchCount = escalated
|
|
357
|
+
? { status: "ready", count: escalated.total }
|
|
358
|
+
: previewed
|
|
359
|
+
? previewCount
|
|
360
|
+
: { status: "ready", count: selection.length };
|
|
279
361
|
|
|
280
362
|
const { data: mailboxesData } = useQuery({
|
|
281
363
|
...mailboxOperationsListMailboxesOptions({
|
|
@@ -371,8 +453,8 @@ function SelectionWizardSession({
|
|
|
371
453
|
);
|
|
372
454
|
|
|
373
455
|
const changeMode = useCallback(
|
|
374
|
-
(next:
|
|
375
|
-
|
|
456
|
+
(next: MatchDoor) => {
|
|
457
|
+
setDoor(next);
|
|
376
458
|
setDraft((held) => ({
|
|
377
459
|
...held,
|
|
378
460
|
widen:
|
|
@@ -392,7 +474,7 @@ function SelectionWizardSession({
|
|
|
392
474
|
// semantic match without saying so (#477 3.6).
|
|
393
475
|
const takeSemanticFallback = useCallback(() => {
|
|
394
476
|
setSemanticFallbackTaken(true);
|
|
395
|
-
|
|
477
|
+
setDoor("properties");
|
|
396
478
|
setDraft((held) => ({
|
|
397
479
|
...held,
|
|
398
480
|
widen: undefined,
|
|
@@ -486,16 +568,7 @@ function SelectionWizardSession({
|
|
|
486
568
|
if (!action) {
|
|
487
569
|
setBulkRun({
|
|
488
570
|
matched: ids.length,
|
|
489
|
-
outcome:
|
|
490
|
-
done: 0,
|
|
491
|
-
failedIds: [...ids],
|
|
492
|
-
cancelled: false,
|
|
493
|
-
error: new Error(
|
|
494
|
-
verb === "junk"
|
|
495
|
-
? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
|
|
496
|
-
: "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
|
|
497
|
-
),
|
|
498
|
-
},
|
|
571
|
+
outcome: noDestinationOutcome(verb, ids),
|
|
499
572
|
});
|
|
500
573
|
return;
|
|
501
574
|
}
|
|
@@ -506,13 +579,38 @@ function SelectionWizardSession({
|
|
|
506
579
|
[verb, named.moveMailboxId, junkMailboxId, runAction],
|
|
507
580
|
);
|
|
508
581
|
|
|
582
|
+
// The escalated predicate, run by the chunked runner the list already owns.
|
|
583
|
+
// It pages the match on the server rather than acting on ids the browser
|
|
584
|
+
// loaded, so the verb reaches every message the review screen counted.
|
|
585
|
+
const runEscalated = useCallback(async () => {
|
|
586
|
+
if (!escalated) return;
|
|
587
|
+
const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
|
|
588
|
+
if (!action) {
|
|
589
|
+
setBulkRun({
|
|
590
|
+
matched: escalated.total,
|
|
591
|
+
outcome: noDestinationOutcome(verb, []),
|
|
592
|
+
});
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
setBulkRun({ matched: escalated.total });
|
|
596
|
+
const outcome = await escalated.run(action);
|
|
597
|
+
setBulkRun({ matched: escalated.total, outcome });
|
|
598
|
+
}, [escalated, verb, named.moveMailboxId, junkMailboxId]);
|
|
599
|
+
|
|
509
600
|
const { start: startJob } = organizeJob;
|
|
510
601
|
const { createFilterAsync } = createFilter;
|
|
511
602
|
|
|
512
603
|
const sendCommit = useCallback(() => {
|
|
513
|
-
|
|
604
|
+
// An escalated predicate is not a rule and has no scope to reconstruct: it
|
|
605
|
+
// is the search the list is showing, applied once, by the runner that
|
|
606
|
+
// resolved it.
|
|
607
|
+
if (escalated) {
|
|
608
|
+
void runEscalated();
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const scope = organizeScopeFor({ mode: door, ruleScope: named.scope });
|
|
514
612
|
const organizeDraft = buildWizardDraft({
|
|
515
|
-
mode,
|
|
613
|
+
mode: door,
|
|
516
614
|
ruleScope: named.scope,
|
|
517
615
|
anchorMessageId,
|
|
518
616
|
clauses: named.clauses,
|
|
@@ -547,7 +645,9 @@ function SelectionWizardSession({
|
|
|
547
645
|
}
|
|
548
646
|
void runBulk(scope === "just-these" ? messageIds : matchedIds);
|
|
549
647
|
}, [
|
|
550
|
-
|
|
648
|
+
escalated,
|
|
649
|
+
runEscalated,
|
|
650
|
+
door,
|
|
551
651
|
named,
|
|
552
652
|
anchorMessageId,
|
|
553
653
|
verb,
|
|
@@ -596,43 +696,75 @@ function SelectionWizardSession({
|
|
|
596
696
|
return NOT_STARTED;
|
|
597
697
|
}, [organizeJob]);
|
|
598
698
|
|
|
699
|
+
// Every row the wizard has seen a description of, so a run that names what it
|
|
700
|
+
// did not reach can name it rather than listing an id.
|
|
599
701
|
const rowsById = useMemo(() => {
|
|
600
702
|
const rows = new Map<string, WizardMessage>();
|
|
601
|
-
for (const message of [
|
|
703
|
+
for (const message of [
|
|
704
|
+
...selection,
|
|
705
|
+
...matchSample.messages,
|
|
706
|
+
...escalatedSample.messages,
|
|
707
|
+
]) {
|
|
602
708
|
rows.set(message.id, message);
|
|
603
709
|
}
|
|
604
710
|
return rows;
|
|
605
|
-
}, [selection, matchSample.messages]);
|
|
711
|
+
}, [selection, matchSample.messages, escalatedSample.messages]);
|
|
712
|
+
|
|
713
|
+
const runProgress = escalated ? escalatedProgress : bulk.progress;
|
|
606
714
|
|
|
607
715
|
const bulkSnapshot = useCallback((): RunSnapshot => {
|
|
608
716
|
if (!bulkRun) return NOT_STARTED;
|
|
609
717
|
const { matched, outcome } = bulkRun;
|
|
610
718
|
if (!outcome) {
|
|
719
|
+
const applied = runProgress?.done ?? 0;
|
|
720
|
+
// A predicate matches more by the time the run re-pages it than the count
|
|
721
|
+
// saw, so what it has covered can overtake what it was offered against.
|
|
722
|
+
// The bar never reads more done than out of, and neither does this.
|
|
611
723
|
return {
|
|
612
724
|
state: "backApplyRunning",
|
|
613
|
-
matched,
|
|
614
|
-
applied
|
|
725
|
+
matched: Math.max(matched, applied),
|
|
726
|
+
applied,
|
|
615
727
|
failed: 0,
|
|
616
728
|
failures: [],
|
|
617
729
|
};
|
|
618
730
|
}
|
|
619
|
-
|
|
731
|
+
const stopped = outcome.cancelled || outcome.error !== undefined;
|
|
732
|
+
if (!stopped) {
|
|
733
|
+
return {
|
|
734
|
+
state: "backApplyComplete",
|
|
735
|
+
matched: Math.max(matched, outcome.done),
|
|
736
|
+
applied: outcome.done,
|
|
737
|
+
failed: 0,
|
|
738
|
+
failures: [],
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (outcome.done === 0) {
|
|
620
742
|
return { ...NOT_STARTED, state: "commitFailed" };
|
|
621
743
|
}
|
|
744
|
+
// The run stopped part-way. Nothing here was rejected: a returned bulk call
|
|
745
|
+
// accepts every id in it, so the only failure this layer sees is a call that
|
|
746
|
+
// threw, and everything after it was never sent. A bounded run hands back
|
|
747
|
+
// exactly those ids; a predicate run re-resolves its match on every pass and
|
|
748
|
+
// has no remainder to hand back, so what is left is the difference between
|
|
749
|
+
// the count and what the run covered.
|
|
622
750
|
const failures = outcome.failedIds
|
|
623
751
|
.map((id) => rowsById.get(id))
|
|
624
752
|
.filter((message): message is WizardMessage => message !== undefined);
|
|
753
|
+
const unreached =
|
|
754
|
+
outcome.failedIds.length > 0
|
|
755
|
+
? outcome.failedIds.length
|
|
756
|
+
: Math.max(matched - outcome.done, 1);
|
|
625
757
|
return {
|
|
626
|
-
state:
|
|
627
|
-
|
|
628
|
-
matched,
|
|
758
|
+
state: "runStopped",
|
|
759
|
+
matched: Math.max(matched, outcome.done),
|
|
629
760
|
applied: outcome.done,
|
|
630
|
-
failed:
|
|
761
|
+
failed: unreached,
|
|
631
762
|
failures,
|
|
632
763
|
};
|
|
633
|
-
}, [bulkRun,
|
|
764
|
+
}, [bulkRun, runProgress, rowsById]);
|
|
634
765
|
|
|
635
766
|
const runSnapshot = (): RunSnapshot => {
|
|
767
|
+
if (escalated) return bulkSnapshot();
|
|
636
768
|
if (committedScope === "standing" || committedScope === "temporary") {
|
|
637
769
|
if (createFilter.isError)
|
|
638
770
|
return { ...NOT_STARTED, state: "commitFailed" };
|
|
@@ -653,6 +785,12 @@ function SelectionWizardSession({
|
|
|
653
785
|
// walking back to Review, which would push an entry the wizard does not own
|
|
654
786
|
// and leave Cancel rewinding to a step instead of out.
|
|
655
787
|
const retry = (): void => {
|
|
788
|
+
// The predicate is re-resolved, not resumed: every verb it carries is
|
|
789
|
+
// idempotent, so the messages the first pass already reached are a no-op.
|
|
790
|
+
if (escalated) {
|
|
791
|
+
void runEscalated();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
656
794
|
if (committedScope === "standing" || committedScope === "temporary") {
|
|
657
795
|
if (createFilter.isError) {
|
|
658
796
|
createFilter.reset();
|
|
@@ -704,15 +842,22 @@ function SelectionWizardSession({
|
|
|
704
842
|
});
|
|
705
843
|
|
|
706
844
|
const run = runSnapshot();
|
|
845
|
+
const sampleMessages = escalated
|
|
846
|
+
? escalatedSample.messages
|
|
847
|
+
: previewed
|
|
848
|
+
? matchSample.messages
|
|
849
|
+
: selection;
|
|
707
850
|
const sample = {
|
|
708
|
-
messages:
|
|
851
|
+
messages: sampleMessages,
|
|
709
852
|
count,
|
|
710
|
-
label:
|
|
853
|
+
label: mode === "selected" ? "Your selection" : "A sample of what matches",
|
|
711
854
|
emptyReason:
|
|
712
855
|
mode === "similar" && semanticUnavailable
|
|
713
856
|
? ("notIndexed" as const)
|
|
714
857
|
: ("noMatch" as const),
|
|
715
|
-
loading:
|
|
858
|
+
loading: escalated
|
|
859
|
+
? escalatedSample.isPending
|
|
860
|
+
: previewed && (count.status === "loading" || matchSample.isPending),
|
|
716
861
|
};
|
|
717
862
|
|
|
718
863
|
return (
|
|
@@ -735,6 +880,7 @@ function SelectionWizardSession({
|
|
|
735
880
|
widen.error instanceof Error ? widen.error.message : undefined,
|
|
736
881
|
semanticFallbackTaken,
|
|
737
882
|
onSemanticFallback: takeSemanticFallback,
|
|
883
|
+
escalatedScope: escalated?.scope,
|
|
738
884
|
sample,
|
|
739
885
|
}}
|
|
740
886
|
properties={{
|
|
@@ -786,6 +932,7 @@ function SelectionWizardSession({
|
|
|
786
932
|
scope: named.scope,
|
|
787
933
|
until: named.until,
|
|
788
934
|
ruleName: steps.includes("name") ? named.name : undefined,
|
|
935
|
+
escalatedScope: escalated?.scope,
|
|
789
936
|
sample,
|
|
790
937
|
}}
|
|
791
938
|
run={{
|
|
@@ -836,6 +983,7 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
|
836
983
|
accountId: conversion.targetAccountId ?? accounts[0]?.accountId,
|
|
837
984
|
selection: EMPTY_SELECTION,
|
|
838
985
|
crossAccount: false,
|
|
986
|
+
escalated: undefined,
|
|
839
987
|
searchConversion: conversion,
|
|
840
988
|
}
|
|
841
989
|
: {})}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
11
|
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
12
|
+
import type { Verb } from "@remit/ui";
|
|
12
13
|
import type { JSDOM } from "jsdom";
|
|
13
14
|
import { act, createElement, createRef, useState } from "react";
|
|
14
15
|
import { createRoot, type Root } from "react-dom/client";
|
|
@@ -68,8 +69,10 @@ const rowElements = (ids: string[]) =>
|
|
|
68
69
|
function mountList(options: {
|
|
69
70
|
initialIds: string[];
|
|
70
71
|
onDeleteMessages?: (ids: string[]) => void;
|
|
72
|
+
onSelectionVerb?: (verb: Verb) => void;
|
|
71
73
|
}) {
|
|
72
74
|
const onDeleteMessages = options.onDeleteMessages ?? (() => undefined);
|
|
75
|
+
const onSelectionVerb = options.onSelectionVerb ?? (() => undefined);
|
|
73
76
|
const commandsRef = createRef<MessageListCommands | null>();
|
|
74
77
|
let setIds: ((ids: string[]) => void) | undefined;
|
|
75
78
|
const Harness = () => {
|
|
@@ -81,6 +84,7 @@ function mountList(options: {
|
|
|
81
84
|
selectedMessageId: undefined,
|
|
82
85
|
onOpen: () => undefined,
|
|
83
86
|
onDeleteMessages,
|
|
87
|
+
onSelectionVerb,
|
|
84
88
|
commandsRef,
|
|
85
89
|
},
|
|
86
90
|
...rowElements(ids),
|
|
@@ -146,6 +150,71 @@ describe("ThreadListInteraction — the cursor follows the rendered rows", () =>
|
|
|
146
150
|
});
|
|
147
151
|
});
|
|
148
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The provider runs no verb over a selection. Every one of them is handed out
|
|
155
|
+
* to be opened on the wizard, which is the only place a bulk action is named
|
|
156
|
+
* before it reaches the mail server (#477 1.4, #508). A verb aimed at the bare
|
|
157
|
+
* cursor is one message rather than a bulk action, and only Delete is this
|
|
158
|
+
* list's — it keeps its confirmation.
|
|
159
|
+
*/
|
|
160
|
+
describe("ThreadListInteraction — a verb over a selection goes to the wizard", () => {
|
|
161
|
+
const selectRow = (
|
|
162
|
+
list: ReturnType<typeof mountList>,
|
|
163
|
+
toggleFirst = true,
|
|
164
|
+
) => {
|
|
165
|
+
act(() => list.commands().focusFirst());
|
|
166
|
+
if (toggleFirst) act(() => list.commands().toggleSelect());
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const verb of [
|
|
170
|
+
"delete",
|
|
171
|
+
"move",
|
|
172
|
+
"junk",
|
|
173
|
+
"markRead",
|
|
174
|
+
"organize",
|
|
175
|
+
] as const) {
|
|
176
|
+
it(`hands ${verb} over rather than running it`, () => {
|
|
177
|
+
const handed: Verb[] = [];
|
|
178
|
+
const deleted: string[][] = [];
|
|
179
|
+
const list = mountList({
|
|
180
|
+
initialIds: ["m1", "m2"],
|
|
181
|
+
onDeleteMessages: (ids) => deleted.push(ids),
|
|
182
|
+
onSelectionVerb: (v) => handed.push(v),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
selectRow(list);
|
|
186
|
+
act(() => {
|
|
187
|
+
assert.equal(list.commands().requestVerb(verb), true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(handed, [verb]);
|
|
191
|
+
assert.deepEqual(deleted, [], "nothing runs from here");
|
|
192
|
+
assert.equal(
|
|
193
|
+
Array.from(
|
|
194
|
+
dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
|
|
195
|
+
).some((b) => b.textContent === "Move to Trash"),
|
|
196
|
+
false,
|
|
197
|
+
"no second confirmation stands in for the review screen",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("leaves a verb aimed at the bare cursor to the pane, except delete", () => {
|
|
203
|
+
const handed: Verb[] = [];
|
|
204
|
+
const list = mountList({
|
|
205
|
+
initialIds: ["m1", "m2"],
|
|
206
|
+
onSelectionVerb: (v) => handed.push(v),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
act(() => list.commands().focusFirst());
|
|
210
|
+
act(() => {
|
|
211
|
+
assert.equal(list.commands().requestVerb("markRead"), false);
|
|
212
|
+
assert.equal(list.commands().requestVerb("junk"), false);
|
|
213
|
+
});
|
|
214
|
+
assert.deepEqual(handed, [], "one row is not a selection");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
149
218
|
describe("ThreadListInteraction — delete confirms first", () => {
|
|
150
219
|
const confirmButton = () =>
|
|
151
220
|
Array.from(
|
|
@@ -161,7 +230,7 @@ describe("ThreadListInteraction — delete confirms first", () => {
|
|
|
161
230
|
|
|
162
231
|
act(() => list.commands().focusFirst());
|
|
163
232
|
act(() => {
|
|
164
|
-
assert.equal(list.commands().
|
|
233
|
+
assert.equal(list.commands().requestVerb("delete"), true);
|
|
165
234
|
});
|
|
166
235
|
assert.deepEqual(deleted, [], "nothing is deleted before confirming");
|
|
167
236
|
assert.ok(confirmButton(), "the confirmation is on screen");
|
|
@@ -179,11 +248,11 @@ describe("ThreadListInteraction — delete confirms first", () => {
|
|
|
179
248
|
|
|
180
249
|
act(() => list.commands().focusFirst());
|
|
181
250
|
act(() => {
|
|
182
|
-
list.commands().
|
|
251
|
+
list.commands().requestVerb("delete");
|
|
183
252
|
});
|
|
184
253
|
act(() => {
|
|
185
254
|
assert.equal(
|
|
186
|
-
list.commands().
|
|
255
|
+
list.commands().requestVerb("delete"),
|
|
187
256
|
true,
|
|
188
257
|
"the second press belongs to the dialog",
|
|
189
258
|
);
|
|
@@ -197,7 +266,7 @@ describe("ThreadListInteraction — delete confirms first", () => {
|
|
|
197
266
|
onDeleteMessages: () => undefined,
|
|
198
267
|
});
|
|
199
268
|
act(() => {
|
|
200
|
-
assert.equal(list.commands().
|
|
269
|
+
assert.equal(list.commands().requestVerb("delete"), false);
|
|
201
270
|
});
|
|
202
271
|
});
|
|
203
272
|
});
|
|
@@ -227,6 +296,7 @@ function mountSelectableList(initialIds: string[]) {
|
|
|
227
296
|
selectedMessageId: undefined,
|
|
228
297
|
onOpen: () => undefined,
|
|
229
298
|
onDeleteMessages: () => undefined,
|
|
299
|
+
onSelectionVerb: () => undefined,
|
|
230
300
|
commandsRef,
|
|
231
301
|
},
|
|
232
302
|
...rowElements(ids),
|