@remit/web-client 0.0.93 → 0.0.94

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.93",
3
+ "version": "0.0.94",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -415,20 +415,28 @@ function BriefSelectionChrome({
415
415
  />
416
416
  );
417
417
 
418
+ // Mounted as the pane's overlay rather than beside the header: the brief hands
419
+ // its body over to the search results panel while a query is being typed, and
420
+ // the wizard is not part of the body it hands over. The overlay slot also puts
421
+ // it inside the list header's chrome, which is where the converted query it
422
+ // opens on comes from.
418
423
  return (
419
- <>
420
- <MailListHeader {...header} selectionBar={selectionBar}>
421
- {children}
422
- </MailListHeader>
423
- <SelectionWizardHost
424
- verb={wizardVerb}
425
- accountId={scope.accountId}
426
- mailboxId={scope.mailboxId}
427
- selection={wizardSelection}
428
- crossAccount={scope.moveDisabledHint !== undefined}
429
- onFinished={exitSelection}
430
- />
431
- </>
424
+ <MailListHeader
425
+ {...header}
426
+ selectionBar={selectionBar}
427
+ paneOverlay={
428
+ <SelectionWizardHost
429
+ verb={wizardVerb}
430
+ accountId={scope.accountId}
431
+ mailboxId={scope.mailboxId}
432
+ selection={wizardSelection}
433
+ crossAccount={scope.moveDisabledHint !== undefined}
434
+ onFinished={exitSelection}
435
+ />
436
+ }
437
+ >
438
+ {children}
439
+ </MailListHeader>
432
440
  );
433
441
  }
434
442
 
@@ -38,10 +38,15 @@ import { dedupeByThread } from "@/lib/starred-rows";
38
38
  import { MailViewChrome } from "./MailViewChrome";
39
39
  import type { MessageListCommands } from "./MessageList";
40
40
  import { MessageRow } from "./MessageRow";
41
+ import {
42
+ SelectionWizardHost,
43
+ type WizardSelectionMessage,
44
+ } from "./SelectionWizardHost";
41
45
  import {
42
46
  type OpenMessageOptions,
43
47
  ThreadListInteraction,
44
48
  ThreadListSelectionBar,
49
+ useThreadListSelection,
45
50
  } from "./ThreadListInteraction";
46
51
 
47
52
  const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
@@ -49,6 +54,42 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
49
54
  attachment: (t) => t.hasAttachment === true,
50
55
  };
51
56
 
