@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/BriefPane.selection.test.ts +0 -1
  3. package/src/components/mail/BriefPane.tsx +7 -17
  4. package/src/components/mail/DailyBrief.selection.test.ts +21 -3
  5. package/src/components/mail/DailyBrief.tsx +37 -39
  6. package/src/components/mail/FlaggedList.tsx +55 -8
  7. package/src/components/mail/FlaggedPane.tsx +9 -23
  8. package/src/components/mail/MailListHeader.tsx +41 -39
  9. package/src/components/mail/MailboxPane.tsx +24 -11
  10. package/src/components/mail/MessageList.selection.test.ts +44 -30
  11. package/src/components/mail/MessageList.tsx +117 -339
  12. package/src/components/mail/SelectionWizardHost.tsx +265 -48
  13. package/src/components/mail/ThreadListInteraction.test.ts +74 -4
  14. package/src/components/mail/ThreadListInteraction.tsx +58 -50
  15. package/src/components/mail/make-filter-entry.test.ts +63 -0
  16. package/src/components/mail/selection-verbs.test.ts +278 -0
  17. package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
  18. package/src/hooks/useEscalatedActions.ts +2 -2
  19. package/src/hooks/useMatchSample.ts +44 -3
  20. package/src/hooks/useRulePreview.ts +1 -2
  21. package/src/lib/bulk-action-copy.test.ts +0 -19
  22. package/src/lib/bulk-action-copy.ts +0 -8
  23. package/src/lib/bulk-actions.test.ts +0 -60
  24. package/src/lib/bulk-actions.ts +0 -26
  25. package/src/lib/format.test.ts +0 -27
  26. package/src/lib/format.ts +4 -14
  27. package/src/lib/list-header-chrome.ts +10 -0
  28. package/src/lib/organize/organize-model.ts +8 -2
  29. package/src/lib/organize/rule-model.ts +0 -4
  30. package/src/lib/wizard-history.test.ts +31 -0
  31. package/src/lib/wizard-history.ts +97 -9
  32. package/src/routes/mail.tsx +3 -1
  33. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  34. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  35. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  36. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -140
  37. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  38. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  39. package/src/hooks/useRuleEditorState.ts +0 -182
  40. package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
  41. 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
- const [mode, setMode] = useState<MatchMode>("selected");
173
- const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
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
- const widen = useOrganizeWiden(accountId, anchorMessageId, senders);
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
- const widened = mode !== "selected";
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
- widened ? accountId : undefined,
346
+ previewed ? accountId : undefined,
230
347
  predicate,
231
348
  );
232
- const matchSample = useMatchSample(widened ? matchedIds : []);
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 = widened
235
- ? previewCount
236
- : { status: "ready", count: selection.length };
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: MatchMode) => {
333
- setMode(next);
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
- setMode("properties");
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
- const scope = organizeScopeFor({ mode, ruleScope: named.scope });
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
- mode,
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 [...selection, ...matchSample.messages]) {
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: bulk.progress?.done ?? 0,
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 && (outcome.error || outcome.failedIds.length > 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
- outcome.failedIds.length > 0 ? "backApplyFailed" : "backApplyComplete",
586
- matched,
758
+ state: "runStopped",
759
+ matched: Math.max(matched, outcome.done),
587
760
  applied: outcome.done,
588
- failed: outcome.failedIds.length,
761
+ failed: unreached,
589
762
  failures,
590
763
  };
591
- }, [bulkRun, bulk.progress, rowsById]);
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: widened ? matchSample.messages : selection,
851
+ messages: sampleMessages,
667
852
  count,
668
- label: widened ? "A sample of what matches" : "Your selection",
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: widened && (count.status === "loading" || matchSample.isPending),
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, beside the list that opens it. It owns the step in
763
- * the URL and the history entries the wizard walks; the walk itself is a
764
- * separate component, mounted only while a step is held, so every answer it
765
- * collects is gone by the time the next one starts.
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 { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
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().requestDelete(), true);
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().requestDelete();
251
+ list.commands().requestVerb("delete");
183
252
  });
184
253
  act(() => {
185
254
  assert.equal(
186
- list.commands().requestDelete(),
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().requestDelete(), false);
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),