@remit/web-client 0.0.90 → 0.0.92

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.
@@ -0,0 +1,50 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+
3
+ /**
4
+ * The list header's own chrome, as rendered nodes.
5
+ *
6
+ * The list header and the selection bar are one surface (#480), so the nav
7
+ * button, the unread count and the search affordance belong on the same row as
8
+ * the count and the verbs. That row is rendered by the body — the list owns the
9
+ * selection — while the search state it needs belongs to `MailListHeader`.
10
+ * Handing the built nodes down keeps one definition of the chrome and keeps the
11
+ * bar's two states in the same commit: nothing here is derived from a state
12
+ * update that lands after paint.
13
+ */
14
+ export interface ListHeaderChrome {
15
+ /** The view's name, carried while nothing is ticked. */
16
+ title: string;
17
+ /** Hamburger for the nav slide-over; absent where the nav is a pane. */
18
+ navSlot: ReactNode;
19
+ /** Unread count, beside the title. */
20
+ titleMeta: ReactNode;
21
+ /** Magnifier that opens search, on the tiers whose header owns a field. */
22
+ searchSlot: ReactNode;
23
+ /** The expanded field, which takes the title's place for as long as it is up. */
24
+ searchField: ReactNode;
25
+ /**
26
+ * The read-only two-engine results panel, when a query is being typed and
27
+ * this view answers it with the panel rather than its own rows. The body
28
+ * renders it in place of its list; `MailListHeader` cannot swap the body
29
+ * out from above, because the header is inside it.
30
+ */
31
+ searchResults: ReactNode;
32
+ /** The search's make-this-a-filter row, up only while nothing is ticked. */
33
+ makeFilterSlot: ReactNode;
34
+ }
35
+
36
+ const NO_CHROME: ListHeaderChrome = {
37
+ title: "",
38
+ navSlot: null,
39
+ titleMeta: null,
40
+ searchSlot: null,
41
+ searchField: null,
42
+ searchResults: null,
43
+ makeFilterSlot: null,
44
+ };
45
+
46
+ export const ListHeaderChromeContext =
47
+ createContext<ListHeaderChrome>(NO_CHROME);
48
+
49
+ export const useListHeaderChrome = (): ListHeaderChrome =>
50
+ useContext(ListHeaderChromeContext);
@@ -54,6 +54,13 @@ export interface MailContextValue {
54
54
  onToggleIntelligence: () => void;
55
55
  /** Set the pane open/closed and persist the choice (desktop default-open). */
56
56
  onSetIntelligenceOpen: (open: boolean) => void;
57
+ /**
58
+ * How many rows the list has ticked. The selection is owned by the list, and
59
+ * surfaces mounted beside it — the selection wizard (#477) — need the same
60
+ * number the bar is counting rather than one of their own.
61
+ */
62
+ selectedCount: number;
63
+ onSelectedCountChange: (count: number) => void;
57
64
  }
58
65
 
59
66
  export const MailContext = createContext<MailContextValue | null>(null);
@@ -78,6 +85,8 @@ export const useMailContext = (): MailContextValue => {
78
85
  intelligenceOpen: false,
79
86
  onToggleIntelligence: () => {},
80
87
  onSetIntelligenceOpen: () => {},
88
+ selectedCount: 0,
89
+ onSelectedCountChange: () => {},
81
90
  }
82
91
  );
83
92
  };