57
+ /**
58
+ * The wizard, on the one surface whose bar borrows it rather than raising it.
59
+ *
60
+ * The list header offers "make this a filter" wherever a search is active, and
61
+ * the step that affordance pushes has to be answered on the view that offered
62
+ * it — an affordance whose press lands on nothing is the dead button clause 1.7
63
+ * exists to prevent. Starred spans accounts and mailboxes, so a rule made from
64
+ * the ticked rows has no single account to belong to; a rule made from the
65
+ * query belongs to the account the query names, which the host reads for
66
+ * itself.
67
+ */
68
+ function StarredWizardHost({ rows }: { rows: readonly ThreadRowData[] }) {
69
+ const { selectedIds, exitSelection } = useThreadListSelection();
70
+ const selection = useMemo<WizardSelectionMessage[]>(
71
+ () =>
72
+ rows
73
+ .filter((row) => selectedIds.has(row.id))
74
+ .map((row) => ({
75
+ id: row.id,
76
+ sender: row.fromName,
77
+ email: row.fromEmail,
78
+ subject: row.subject,
79
+ date: row.timeLabel,
80
+ })),
81
+ [rows, selectedIds],
82
+ );
83
+ return (
84
+ <SelectionWizardHost
85
+ verb="organize"
86
+ selection={selection}
87
+ crossAccount
88
+ onFinished={exitSelection}
89
+ />
90
+ );
91
+ }
92
+
52
93
  interface FlaggedListProps {
53
94
  selectedMessageId?: string;
54
95
  onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
@@ -221,6 +262,7 @@ export function FlaggedList({
221
262
  (listState === "ready" ? listBody : undefined)
222
263
  }
223
264
  />
265
+ <StarredWizardHost rows={rows} />
224
266
  </ThreadListInteraction>
225
267
  </MailViewChrome>
226
268
  );
@@ -86,7 +86,7 @@ import {
86
86
  searchTokenLabel,
87
87
  } from "@/lib/search-tokens";
88
88
  import { spamOfferForResults } from "@/lib/spam-offer";
89
- import { SearchFilterDialog } from "./organize/SearchFilterDialog";
89
+ import { useOpenWizard } from "@/lib/wizard-history";
90
90
 
91
91
  export interface MailListHeaderProps {
92
92
  title: string;
@@ -102,8 +102,11 @@ export interface MailListHeaderProps {
102
102
  */
103
103
  selectionBar?: (chrome: ListHeaderChrome) => ReactNode;
104
104
  /**
105
- * An overlay covering the pane, above the list (the guided organize flow).
106
- * The pane is the positioned ancestor it measures against.
105
+ * An overlay covering the pane, above the list (the selection wizard). Kept
106
+ * out of the body and rendered on both the ordinary layout and the phone
107
+ * search takeover, so a surface that covers the screen is not taken down by
108
+ * whatever the body swapped to underneath it. Inside the chrome provider, so
109
+ * an overlay a view mounts from above still reads the search state.
107
110
  */
108
111
  paneOverlay?: ReactNode;
109
112
  /** Pinned below the scrollable list (e.g. the keyboard hint bar). */
@@ -169,7 +172,7 @@ export function MailListHeader({
169
172
  const tier = useLayoutTier();
170
173
  const [searchOpen, setSearchOpen] = useState(false);
171
174
  const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
172
- const [filterOpen, setFilterOpen] = useState(false);
175
+ const openWizard = useOpenWizard();
173
176
 
174
177
  // Leaving the view ends the search: the shell drops the query, and the chrome
175
178
  // it opened — the phone takeover, the expanded tablet field — closes with it
@@ -320,14 +323,15 @@ export function MailListHeader({
320
323
  }
321
324
  : routeScope;
322
325
 
323
- // Make-this-a-filter (RFC 038 D5): convert the current search to a pre-filled
324
- // rule and open the shared chip editor. The filter is created for the account
325
- // an `account:` facet names, else the primary account. The literal filter
326
- // cannot reproduce the search's semantic reach, so the conversion states it
327
- // whenever the search surfaced a "Related" section a direct signal, read
328
- // here from the semantic results, never a capability probe. Disabled with a
329
- // reason when the search has no clause to filter on (only non-clause facets,
330
- // or a bare folder scope).
326
+ // Make-this-a-filter (#477 clause 1.8): the search is the wizard's second
327
+ // entry. It opens on the properties step with the clauses `convertSearchToRule`
328
+ // derives from the query, nothing ticked, and the notice for what the query
329
+ // could not be turned into. The conversion is computed once, here, and handed
330
+ // to the wizard through the chromethe reason the affordance gives and the
331
+ // clauses the wizard seeds from are the same answer. The literal filter cannot
332
+ // reproduce the search's semantic reach, so the conversion states it whenever
333
+ // the search surfaced a "Related" section — a direct signal, read here from
334
+ // the semantic results, never a capability probe.
331
335
  //
332
336
  // It belongs to the search, not to any one way of showing it. The affordance
333
337
  // therefore sits in the pane, above whichever body is up — the read-only
@@ -338,35 +342,32 @@ export function MailListHeader({
338
342
  // the URL, taking the affordance down with it a few hundred milliseconds after
339
343
  // it appeared, and leaving the brief (which keeps the panel for any query) as
340
344
  // the only place it survived.
341
- const accountToken = parsed.tokens.find((token) => token.type === "account");
342
- const targetAccountId = accountToken?.accountId ?? accounts[0]?.accountId;
343
345
  const searchHadSemanticReach = related.length > 0;
344
- const makeFilter =
345
- hasQuery && targetAccountId
346
- ? {
347
- onClick: () => setFilterOpen(true),
348
- disabledReason: isConvertible(
349
- convertSearchToRule(parsed, { searchHadSemanticReach }),
350
- )
351
- ? undefined
352
- : "Add a sender or words to filter on",
353
- }
354
- : undefined;
346
+ const conversion = useMemo(
347
+ () => convertSearchToRule(parsed, { searchHadSemanticReach }),
348
+ [parsed, searchHadSemanticReach],
349
+ );
350
+ const searchConversion = hasQuery ? conversion : undefined;
351
+ const makeFilter = hasQuery
352
+ ? {
353
+ // The wizard is a full-screen surface, so the phone takeover it was
354
+ // pressed from stands down rather than sitting under it — and the list
355
+ // underneath, which hosts the wizard, is mounted again by the time the
356
+ // step lands.
357
+ onClick: () => {
358
+ setSearchOpen(false);
359
+ openWizard("properties", "search");
360
+ },
361
+ blockedReason: isConvertible(conversion)
362
+ ? undefined
363
+ : "Add a sender or words to filter on",
364
+ }
365
+ : undefined;
355
366
  // Handed to the bar rather than rendered here: the bar knows whether rows
356
367
  // are ticked, and a selection's own verbs own the surface while they are up.
357
368
  const makeFilterAction = makeFilter ? (
358
369
  <MakeFilterAction {...makeFilter} />
359
370
  ) : null;
360
- const filterDialog =
361
- filterOpen && targetAccountId ? (
362
- <SearchFilterDialog
363
- open={filterOpen}
364
- accountId={targetAccountId}
365
- parsed={parsed}
366
- searchHadSemanticReach={searchHadSemanticReach}
367
- onClose={() => setFilterOpen(false)}
368
- />
369
- ) : null;
370
371
 
371
372
  // Tablet + desktop keep the inline toolbar search; while a query is being
372
373
  // typed the list-pane body swaps to the same sectioned results the phone
@@ -421,6 +422,7 @@ export function MailListHeader({
421
422
  title,
422
423
  searchResults: chromeResults,
423
424
  makeFilterSlot: makeFilterAction,
425
+ searchConversion,
424
426
  navSlot: layout && !layout.showNavPane && (
425
427
  <Button
426
428
  variant="ghost"
@@ -484,6 +486,7 @@ export function MailListHeader({
484
486
  searchSuggest,
485
487
  chromeResults,
486
488
  makeFilterAction,
489
+ searchConversion,
487
490
  ],
488
491
  );
489
492
 
@@ -494,7 +497,7 @@ export function MailListHeader({
494
497
  onSelectSearchResult?.(result);
495
498
  };
496
499
  return (
497
- <>
500
+ <ListHeaderChromeContext.Provider value={chrome}>
498
501
  <MobileSearchView
499
502
  value={searchInput}
500
503
  onChange={onSearchChange}
@@ -515,8 +518,8 @@ export function MailListHeader({
515
518
  suggest={searchSuggest}
516
519
  suggestList={suggestList}
517
520
  />
518
- {filterDialog}
519
- </>
521
+ {paneOverlay}
522
+ </ListHeaderChromeContext.Provider>
520
523
  );
521
524
  }
522
525
 
@@ -527,7 +530,6 @@ export function MailListHeader({
527
530
  {suggestList}
528
531
  <div className="min-h-0 flex-1">{body}</div>
529
532
  {footer}
530
- {filterDialog}
531
533
  {paneOverlay}
532
534
  </section>
533
535
  </ListHeaderChromeContext.Provider>
@@ -12,8 +12,10 @@ import {
12
12
  type MoveMailboxOption,
13
13
  type RuleClause,
14
14
  type RunState,
15
+ type SearchConversion,
15
16
  SelectionWizard,
16
17
  type StepId,
18
+ searchConversionNotice,
17
19
  senderLabel,
18
20
  stepBlockedReason,
19
21
  stepIndex,
@@ -44,6 +46,8 @@ import { useRulePreview } from "@/hooks/useRulePreview";
44
46
  import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
45
47
  import type { BulkRunOutcome } from "@/lib/bulk-actions";
46
48
  import { getMailboxDisplayName } from "@/lib/folder-roles";
49
+ import { useListHeaderChrome } from "@/lib/list-header-chrome";
50
+ import { useMailContext } from "@/lib/mail-context";
47
51
  import { buildMoveTargets } from "@/lib/move-targets";
48
52
  import {
49
53
  buildWizardDraft,
@@ -57,9 +61,8 @@ import {
57
61
  SUPPORTED_CLAUSE_FIELDS,
58
62
  } from "@/lib/organize/rule-model";
59
63
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
60
- import { useWizardStep } from "@/lib/wizard-history";
64
+ import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
61
65
 
62
- const OPENING_STEP: StepId = "match";
63
66
  const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
64
67
 
65
68
  /** A ticked row, as the wizard's samples and its clause prefill read it. */
@@ -74,6 +77,14 @@ interface SelectionWizardSessionProps extends SelectionWizardHostProps {
74
77
  goToStep: (step: StepId) => void;
75
78
  goBack: () => void;
76
79
  closeWizard: (steps: readonly StepId[], step: StepId) => void;
80
+ /**
81
+ * The wizard was entered by converting a search rather than by ticking rows
82
+ * (#477 1.8). The conversion seeds the clauses on the properties step and the
83
+ * notice above them, and nothing else: every step after that is the one a
84
+ * selection from the inbox walks (#477 3.4). Absent for the selection bar's
85
+ * entry.
86
+ */
87
+ searchConversion?: SearchConversion;
77
88
  }
78
89
 
79
90
  export interface SelectionWizardHostProps {
@@ -90,6 +101,22 @@ export interface SelectionWizardHostProps {
90
101
  onFinished: () => void;
91
102
  }
92
103
 
104
+ /**
105
+ * A search entry with no query behind it — a link typed by hand, or one whose
106
+ * query is gone. It opens the same properties step with nothing seeded, which
107
+ * the step already states, rather than a wizard that renders nothing.
108
+ */
109
+ const NO_CONVERSION: SearchConversion = {
110
+ clauses: [],
111
+ matchOperator: "all",
112
+ droppedFacets: [],
113
+ keptTerms: false,
114
+ droppedSemantic: false,
115
+ };
116
+
117
+ /** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
118
+ const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
119
+
93
120
  /** How far a commit has got, whichever of the three ways it took. */
94
121
  interface RunSnapshot {
95
122
  state: RunState;
@@ -163,14 +190,29 @@ function SelectionWizardSession({
163
190
  mailboxId,
164
191
  selection,
165
192
  crossAccount = false,
193
+ searchConversion,
166
194
  onFinished,
167
195
  step,
168
196
  goToStep,
169
197
  goBack,
170
198
  closeWizard,
171
199
  }: SelectionWizardSessionProps) {
172
- const [mode, setMode] = useState<MatchMode>("selected");
173
- const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
200
+ // The query as it read when the wizard opened. Held for the walk, so a search
201
+ // still settling underneath cannot rewrite the notice a user is reading while
202
+ // the clauses beside it stay as they were seeded.
203
+ const [conversion] = useState(() => searchConversion);
204
+ const fromSearch = conversion !== undefined;
205
+ const [mode, setMode] = useState<MatchMode>(
206
+ fromSearch ? "properties" : "selected",
207
+ );
208
+ const [draft, setDraft] = useState<WizardDraft>(() =>
209
+ conversion
210
+ ? {
211
+ clauses: withIds(conversion.clauses, "search"),
212
+ matchOperator: conversion.matchOperator,
213
+ }
214
+ : EMPTY_DRAFT,
215
+ );
174
216
  const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
175
217
  undefined,
176
218
  );
@@ -299,7 +341,7 @@ function SelectionWizardSession({
299
341
  });
300
342
  const { runAction } = bulk;
301
343
 
302
- const steps = stepsFor({ verb, mode, scope: draft.scope });
344
+ const steps = stepsFor({ verb, mode, scope: draft.scope, fromSearch });
303
345
  // The step the screens are on, which is the held one only while the answers
304
346
  // still hold it. Reading the URL's step here and the resolved one on screen
305
347
  // is how a footer comes to name a screen nobody is looking at.
@@ -709,6 +751,9 @@ function SelectionWizardSession({
709
751
  onCancelClause: () => setClauseEdit(undefined),
710
752
  clauseFields: SUPPORTED_CLAUSE_FIELDS,
711
753
  clauseSuggestions,
754
+ conversionNotice: conversion
755
+ ? searchConversionNotice(conversion)
756
+ : undefined,
712
757
  semanticFallbackTaken,
713
758
  sample: { ...sample, label: "What this matches" },
714
759
  }}
@@ -759,17 +804,41 @@ function SelectionWizardSession({
759
804
  }
760
805
 
761
806
  /**
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.
807
+ * The wizard's mount point, and the only one: both entries walk this host, so
808
+ * there is one owner of the step in the URL and of the history entries the
809
+ * wizard pushes. The walk itself is a separate component, mounted only while a
810
+ * step is held, so every answer it collects is gone by the time the next one
811
+ * starts.
812
+ *
813
+ * The URL says which affordance opened the wizard, and that decides two things
814
+ * and no more: which step it opens on, and whether the query seeds the clauses.
815
+ * A search entry ticks nothing and its rule belongs to the account the query
816
+ * names, so the selection the bar would have handed over is not the one it
817
+ * walks.
766
818
  */
767
819
  export function SelectionWizardHost(props: SelectionWizardHostProps) {
768
- const { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
820
+ const fromSearch = useWizardEntryValue() === "search";
821
+ const { searchConversion } = useListHeaderChrome();
822
+ const { accounts } = useMailContext();
823
+ const { step, goToStep, goBack, closeWizard } = useWizardStep(
824
+ fromSearch ? "properties" : "match",
825
+ );
769
826
  if (!step) return null;
827
+ const conversion = fromSearch
828
+ ? (searchConversion ?? NO_CONVERSION)
829
+ : undefined;
770
830
  return (
771
831
  <SelectionWizardSession
772
832
  {...props}
833
+ {...(conversion
834
+ ? {
835
+ verb: "organize" as const,
836
+ accountId: conversion.targetAccountId ?? accounts[0]?.accountId,
837
+ selection: EMPTY_SELECTION,
838
+ crossAccount: false,
839
+ searchConversion: conversion,
840
+ }
841
+ : {})}
773
842
  step={step}
774
843
  goToStep={goToStep}
775
844
  goBack={goBack}
@@ -0,0 +1,63 @@
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
+ * "Make this a filter" is the wizard's second entry (#477 clause 1.8), and the
9
+ * step it pushes has to be answered on the view that offered it.
10
+ *
11
+ * The affordance is built once by `MailListHeader` and handed down as
12
+ * `makeFilterSlot`, so any surface can put it on screen — but the wizard is
13
+ * mounted per surface, and a surface that renders the row without one leaves
14
+ * the press on a URL nothing answers. That is the dead button clause 1.7 exists
15
+ * to prevent, and it is invisible in review: the row and the host are two files
16
+ * apart, and the affordance keeps working everywhere else.
17
+ *
18
+ * So the pairing is the assertion. A surface that renders the row names where
19
+ * its step is answered, and that file is checked to mount a host. A fourth
20
+ * surface fails here until it does the same.
21
+ */
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+
25
+ /** How the chrome's slot is read — `MailListHeader` writes it, everyone else reads it. */
26
+ const RENDERS_SLOT = /\.makeFilterSlot/;
27
+ const MOUNTS_HOST = /<SelectionWizardHost/;
28
+
29
+ /** Surface that renders the affordance → the file whose composition answers the step. */
30
+ const ANSWERED_BY: Record<string, string> = {
31
+ "DailyBrief.tsx": "DailyBrief.tsx",
32
+ "MessageList.tsx": "MessageList.tsx",
33
+ // The starred list's bar is shared with the brief's, so its host is mounted
34
+ // by the view rather than beside the bar.
35
+ "ThreadListInteraction.tsx": "FlaggedList.tsx",
36
+ };
37
+
38
+ const read = (file: string): string =>
39
+ readFileSync(resolve(here, file), "utf8");
40
+
41
+ const surfacesRenderingTheAffordance = (): string[] =>
42
+ readdirSync(here)
43
+ .filter((file) => file.endsWith(".tsx"))
44
+ .filter((file) => RENDERS_SLOT.test(read(file)));
45
+
46
+ describe("the make-filter entry", () => {
47
+ it("is rendered only by surfaces that say where its step is answered", () => {
48
+ assert.deepEqual(
49
+ surfacesRenderingTheAffordance().sort(),
50
+ Object.keys(ANSWERED_BY).sort(),
51
+ );
52
+ });
53
+
54
+ it("is answered by a mounted wizard on every one of them", () => {
55
+ for (const [surface, host] of Object.entries(ANSWERED_BY)) {
56
+ assert.match(
57
+ read(host),
58
+ MOUNTS_HOST,
59
+ `${surface} is answered by ${host}`,
60
+ );
61
+ }
62
+ });
63
+ });
@@ -1,5 +1,5 @@
1
1
  import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { PreviewCount } from "@remit/ui";
2
+ import { type PreviewCount, UNCOUNTABLE_PREDICATE_REASON } from "@remit/ui";
3
3
  import { useMutation } from "@tanstack/react-query";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { buildOrganizeInput } from "@/lib/organize/organize-model";
@@ -9,7 +9,6 @@ import {
9
9
  PREVIEW_DEBOUNCE_MS,
10
10
  type PreviewState,
11
11
  predicateSignature,
12
- UNCOUNTABLE_PREDICATE_REASON,
13
12
  } from "@/lib/organize/rule-model";
14
13
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
15
14
 
@@ -1,3 +1,4 @@
1
+ import type { SearchConversion } from "@remit/ui";
1
2
  import { createContext, type ReactNode, useContext } from "react";
2
3
 
3
4
  /**
@@ -31,6 +32,15 @@ export interface ListHeaderChrome {
31
32
  searchResults: ReactNode;
32
33
  /** The search's make-this-a-filter row, up only while nothing is ticked. */
33
34
  makeFilterSlot: ReactNode;
35
+ /**
36
+ * The active query as clauses, which is what the wizard's search entry opens
37
+ * on (#484). It travels with the chrome for the same reason the rest does:
38
+ * the query belongs to `MailListHeader` and the wizard is mounted by the body
39
+ * below it, and one conversion computed in one place is what keeps the reason
40
+ * the affordance gives and the clauses the wizard seeds from disagreeing.
41
+ * Absent when no query is up.
42
+ */
43
+ searchConversion?: SearchConversion;
34
44
  }
35
45
 
36
46
  const NO_CHROME: ListHeaderChrome = {
@@ -207,10 +207,6 @@ export const isEvaluablePredicate = (
207
207
  predicate.anchorMessageId !== undefined ||
208
208
  !predicate.literalClauses.some((clause) => clause.field === "HasWords");
209
209
 
210
- /** What the count says when the predicate cannot be counted at all. */
211
- export const UNCOUNTABLE_PREDICATE_REASON =
212
- "Can't count matches — “has the words” reads message bodies, which only a saved rule does.";
213
-
214
210
  /**
215
211
  * A stable key for a predicate's match set. Two predicates with the same key
216
212
  * match the same messages; a change to the key is what marks the live count
@@ -15,6 +15,8 @@ import {
15
15
  } from "@remit/ui";
16
16
  import {
17
17
  ownedHistoryEntries,
18
+ wizardEntryFromParam,
19
+ wizardEntryValue,
18
20
  wizardStepFromParam,
19
21
  wizardStepValue,
20
22
  } from "./wizard-history.js";
@@ -72,6 +74,17 @@ describe("re-rooting a wizard that was loaded into", () => {
72
74
  );
73
75
  });
74
76
 
77
+ // The root is the wizard-closed state, so it carries neither the step nor
78
+ // the affordance that opened it; otherwise closing the wizard leaves the
79
+ // entry marker behind in the address bar (#484).
80
+ it("puts the wizard back on a root that names no entry", () => {
81
+ assert.match(source, /wizard: undefined,\s*\n\s*wizardFrom: undefined,/);
82
+ assert.match(
83
+ source,
84
+ /wizard: openingStep,\s*\n\s*wizardFrom: openingEntry,/,
85
+ );
86
+ });
87
+
75
88
  it("is never armed by a step the app pushed", () => {
76
89
  const goToStep = source.slice(source.indexOf("const goToStep"));
77
90
  assert.doesNotMatch(goToStep, /loadedHoldingStep/);
@@ -107,6 +120,24 @@ describe("the wizard step in the URL", () => {
107
120
  });
108
121
  });
109
122
 
123
+ describe("which affordance opened the wizard", () => {
124
+ it("reads the search entry, and everything else as the selection bar", () => {
125
+ assert.equal(wizardEntryFromParam("search"), "search");
126
+ for (const value of [undefined, "", "Search", "bar", 1, null, {}]) {
127
+ assert.equal(wizardEntryFromParam(value), undefined);
128
+ }
129
+ });
130
+
131
+ it("never fails validation, so a mistyped link still lands on the mail", () => {
132
+ for (const value of ["nope", "SEARCH", 7, [], null, undefined]) {
133
+ const parsed = wizardEntryValue.safeParse(value);
134
+ assert.ok(parsed.success);
135
+ assert.equal(parsed.data, undefined);
136
+ }
137
+ assert.equal(wizardEntryValue.parse("search"), "search");
138
+ });
139
+ });
140
+
110
141
  describe("the history entries the wizard owns", () => {
111
142
  it("is one per step reached, on every shape of the list", () => {
112
143
  for (const answers of answerSets()) {