@remit/web-client 0.0.93 → 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 +37 -39
- package/src/components/mail/FlaggedList.tsx +55 -8
- package/src/components/mail/FlaggedPane.tsx +9 -23
- package/src/components/mail/MailListHeader.tsx +41 -39
- 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 +265 -48
- package/src/components/mail/ThreadListInteraction.test.ts +74 -4
- package/src/components/mail/ThreadListInteraction.tsx +58 -50
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- 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/hooks/useRulePreview.ts +1 -2
- 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/list-header-chrome.ts +10 -0
- package/src/lib/organize/organize-model.ts +8 -2
- package/src/lib/organize/rule-model.ts +0 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +97 -9
- package/src/routes/mail.tsx +3 -1
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
- package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
- package/src/components/mail/organize/SearchFilterEditor.tsx +0 -140
- package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
|
@@ -8,12 +8,15 @@ 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,
|
|
14
15
|
type RunState,
|
|
16
|
+
type SearchConversion,
|
|
15
17
|
SelectionWizard,
|
|
16
18
|
type StepId,
|
|
19
|
+
searchConversionNotice,
|
|
17
20
|
senderLabel,
|
|
18
21
|
stepBlockedReason,
|
|
19
22
|
stepIndex,
|
|
@@ -34,16 +37,19 @@ import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
|
34
37
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
35
38
|
import {
|
|
36
39
|
type EscalatedAction,
|
|
40
|
+
type EscalationSearchQuery,
|
|
37
41
|
useEscalatedActions,
|
|
38
42
|
} from "@/hooks/useEscalatedActions";
|
|
39
43
|
import { useCreateFilter } from "@/hooks/useFilters";
|
|
40
|
-
import { useMatchSample } from "@/hooks/useMatchSample";
|
|
44
|
+
import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
|
|
41
45
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
42
46
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
43
47
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
44
48
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
45
|
-
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
49
|
+
import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
46
50
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
51
|
+
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
52
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
47
53
|
import { buildMoveTargets } from "@/lib/move-targets";
|
|
48
54
|
import {
|
|
49
55
|
buildWizardDraft,
|
|
@@ -57,9 +63,8 @@ import {
|
|
|
57
63
|
SUPPORTED_CLAUSE_FIELDS,
|
|
58
64
|
} from "@/lib/organize/rule-model";
|
|
59
65
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
60
|
-
import { useWizardStep } from "@/lib/wizard-history";
|
|
66
|
+
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
61
67
|
|
|
62
|
-
const OPENING_STEP: StepId = "match";
|
|
63
68
|
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
|
|
64
69
|
|
|
65
70
|
/** A ticked row, as the wizard's samples and its clause prefill read it. */
|
|
@@ -74,6 +79,36 @@ interface SelectionWizardSessionProps extends SelectionWizardHostProps {
|
|
|
74
79
|
goToStep: (step: StepId) => void;
|
|
75
80
|
goBack: () => void;
|
|
76
81
|
closeWizard: (steps: readonly StepId[], step: StepId) => void;
|
|
82
|
+
/**
|
|
83
|
+
* The wizard was entered by converting a search rather than by ticking rows
|
|
84
|
+
* (#477 1.8). The conversion seeds the clauses on the properties step and the
|
|
85
|
+
* notice above them, and nothing else: every step after that is the one a
|
|
86
|
+
* selection from the inbox walks (#477 3.4). Absent for the selection bar's
|
|
87
|
+
* entry.
|
|
88
|
+
*/
|
|
89
|
+
searchConversion?: SearchConversion;
|
|
90
|
+
}
|
|
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>;
|
|
77
112
|
}
|
|
78
113
|
|
|
79
114
|
export interface SelectionWizardHostProps {
|
|
@@ -86,10 +121,36 @@ export interface SelectionWizardHostProps {
|
|
|
86
121
|
selection: readonly WizardSelectionMessage[];
|
|
87
122
|
/** The ticked rows span more than one account, so no rule can be created. */
|
|
88
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;
|
|
89
134
|
/** The wizard is done with the selection, and the list drops it. */
|
|
90
135
|
onFinished: () => void;
|
|
91
136
|
}
|
|
92
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A search entry with no query behind it — a link typed by hand, or one whose
|
|
140
|
+
* query is gone. It opens the same properties step with nothing seeded, which
|
|
141
|
+
* the step already states, rather than a wizard that renders nothing.
|
|
142
|
+
*/
|
|
143
|
+
const NO_CONVERSION: SearchConversion = {
|
|
144
|
+
clauses: [],
|
|
145
|
+
matchOperator: "all",
|
|
146
|
+
droppedFacets: [],
|
|
147
|
+
keptTerms: false,
|
|
148
|
+
droppedSemantic: false,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
|
|
152
|
+
const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
|
|
153
|
+
|
|
93
154
|
/** How far a commit has got, whichever of the three ways it took. */
|
|
94
155
|
interface RunSnapshot {
|
|
95
156
|
state: RunState;
|
|
@@ -141,6 +202,26 @@ const bulkActionFor = (
|
|
|
141
202
|
const widenedRunsAsJob = (verb: Verb): boolean =>
|
|
142
203
|
verb === "move" || verb === "junk" || verb === "organize";
|
|
143
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
|
+
|
|
144
225
|
/**
|
|
145
226
|
* Where the wizard meets the app (#483). The steps and their bodies belong to
|
|
146
227
|
* `@remit/ui`; everything that talks to a server is here: the widen, the live
|
|
@@ -163,14 +244,41 @@ function SelectionWizardSession({
|
|
|
163
244
|
mailboxId,
|
|
164
245
|
selection,
|
|
165
246
|
crossAccount = false,
|
|
247
|
+
escalated: escalatedSelection,
|
|
248
|
+
escalatedProgress,
|
|
249
|
+
searchConversion,
|
|
166
250
|
onFinished,
|
|
167
251
|
step,
|
|
168
252
|
goToStep,
|
|
169
253
|
goBack,
|
|
170
254
|
closeWizard,
|
|
171
255
|
}: SelectionWizardSessionProps) {
|
|
172
|
-
|
|
173
|
-
|
|
256
|
+
// The query as it read when the wizard opened. Held for the walk, so a search
|
|
257
|
+
// still settling underneath cannot rewrite the notice a user is reading while
|
|
258
|
+
// the clauses beside it stay as they were seeded.
|
|
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);
|
|
265
|
+
const fromSearch = conversion !== undefined;
|
|
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>(
|
|
271
|
+
fromSearch ? "properties" : "selected",
|
|
272
|
+
);
|
|
273
|
+
const mode: MatchMode = escalated ? "escalated" : door;
|
|
274
|
+
const [draft, setDraft] = useState<WizardDraft>(() =>
|
|
275
|
+
conversion
|
|
276
|
+
? {
|
|
277
|
+
clauses: withIds(conversion.clauses, "search"),
|
|
278
|
+
matchOperator: conversion.matchOperator,
|
|
279
|
+
}
|
|
280
|
+
: EMPTY_DRAFT,
|
|
281
|
+
);
|
|
174
282
|
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
|
|
175
283
|
undefined,
|
|
176
284
|
);
|
|
@@ -201,7 +309,13 @@ function SelectionWizardSession({
|
|
|
201
309
|
const subjects = useSelectedSubjects(messageIds);
|
|
202
310
|
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
203
311
|
|
|
204
|
-
|
|
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
|
+
);
|
|
205
319
|
const { preview: probeWiden } = widen;
|
|
206
320
|
// The similar door has to know before it is pressed whether it can run, so
|
|
207
321
|
// the probe fires with the wizard rather than with the door (#477 3.6).
|
|
@@ -209,7 +323,10 @@ function SelectionWizardSession({
|
|
|
209
323
|
probeWiden();
|
|
210
324
|
}, [probeWiden]);
|
|
211
325
|
|
|
212
|
-
|
|
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";
|
|
213
330
|
const literalPredicate: OrganizeMatchPredicate = useMemo(
|
|
214
331
|
() => ({
|
|
215
332
|
matchOperator: draft.matchOperator === "all" ? "And" : "Or",
|
|
@@ -226,14 +343,21 @@ function SelectionWizardSession({
|
|
|
226
343
|
// in the browser (#477 5.3). The ticked list is its own count, so no request
|
|
227
344
|
// is made for it.
|
|
228
345
|
const { count: previewCount, matchedIds } = useRulePreview(
|
|
229
|
-
|
|
346
|
+
previewed ? accountId : undefined,
|
|
230
347
|
predicate,
|
|
231
348
|
);
|
|
232
|
-
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
|
+
);
|
|
233
355
|
|
|
234
|
-
const count: MatchCount =
|
|
235
|
-
?
|
|
236
|
-
:
|
|
356
|
+
const count: MatchCount = escalated
|
|
357
|
+
? { status: "ready", count: escalated.total }
|
|
358
|
+
: previewed
|
|
359
|
+
? previewCount
|
|
360
|
+
: { status: "ready", count: selection.length };
|
|
237
361
|
|
|
238
362
|
const { data: mailboxesData } = useQuery({
|
|
239
363
|
...mailboxOperationsListMailboxesOptions({
|
|
@@ -299,7 +423,7 @@ function SelectionWizardSession({
|
|
|
299
423
|
});
|
|
300
424
|
const { runAction } = bulk;
|
|
301
425
|
|
|
302
|
-
const steps = stepsFor({ verb, mode, scope: draft.scope });
|
|
426
|
+
const steps = stepsFor({ verb, mode, scope: draft.scope, fromSearch });
|
|
303
427
|
// The step the screens are on, which is the held one only while the answers
|
|
304
428
|
// still hold it. Reading the URL's step here and the resolved one on screen
|
|
305
429
|
// is how a footer comes to name a screen nobody is looking at.
|
|
@@ -329,8 +453,8 @@ function SelectionWizardSession({
|
|
|
329
453
|
);
|
|
330
454
|
|
|
331
455
|
const changeMode = useCallback(
|
|
332
|
-
(next:
|
|
333
|
-
|
|
456
|
+
(next: MatchDoor) => {
|
|
457
|
+
setDoor(next);
|
|
334
458
|
setDraft((held) => ({
|
|
335
459
|
...held,
|
|
336
460
|
widen:
|
|
@@ -350,7 +474,7 @@ function SelectionWizardSession({
|
|
|
350
474
|
// semantic match without saying so (#477 3.6).
|
|
351
475
|
const takeSemanticFallback = useCallback(() => {
|
|
352
476
|
setSemanticFallbackTaken(true);
|
|
353
|
-
|
|
477
|
+
setDoor("properties");
|
|
354
478
|
setDraft((held) => ({
|
|
355
479
|
...held,
|
|
356
480
|
widen: undefined,
|
|
@@ -444,16 +568,7 @@ function SelectionWizardSession({
|
|
|
444
568
|
if (!action) {
|
|
445
569
|
setBulkRun({
|
|
446
570
|
matched: ids.length,
|
|
447
|
-
outcome:
|
|
448
|
-
done: 0,
|
|
449
|
-
failedIds: [...ids],
|
|
450
|
-
cancelled: false,
|
|
451
|
-
error: new Error(
|
|
452
|
-
verb === "junk"
|
|
453
|
-
? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
|
|
454
|
-
: "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
|
|
455
|
-
),
|
|
456
|
-
},
|
|
571
|
+
outcome: noDestinationOutcome(verb, ids),
|
|
457
572
|
});
|
|
458
573
|
return;
|
|
459
574
|
}
|
|
@@ -464,13 +579,38 @@ function SelectionWizardSession({
|
|
|
464
579
|
[verb, named.moveMailboxId, junkMailboxId, runAction],
|
|
465
580
|
);
|
|
466
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
|
+
|
|
467
600
|
const { start: startJob } = organizeJob;
|
|
468
601
|
const { createFilterAsync } = createFilter;
|
|
469
602
|
|
|
470
603
|
const sendCommit = useCallback(() => {
|
|
471
|
-
|
|
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 });
|
|
472
612
|
const organizeDraft = buildWizardDraft({
|
|
473
|
-
mode,
|
|
613
|
+
mode: door,
|
|
474
614
|
ruleScope: named.scope,
|
|
475
615
|
anchorMessageId,
|
|
476
616
|
clauses: named.clauses,
|
|
@@ -505,7 +645,9 @@ function SelectionWizardSession({
|
|
|
505
645
|
}
|
|
506
646
|
void runBulk(scope === "just-these" ? messageIds : matchedIds);
|
|
507
647
|
}, [
|
|
508
|
-
|
|
648
|
+
escalated,
|
|
649
|
+
runEscalated,
|
|
650
|
+
door,
|
|
509
651
|
named,
|
|
510
652
|
anchorMessageId,
|
|
511
653
|
verb,
|
|
@@ -554,43 +696,75 @@ function SelectionWizardSession({
|
|
|
554
696
|
return NOT_STARTED;
|
|
555
697
|
}, [organizeJob]);
|
|
556
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.
|
|
557
701
|
const rowsById = useMemo(() => {
|
|
558
702
|
const rows = new Map<string, WizardMessage>();
|
|
559
|
-
for (const message of [
|
|
703
|
+
for (const message of [
|
|
704
|
+
...selection,
|
|
705
|
+
...matchSample.messages,
|
|
706
|
+
...escalatedSample.messages,
|
|
707
|
+
]) {
|
|
560
708
|
rows.set(message.id, message);
|
|
561
709
|
}
|
|
562
710
|
return rows;
|
|
563
|
-
}, [selection, matchSample.messages]);
|
|
711
|
+
}, [selection, matchSample.messages, escalatedSample.messages]);
|
|
712
|
+
|
|
713
|
+
const runProgress = escalated ? escalatedProgress : bulk.progress;
|
|
564
714
|
|
|
565
715
|
const bulkSnapshot = useCallback((): RunSnapshot => {
|
|
566
716
|
if (!bulkRun) return NOT_STARTED;
|
|
567
717
|
const { matched, outcome } = bulkRun;
|
|
568
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.
|
|
569
723
|
return {
|
|
570
724
|
state: "backApplyRunning",
|
|
571
|
-
matched,
|
|
572
|
-
applied
|
|
725
|
+
matched: Math.max(matched, applied),
|
|
726
|
+
applied,
|
|
727
|
+
failed: 0,
|
|
728
|
+
failures: [],
|
|
729
|
+
};
|
|
730
|
+
}
|
|
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,
|
|
573
737
|
failed: 0,
|
|
574
738
|
failures: [],
|
|
575
739
|
};
|
|
576
740
|
}
|
|
577
|
-
if (outcome.done === 0
|
|
741
|
+
if (outcome.done === 0) {
|
|
578
742
|
return { ...NOT_STARTED, state: "commitFailed" };
|
|
579
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.
|
|
580
750
|
const failures = outcome.failedIds
|
|
581
751
|
.map((id) => rowsById.get(id))
|
|
582
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);
|
|
583
757
|
return {
|
|
584
|
-
state:
|
|
585
|
-
|
|
586
|
-
matched,
|
|
758
|
+
state: "runStopped",
|
|
759
|
+
matched: Math.max(matched, outcome.done),
|
|
587
760
|
applied: outcome.done,
|
|
588
|
-
failed:
|
|
761
|
+
failed: unreached,
|
|
589
762
|
failures,
|
|
590
763
|
};
|
|
591
|
-
}, [bulkRun,
|
|
764
|
+
}, [bulkRun, runProgress, rowsById]);
|
|
592
765
|
|
|
593
766
|
const runSnapshot = (): RunSnapshot => {
|
|
767
|
+
if (escalated) return bulkSnapshot();
|
|
594
768
|
if (committedScope === "standing" || committedScope === "temporary") {
|
|
595
769
|
if (createFilter.isError)
|
|
596
770
|
return { ...NOT_STARTED, state: "commitFailed" };
|
|
@@ -611,6 +785,12 @@ function SelectionWizardSession({
|
|
|
611
785
|
// walking back to Review, which would push an entry the wizard does not own
|
|
612
786
|
// and leave Cancel rewinding to a step instead of out.
|
|
613
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
|
+
}
|
|
614
794
|
if (committedScope === "standing" || committedScope === "temporary") {
|
|
615
795
|
if (createFilter.isError) {
|
|
616
796
|
createFilter.reset();
|
|
@@ -662,15 +842,22 @@ function SelectionWizardSession({
|
|
|
662
842
|
});
|
|
663
843
|
|
|
664
844
|
const run = runSnapshot();
|
|
845
|
+
const sampleMessages = escalated
|
|
846
|
+
? escalatedSample.messages
|
|
847
|
+
: previewed
|
|
848
|
+
? matchSample.messages
|
|
849
|
+
: selection;
|
|
665
850
|
const sample = {
|
|
666
|
-
messages:
|
|
851
|
+
messages: sampleMessages,
|
|
667
852
|
count,
|
|
668
|
-
label:
|
|
853
|
+
label: mode === "selected" ? "Your selection" : "A sample of what matches",
|
|
669
854
|
emptyReason:
|
|
670
855
|
mode === "similar" && semanticUnavailable
|
|
671
856
|
? ("notIndexed" as const)
|
|
672
857
|
: ("noMatch" as const),
|
|
673
|
-
loading:
|
|
858
|
+
loading: escalated
|
|
859
|
+
? escalatedSample.isPending
|
|
860
|
+
: previewed && (count.status === "loading" || matchSample.isPending),
|
|
674
861
|
};
|
|
675
862
|
|
|
676
863
|
return (
|
|
@@ -693,6 +880,7 @@ function SelectionWizardSession({
|
|
|
693
880
|
widen.error instanceof Error ? widen.error.message : undefined,
|
|
694
881
|
semanticFallbackTaken,
|
|
695
882
|
onSemanticFallback: takeSemanticFallback,
|
|
883
|
+
escalatedScope: escalated?.scope,
|
|
696
884
|
sample,
|
|
697
885
|
}}
|
|
698
886
|
properties={{
|
|
@@ -709,6 +897,9 @@ function SelectionWizardSession({
|
|
|
709
897
|
onCancelClause: () => setClauseEdit(undefined),
|
|
710
898
|
clauseFields: SUPPORTED_CLAUSE_FIELDS,
|
|
711
899
|
clauseSuggestions,
|
|
900
|
+
conversionNotice: conversion
|
|
901
|
+
? searchConversionNotice(conversion)
|
|
902
|
+
: undefined,
|
|
712
903
|
semanticFallbackTaken,
|
|
713
904
|
sample: { ...sample, label: "What this matches" },
|
|
714
905
|
}}
|
|
@@ -741,6 +932,7 @@ function SelectionWizardSession({
|
|
|
741
932
|
scope: named.scope,
|
|
742
933
|
until: named.until,
|
|
743
934
|
ruleName: steps.includes("name") ? named.name : undefined,
|
|
935
|
+
escalatedScope: escalated?.scope,
|
|
744
936
|
sample,
|
|
745
937
|
}}
|
|
746
938
|
run={{
|
|
@@ -759,17 +951,42 @@ function SelectionWizardSession({
|
|
|
759
951
|
}
|
|
760
952
|
|
|
761
953
|
/**
|
|
762
|
-
* The wizard's mount point,
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
* collects is gone by the time the next one
|
|
954
|
+
* The wizard's mount point, and the only one: both entries walk this host, so
|
|
955
|
+
* there is one owner of the step in the URL and of the history entries the
|
|
956
|
+
* wizard pushes. The walk itself is a separate component, mounted only while a
|
|
957
|
+
* step is held, so every answer it collects is gone by the time the next one
|
|
958
|
+
* starts.
|
|
959
|
+
*
|
|
960
|
+
* The URL says which affordance opened the wizard, and that decides two things
|
|
961
|
+
* and no more: which step it opens on, and whether the query seeds the clauses.
|
|
962
|
+
* A search entry ticks nothing and its rule belongs to the account the query
|
|
963
|
+
* names, so the selection the bar would have handed over is not the one it
|
|
964
|
+
* walks.
|
|
766
965
|
*/
|
|
767
966
|
export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
768
|
-
const
|
|
967
|
+
const fromSearch = useWizardEntryValue() === "search";
|
|
968
|
+
const { searchConversion } = useListHeaderChrome();
|
|
969
|
+
const { accounts } = useMailContext();
|
|
970
|
+
const { step, goToStep, goBack, closeWizard } = useWizardStep(
|
|
971
|
+
fromSearch ? "properties" : "match",
|
|
972
|
+
);
|
|
769
973
|
if (!step) return null;
|
|
974
|
+
const conversion = fromSearch
|
|
975
|
+
? (searchConversion ?? NO_CONVERSION)
|
|
976
|
+
: undefined;
|
|
770
977
|
return (
|
|
771
978
|
<SelectionWizardSession
|
|
772
979
|
{...props}
|
|
980
|
+
{...(conversion
|
|
981
|
+
? {
|
|
982
|
+
verb: "organize" as const,
|
|
983
|
+
accountId: conversion.targetAccountId ?? accounts[0]?.accountId,
|
|
984
|
+
selection: EMPTY_SELECTION,
|
|
985
|
+
crossAccount: false,
|
|
986
|
+
escalated: undefined,
|
|
987
|
+
searchConversion: conversion,
|
|
988
|
+
}
|
|
989
|
+
: {})}
|
|
773
990
|
step={step}
|
|
774
991
|
goToStep={goToStep}
|
|
775
992
|
goBack={goBack}
|
|
@@ -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),
|