@remit/web-client 0.0.91 → 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.
- package/package.json +1 -1
- package/src/components/mail/MessageList.selection.test.ts +8 -1
- package/src/components/mail/MessageList.tsx +6 -2
- package/src/components/mail/SelectionWizardHost.tsx +191 -0
- package/src/components/mail/ThreadListInteraction.tsx +9 -0
- package/src/lib/mail-context.ts +9 -0
- package/src/lib/selection-mode.test.ts +20 -6
- package/src/lib/selection-mode.ts +8 -1
- package/src/lib/wizard-history.test.ts +176 -0
- package/src/lib/wizard-history.ts +116 -0
- package/src/routes/mail.tsx +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.92",
|
|
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": {
|
|
@@ -45,10 +45,17 @@ describe("MessageList selection mode", () => {
|
|
|
45
45
|
it("turns the back gesture into an exit, and only while selecting", () => {
|
|
46
46
|
assert.match(
|
|
47
47
|
source,
|
|
48
|
-
/shouldExitSelectionOnNavigate\(action, hasSelection\)/,
|
|
48
|
+
/shouldExitSelectionOnNavigate\(action, hasSelection, wizardStep\)/,
|
|
49
49
|
);
|
|
50
50
|
assert.match(source, /disabled: !hasSelection/);
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
it("leaves the back gesture to the wizard while the wizard is open", () => {
|
|
54
|
+
assert.match(
|
|
55
|
+
source,
|
|
56
|
+
/disabled: !hasSelection \|\| wizardStep !== undefined/,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
shouldExitSelectionOnNavigate,
|
|
53
53
|
} from "@/lib/selection-mode";
|
|
54
54
|
import { cn } from "@/lib/utils";
|
|
55
|
+
import { useWizardStepValue } from "@/lib/wizard-history";
|
|
55
56
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
56
57
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
57
58
|
import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
|
|
@@ -232,6 +233,7 @@ export const MessageList = ({
|
|
|
232
233
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
233
234
|
const navigate = useNavigate();
|
|
234
235
|
const isDesktop = useIsDesktop();
|
|
236
|
+
const wizardStep = useWizardStepValue();
|
|
235
237
|
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
236
238
|
// Mobile only: which selection-sheet entry opened the guided organize flow
|
|
237
239
|
// (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
|
|
@@ -1060,12 +1062,14 @@ export const MessageList = ({
|
|
|
1060
1062
|
// — is never blocked, and the blocker is off entirely with nothing selected.
|
|
1061
1063
|
useBlocker({
|
|
1062
1064
|
shouldBlockFn: ({ action }) => {
|
|
1063
|
-
if (!shouldExitSelectionOnNavigate(action, hasSelection))
|
|
1065
|
+
if (!shouldExitSelectionOnNavigate(action, hasSelection, wizardStep)) {
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1064
1068
|
exitSelection();
|
|
1065
1069
|
return true;
|
|
1066
1070
|
},
|
|
1067
1071
|
enableBeforeUnload: false,
|
|
1068
|
-
disabled: !hasSelection,
|
|
1072
|
+
disabled: !hasSelection || wizardStep !== undefined,
|
|
1069
1073
|
});
|
|
1070
1074
|
|
|
1071
1075
|
// Load more when scrolling near the bottom
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ClauseDraft,
|
|
3
|
+
type ClauseEditState,
|
|
4
|
+
type MatchCount,
|
|
5
|
+
type MatchMode,
|
|
6
|
+
type RunState,
|
|
7
|
+
SelectionWizard,
|
|
8
|
+
type StepId,
|
|
9
|
+
stepBlockedReason,
|
|
10
|
+
stepIndex,
|
|
11
|
+
stepsFor,
|
|
12
|
+
type Verb,
|
|
13
|
+
type WizardDraft,
|
|
14
|
+
type WizardMessage,
|
|
15
|
+
} from "@remit/ui";
|
|
16
|
+
import { useCallback, useState } from "react";
|
|
17
|
+
import { useWizardStep } from "@/lib/wizard-history";
|
|
18
|
+
|
|
19
|
+
const OPENING_STEP: StepId = "match";
|
|
20
|
+
const UNCOUNTED: MatchCount = { status: "uncounted" };
|
|
21
|
+
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "all" };
|
|
22
|
+
|
|
23
|
+
/** How the commit ended, once something commits it (#483). */
|
|
24
|
+
export interface WizardOutcome {
|
|
25
|
+
state: RunState;
|
|
26
|
+
matched: number;
|
|
27
|
+
applied: number;
|
|
28
|
+
failures: readonly WizardMessage[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Nothing has been sent anywhere, so nothing can have been done. */
|
|
32
|
+
const NOTHING_COMMITTED: WizardOutcome = {
|
|
33
|
+
state: "commitFailed",
|
|
34
|
+
matched: 0,
|
|
35
|
+
applied: 0,
|
|
36
|
+
failures: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface SelectionWizardHostProps {
|
|
40
|
+
verb: Verb;
|
|
41
|
+
selectedCount: number;
|
|
42
|
+
outcome?: WizardOutcome;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SelectionWizardHost({
|
|
46
|
+
verb,
|
|
47
|
+
selectedCount,
|
|
48
|
+
outcome = NOTHING_COMMITTED,
|
|
49
|
+
}: SelectionWizardHostProps) {
|
|
50
|
+
const { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
|
|
51
|
+
const [mode, setMode] = useState<MatchMode>("selected");
|
|
52
|
+
const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
|
|
53
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
|
|
54
|
+
undefined,
|
|
55
|
+
);
|
|
56
|
+
const [nudgedStep, setNudgedStep] = useState<StepId | undefined>(undefined);
|
|
57
|
+
|
|
58
|
+
const startAddClause = useCallback(() => {
|
|
59
|
+
setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const startEditClause = useCallback(
|
|
63
|
+
(clauseId: string) => {
|
|
64
|
+
const clause = draft.clauses.find((held) => held.id === clauseId);
|
|
65
|
+
if (!clause) return;
|
|
66
|
+
setClauseEdit({
|
|
67
|
+
mode: "edit",
|
|
68
|
+
clauseId,
|
|
69
|
+
draft: { field: clause.field, value: clause.value },
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
[draft.clauses],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const removeClause = useCallback((clauseId: string) => {
|
|
76
|
+
setDraft((held) => ({
|
|
77
|
+
...held,
|
|
78
|
+
clauses: held.clauses.filter((clause) => clause.id !== clauseId),
|
|
79
|
+
}));
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const changeClauseDraft = useCallback((next: ClauseDraft) => {
|
|
83
|
+
setClauseEdit((edit) => edit && { ...edit, draft: next });
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const submitClause = useCallback(() => {
|
|
87
|
+
setClauseEdit((edit) => {
|
|
88
|
+
if (!edit) return undefined;
|
|
89
|
+
setDraft((held) => ({
|
|
90
|
+
...held,
|
|
91
|
+
clauses: edit.clauseId
|
|
92
|
+
? held.clauses.map((clause) =>
|
|
93
|
+
clause.id === edit.clauseId
|
|
94
|
+
? { ...clause, ...edit.draft }
|
|
95
|
+
: clause,
|
|
96
|
+
)
|
|
97
|
+
: [...held.clauses, { id: crypto.randomUUID(), ...edit.draft }],
|
|
98
|
+
}));
|
|
99
|
+
return undefined;
|
|
100
|
+
});
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
if (!step) return null;
|
|
104
|
+
|
|
105
|
+
const steps = stepsFor({ verb, mode, scope: draft.scope });
|
|
106
|
+
// The step the screens are on, which is the held one only while the answers
|
|
107
|
+
// still hold it. Reading the URL's step here and the resolved one on screen
|
|
108
|
+
// is how a footer comes to name a screen nobody is looking at.
|
|
109
|
+
const current = steps[stepIndex(steps, step)];
|
|
110
|
+
const blockedReason = stepBlockedReason(current, draft, UNCOUNTED);
|
|
111
|
+
const sample = {
|
|
112
|
+
messages: [],
|
|
113
|
+
count: UNCOUNTED,
|
|
114
|
+
label: "Messages this covers",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const advance = () => {
|
|
118
|
+
if (blockedReason) {
|
|
119
|
+
setNudgedStep(current);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const next = steps[stepIndex(steps, current) + 1];
|
|
123
|
+
if (next) goToStep(next);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<SelectionWizard
|
|
128
|
+
verb={verb}
|
|
129
|
+
steps={steps}
|
|
130
|
+
step={current}
|
|
131
|
+
blockedReason={blockedReason}
|
|
132
|
+
nudged={nudgedStep === current}
|
|
133
|
+
onBack={goBack}
|
|
134
|
+
onExit={() => closeWizard(steps, current)}
|
|
135
|
+
onContinue={advance}
|
|
136
|
+
onCommit={() => goToStep("run")}
|
|
137
|
+
match={{
|
|
138
|
+
selectedCount,
|
|
139
|
+
mode,
|
|
140
|
+
onModeChange: setMode,
|
|
141
|
+
onSemanticFallback: () => setMode("properties"),
|
|
142
|
+
sample,
|
|
143
|
+
}}
|
|
144
|
+
properties={{
|
|
145
|
+
clauses: draft.clauses,
|
|
146
|
+
matchOperator: draft.matchOperator,
|
|
147
|
+
onMatchOperatorChange: (matchOperator) =>
|
|
148
|
+
setDraft((held) => ({ ...held, matchOperator })),
|
|
149
|
+
clauseEdit,
|
|
150
|
+
onStartAddClause: startAddClause,
|
|
151
|
+
onStartEditClause: startEditClause,
|
|
152
|
+
onRemoveClause: removeClause,
|
|
153
|
+
onChangeDraft: changeClauseDraft,
|
|
154
|
+
onSubmitClause: submitClause,
|
|
155
|
+
onCancelClause: () => setClauseEdit(undefined),
|
|
156
|
+
sample,
|
|
157
|
+
}}
|
|
158
|
+
rule={{
|
|
159
|
+
draft,
|
|
160
|
+
onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
|
|
161
|
+
onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
|
|
162
|
+
}}
|
|
163
|
+
name={{
|
|
164
|
+
name: draft.name ?? "",
|
|
165
|
+
onNameChange: (name) => setDraft((held) => ({ ...held, name })),
|
|
166
|
+
nudged: nudgedStep === current,
|
|
167
|
+
}}
|
|
168
|
+
review={{
|
|
169
|
+
verb,
|
|
170
|
+
mode,
|
|
171
|
+
selectedCount,
|
|
172
|
+
clauses: draft.clauses,
|
|
173
|
+
matchOperator: draft.matchOperator,
|
|
174
|
+
scope: draft.scope,
|
|
175
|
+
until: draft.until,
|
|
176
|
+
ruleName: draft.name,
|
|
177
|
+
sample,
|
|
178
|
+
}}
|
|
179
|
+
run={{
|
|
180
|
+
state: outcome.state,
|
|
181
|
+
verb,
|
|
182
|
+
scope: draft.scope,
|
|
183
|
+
matched: outcome.matched,
|
|
184
|
+
applied: outcome.applied,
|
|
185
|
+
failures: outcome.failures,
|
|
186
|
+
onRetry: goBack,
|
|
187
|
+
onDismiss: () => closeWizard(steps, current),
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -36,6 +36,7 @@ import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
|
36
36
|
import { formatDeleteToTrashTitle } from "@/lib/format";
|
|
37
37
|
import { tabStopId } from "@/lib/list-focus";
|
|
38
38
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
39
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
39
40
|
import type { MessageListCommands } from "./MessageList";
|
|
40
41
|
import type { MessageRowSelection } from "./MessageRow";
|
|
41
42
|
|
|
@@ -186,6 +187,14 @@ export function ThreadListInteraction({
|
|
|
186
187
|
if (orderedIds.length > 0) toggleAll(orderedIds);
|
|
187
188
|
}, [orderedIds, toggleAll]);
|
|
188
189
|
|
|
190
|
+
// The selection wizard is mounted on the route, above this provider, and
|
|
191
|
+
// acts on these rows — so it reads the count from here rather than keeping
|
|
192
|
+
// one of its own (#477).
|
|
193
|
+
const { onSelectedCountChange } = useMailContext();
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
onSelectedCountChange(selectedCount);
|
|
196
|
+
}, [selectedCount, onSelectedCountChange]);
|
|
197
|
+
|
|
189
198
|
// A row that leaves the list — a chip filter, a collapsed section, a
|
|
190
199
|
// completed delete — cannot stay selected. Survivors keep their selection.
|
|
191
200
|
const { intersectWith } = selection;
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/routes/mail.tsx
CHANGED
|
@@ -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. */}
|