@@ -28,17 +28,31 @@ describe("deriveIsMultiSelectMode", () => {
28
28
 
29
29
  describe("shouldExitSelectionOnNavigate", () => {
30
30
  test("back while selecting exits selection instead of navigating", () => {
31
- assert.equal(shouldExitSelectionOnNavigate("BACK", true), true);
31
+ assert.equal(shouldExitSelectionOnNavigate("BACK", true, undefined), true);
32
32
  });
33
33
 
34
34
  test("back with nothing selected is left alone", () => {
35
- assert.equal(shouldExitSelectionOnNavigate("BACK", false), false);
35
+ assert.equal(
36
+ shouldExitSelectionOnNavigate("BACK", false, undefined),
37
+ false,
38
+ );
39
+ });
40
+
41
+ test("back inside the wizard pops a step instead of the selection", () => {
42
+ assert.equal(shouldExitSelectionOnNavigate("BACK", true, "match"), false);
43
+ assert.equal(shouldExitSelectionOnNavigate("BACK", true, "review"), false);
36
44
  });
37
45
 
38
46
  test("forward, push, replace and go are never blocked", () => {
39
- assert.equal(shouldExitSelectionOnNavigate("FORWARD", true), false);
40
- assert.equal(shouldExitSelectionOnNavigate("PUSH", true), false);
41
- assert.equal(shouldExitSelectionOnNavigate("REPLACE", true), false);
42
- assert.equal(shouldExitSelectionOnNavigate("GO", true), false);
47
+ assert.equal(
48
+ shouldExitSelectionOnNavigate("FORWARD", true, undefined),
49
+ false,
50
+ );
51
+ assert.equal(shouldExitSelectionOnNavigate("PUSH", true, undefined), false);
52
+ assert.equal(
53
+ shouldExitSelectionOnNavigate("REPLACE", true, undefined),
54
+ false,
55
+ );
56
+ assert.equal(shouldExitSelectionOnNavigate("GO", true, undefined), false);
43
57
  });
44
58
  });
@@ -3,6 +3,8 @@
3
3
  * and 10). Pure so "one source of truth, one exit" is testable without a DOM.
4
4
  */
5
5
 
6
+ import type { StepId } from "@remit/ui";
7
+
6
8
  /** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
7
9
  export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
8
10
 
@@ -22,8 +24,13 @@ export const deriveIsMultiSelectMode = (
22
24
  * Whether a history navigation should exit selection mode instead of leaving
23
25
  * the route. Only the back gesture is intercepted, so a navigation the app
24
26
  * itself starts (opening a message, switching mailboxes) is never blocked.
27
+ *
28
+ * While the selection wizard is open its own steps own the back gesture, and
29
+ * the selection is what the wizard is acting on — swallowing back there would
30
+ * clear the selection out from under the flow instead of popping a step.
25
31
  */
26
32
  export const shouldExitSelectionOnNavigate = (
27
33
  action: NavigationAction,
28
34
  hasSelection: boolean,
29
- ): boolean => action === "BACK" && hasSelection;
35
+ wizardStep: StepId | undefined,
36
+ ): boolean => action === "BACK" && hasSelection && wizardStep === undefined;
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import { 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
+ import {
7
+ backExits,
8
+ type MatchMode,
9
+ type RuleScope,
10
+ type StepId,
11
+ stepIndex,
12
+ stepsFor,
13
+ type Verb,
14
+ type WizardAnswers,
15
+ } from "@remit/ui";
16
+ import {
17
+ ownedHistoryEntries,
18
+ wizardStepFromParam,
19
+ wizardStepValue,
20
+ } from "./wizard-history.js";
21
+
22
+ const VERBS: readonly Verb[] = [
23
+ "delete",
24
+ "move",
25
+ "junk",
26
+ "markRead",
27
+ "organize",
28
+ ];
29
+ const MODES: readonly MatchMode[] = ["selected", "similar", "properties"];
30
+ const SCOPES: readonly (RuleScope | undefined)[] = [
31
+ undefined,
32
+ "once",
33
+ "standing",
34
+ "until",
35
+ ];
36
+
37
+ const answerSets = (): WizardAnswers[] => {
38
+ const sets: WizardAnswers[] = [];
39
+ for (const verb of VERBS) {
40
+ for (const mode of MODES) {
41
+ for (const scope of SCOPES) {
42
+ for (const fromSearch of [false, true]) {
43
+ sets.push({ verb, mode, scope, fromSearch });
44
+ }
45
+ }
46
+ }
47
+ }
48
+ return sets;
49
+ };
50
+
51
+ const here = dirname(fileURLToPath(import.meta.url));
52
+ const source = readFileSync(resolve(here, "wizard-history.ts"), "utf8");
53
+
54
+ /** The steps up to and including the one an answer is given on. */
55
+ const prefixThrough = (steps: readonly StepId[], step: StepId): StepId[] =>
56
+ steps.slice(0, steps.indexOf(step) + 1);
57
+
58
+ /**
59
+ * The re-root runs inside a router, so the rule is enforced by reading the
60
+ * source — as `../components/mail/MessageList.selection.test.ts` does for its
61
+ * own component-level rules. What it pins is where the decision comes from: a
62
+ * step the app itself pushed must never be re-rooted, or the push that opens
63
+ * the wizard from a verb (#483) duplicates the entry underneath it and the
64
+ * first back after Cancel appears to do nothing.
65
+ */
66
+ describe("re-rooting a wizard that was loaded into", () => {
67
+ it("decides from what the first render held, not from a flag", () => {
68
+ assert.match(source, /useRef\(step !== undefined\)/);
69
+ assert.match(
70
+ source,
71
+ /if \(!loadedHoldingStep\.current\) return;\s*\n\s*loadedHoldingStep\.current = false;/,
72
+ );
73
+ });
74
+
75
+ it("is never armed by a step the app pushed", () => {
76
+ const goToStep = source.slice(source.indexOf("const goToStep"));
77
+ assert.doesNotMatch(goToStep, /loadedHoldingStep/);
78
+ });
79
+
80
+ it("does not re-run when the step changes", () => {
81
+ assert.doesNotMatch(source, /\}, \[step, openingStep, navigate\]\)/);
82
+ });
83
+ });
84
+
85
+ describe("the wizard step in the URL", () => {
86
+ it("round-trips every step the wizard can reach", () => {
87
+ for (const answers of answerSets()) {
88
+ for (const step of stepsFor(answers)) {
89
+ assert.equal(wizardStepFromParam(step), step);
90
+ }
91
+ }
92
+ });
93
+
94
+ it("reads a value the wizard cannot be on as no step", () => {
95
+ for (const value of [undefined, "", "Match", "step-1", 2, null, {}]) {
96
+ assert.equal(wizardStepFromParam(value), undefined);
97
+ }
98
+ });
99
+
100
+ it("never fails validation, so a mistyped link still lands on the mail", () => {
101
+ for (const value of ["nope", "MATCH", "run ", 7, [], null, undefined]) {
102
+ const parsed = wizardStepValue.safeParse(value);
103
+ assert.ok(parsed.success);
104
+ assert.equal(parsed.data, undefined);
105
+ }
106
+ assert.equal(wizardStepValue.parse("review"), "review");
107
+ });
108
+ });
109
+
110
+ describe("the history entries the wizard owns", () => {
111
+ it("is one per step reached, on every shape of the list", () => {
112
+ for (const answers of answerSets()) {
113
+ const steps = stepsFor(answers);
114
+ steps.forEach((step, position) => {
115
+ assert.equal(ownedHistoryEntries(steps, step), position + 1);
116
+ });
117
+ }
118
+ });
119
+
120
+ it("cannot be moved by the match door, which is answered before it", () => {
121
+ for (const verb of VERBS) {
122
+ for (const scope of SCOPES) {
123
+ const prefixes = MODES.map((mode) =>
124
+ prefixThrough(stepsFor({ verb, mode, scope }), "match"),
125
+ );
126
+ for (const prefix of prefixes) {
127
+ assert.deepEqual(prefix, prefixes[0]);
128
+ for (const step of prefix) {
129
+ const counts = MODES.map((mode) =>
130
+ ownedHistoryEntries(stepsFor({ verb, mode, scope }), step),
131
+ );
132
+ assert.equal(new Set(counts).size, 1);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ });
138
+
139
+ it("cannot be moved by the scope, which is answered before it", () => {
140
+ for (const mode of MODES) {
141
+ for (const fromSearch of [false, true]) {
142
+ const listFor = (scope: RuleScope | undefined) =>
143
+ stepsFor({ verb: "organize", mode, scope, fromSearch });
144
+ const prefixes = SCOPES.map((scope) =>
145
+ prefixThrough(listFor(scope), "rule"),
146
+ );
147
+ for (const prefix of prefixes) {
148
+ assert.deepEqual(prefix, prefixes[0]);
149
+ for (const step of prefix) {
150
+ const counts = SCOPES.map((scope) =>
151
+ ownedHistoryEntries(listFor(scope), step),
152
+ );
153
+ assert.equal(new Set(counts).size, 1);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ });
159
+
160
+ it("rewinds the whole flow from the steps Back leaves on", () => {
161
+ for (const answers of answerSets()) {
162
+ const steps = stepsFor(answers);
163
+ assert.ok(backExits(steps, steps[0]));
164
+ assert.equal(ownedHistoryEntries(steps, steps[0]), 1);
165
+ const run = steps[steps.length - 1];
166
+ assert.ok(backExits(steps, run));
167
+ assert.equal(ownedHistoryEntries(steps, run), steps.length);
168
+ }
169
+ });
170
+
171
+ it("holds a step the answers dropped to the opening entry", () => {
172
+ const steps = stepsFor({ verb: "delete", mode: "selected" });
173
+ assert.equal(stepIndex(steps, "properties"), 0);
174
+ assert.equal(ownedHistoryEntries(steps, "properties"), 1);
175
+ });
176
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The selection wizard's step in the URL, and the history entries it owns
3
+ * (#477 clause 1.6). TanStack Router owns history here: the step is a validated
4
+ * `wizard` search param on /mail, pushed with `navigate({ search })`, so
5
+ * `router.state.location` and `window.location` never disagree.
6
+ *
7
+ * The wizard is rooted on an entry that carries no step, so the entries it owns
8
+ * are the pushes that reached the current step: one for the opening step, one
9
+ * per advance. A document that loads straight into a step has no such root, and
10
+ * no answers behind the step either, so it is re-rooted at the opening step
11
+ * before anything else — otherwise the number of entries to rewind is a guess
12
+ * about a tab this code never saw.
13
+ *
14
+ * Which steps Back leaves the wizard from is `backExits` in the step model, and
15
+ * the shell reads it to route Back to exit rather than to a step. These are the
16
+ * two movements it routes between: `goBack` pops one entry, `closeWizard`
17
+ * rewinds every entry the wizard owns.
18
+ */
19
+
20
+ import { type StepId, stepIndex } from "@remit/ui";
21
+ import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
22
+ import { useCallback, useEffect, useRef } from "react";
23
+ import { z } from "zod";
24
+
25
+ const stepId = z.enum([
26
+ "match",
27
+ "properties",
28
+ "folder",
29
+ "rule",
30
+ "name",
31
+ "review",
32
+ "run",
33
+ ]);
34
+
35
+ export const wizardStepFromParam = (value: unknown): StepId | undefined => {
36
+ const parsed = stepId.safeParse(value);
37
+ return parsed.success ? parsed.data : undefined;
38
+ };
39
+
40
+ /**
41
+ * The route's `wizard` field. A value the wizard cannot be on reads as no step
42
+ * rather than as a validation failure, so a truncated or hand-typed link lands
43
+ * on the mail list instead of the router's error screen.
44
+ */
45
+ export const wizardStepValue = z.unknown().transform(wizardStepFromParam);
46
+
47
+ export const ownedHistoryEntries = (
48
+ steps: readonly StepId[],
49
+ step: StepId,
50
+ ): number => stepIndex(steps, step) + 1;
51
+
52
+ export const useWizardStepValue = (): StepId | undefined =>
53
+ useSearch({ from: "/mail", select: (search) => search.wizard });
54
+
55
+ export interface WizardStepNavigation {
56
+ step: StepId | undefined;
57
+ goToStep: (step: StepId) => void;
58
+ goBack: () => void;
59
+ closeWizard: (steps: readonly StepId[], step: StepId) => void;
60
+ }
61
+
62
+ export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
63
+ const router = useRouter();
64
+ const navigate = useNavigate();
65
+ const step = useWizardStepValue();
66
+ // Whether this document loaded already holding a step, which is the one
67
+ // entrance that leaves the wizard unrooted. A step the app itself pushed
68
+ // arrives rooted, so re-rooting it would duplicate the entry underneath it.
69
+ const loadedHoldingStep = useRef(step !== undefined);
70
+ const pushedTo = useRef<StepId | undefined>(undefined);
71
+
72
+ // Two taps on Continue land before the URL settles, and both would push the
73
+ // same step — leaving a back that appears to do nothing.
74
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the guard is cleared because the step changed, which is only knowable from the dependency.
75
+ useEffect(() => {
76
+ pushedTo.current = undefined;
77
+ }, [step]);
78
+
79
+ useEffect(() => {
80
+ if (!loadedHoldingStep.current) return;
81
+ loadedHoldingStep.current = false;
82
+ void (async () => {
83
+ await navigate({
84
+ to: ".",
85
+ search: (prev) => ({ ...prev, wizard: undefined }),
86
+ replace: true,
87
+ });
88
+ await navigate({
89
+ to: ".",
90
+ search: (prev) => ({ ...prev, wizard: openingStep }),
91
+ });
92
+ })();
93
+ }, [openingStep, navigate]);
94
+
95
+ const goToStep = useCallback(
96
+ (next: StepId) => {
97
+ if (next === step || pushedTo.current === next) return;
98
+ pushedTo.current = next;
99
+ navigate({ to: ".", search: (prev) => ({ ...prev, wizard: next }) });
100
+ },
101
+ [navigate, step],
102
+ );
103
+
104
+ const goBack = useCallback(() => {
105
+ router.history.back();
106
+ }, [router]);
107
+
108
+ const closeWizard = useCallback(
109
+ (steps: readonly StepId[], current: StepId) => {
110
+ router.history.go(-ownedHistoryEntries(steps, current));
111
+ },
112
+ [router],
113
+ );
114
+
115
+ return { step, goToStep, goBack, closeWizard };
116
+ };
@@ -21,6 +21,7 @@ import { FlaggedPane } from "@/components/mail/FlaggedPane";
21
21
  import { MailboxPane } from "@/components/mail/MailboxPane";
22
22
  import { MailNav } from "@/components/mail/MailNav";
23
23
  import { OutboxPane } from "@/components/mail/OutboxPane";
24
+ import { SelectionWizardHost } from "@/components/mail/SelectionWizardHost";
24
25
  import { ErrorState } from "@/components/ui/ErrorState";
25
26
  import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
26
27
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
@@ -43,6 +44,7 @@ import {
43
44
  searchInputForView,
44
45
  shouldMirrorQuery,
45
46
  } from "@/lib/search-view";
47
+ import { wizardStepValue } from "@/lib/wizard-history";
46
48
  import "@/lib/client";
47
49
 
48
50
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -53,6 +55,9 @@ export { useMailContext } from "@/lib/mail-context";
53
55
 
54
56
  const mailSearchSchema = z.object({
55
57
  q: z.string().optional(),
58
+ // The selection wizard's step (#477 clause 1.6). The router owns history, so
59
+ // the step is a validated search param rather than a raw pushState entry.
60
+ wizard: wizardStepValue,
56
61
  });
57
62
 
58
63
  export const Route = createFileRoute("/mail")({
@@ -92,6 +97,9 @@ function MailLayout() {
92
97
  // collapse preference there (#782). DKIM-mismatch auto-open still fires on
93
98
  // every tier. Explicit toggles persist the user's choice.
94
99
  const [intelligenceOpen, setIntelligenceOpen] = useState(false);
100
+ // The list owns the selection; the wizard is mounted here, beside it, and
101
+ // counts the same rows the selection bar is counting.
102
+ const [selectedCount, setSelectedCount] = useState(0);
95
103
  const handleSetIntelligenceOpen = useCallback((open: boolean) => {
96
104
  setIntelligenceOpen(open);
97
105
  writeIntelligencePref(open);
@@ -304,6 +312,8 @@ function MailLayout() {
304
312
  intelligenceOpen,
305
313
  onToggleIntelligence: handleToggleIntelligence,
306
314
  onSetIntelligenceOpen: handleSetIntelligenceOpen,
315
+ selectedCount,
316
+ onSelectedCountChange: setSelectedCount,
307
317
  };
308
318
 
309
319
  // Single nav node: the kit renders it as a pane (≥1024px) or inside its
@@ -468,6 +478,7 @@ function MailLayout() {
468
478
  isOpen={showShortcuts}
469
479
  onClose={() => setShowShortcuts(false)}
470
480
  />
481
+ <SelectionWizardHost verb="organize" selectedCount={selectedCount} />
471
482
  {/* Outlet is required for TanStack Router to activate child routes.
472
483
  Routes that manage their own rendering (brief, mailbox, outbox) return
473
484
  null from their component — the parent shell owns the layout. */}