diff --git a/src/pages/ModelChooser.test.tsx b/src/pages/ModelChooser.test.tsx
index 6da7012..33c3251 100644
--- a/src/pages/ModelChooser.test.tsx
+++ b/src/pages/ModelChooser.test.tsx
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { StrictMode } from 'react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { MemoryRouter, useLocation } from 'react-router-dom';
+import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
import { describe, expect, test } from 'vitest';
import { findLesson } from '../content/manifest';
import { DATASET_FILES } from '../r/session';
@@ -15,12 +15,19 @@ function Location() {
return
{location.pathname + location.search}
;
}
+/** Stands in for the browser's Back button. */
+function BrowserBack() {
+ const navigate = useNavigate();
+ return ;
+}
+
function renderChooser(url = '/which-model') {
return render(
+
,
);
@@ -556,3 +563,18 @@ describe('research question', () => {
expect(screen.getByText(/comes from keywords in your question, not from AI/i)).toBeTruthy();
});
});
+
+test("the browser's Back button goes back one question, not off the page", async () => {
+ const user = userEvent.setup();
+ renderChooser();
+ const first = heading().textContent;
+ await user.click(screen.getByRole('button', { name: TO_OUTCOME }));
+ const second = heading().textContent;
+ await user.click(screen.getByRole('button', { name: /^a number/i }));
+ expect(heading().textContent).not.toBe(second);
+ await user.click(screen.getByRole('button', { name: 'Browser back' }));
+ expect(heading().textContent).toBe(second);
+ await user.click(screen.getByRole('button', { name: 'Browser back' }));
+ expect(heading().textContent).toBe(first);
+ expect(screen.getByTestId('location').textContent).toBe('/which-model');
+});
diff --git a/src/pages/ModelChooser.tsx b/src/pages/ModelChooser.tsx
index 9a5b6af..3fc5c6d 100644
--- a/src/pages/ModelChooser.tsx
+++ b/src/pages/ModelChooser.tsx
@@ -372,9 +372,13 @@ const startsWith = (path: string[], prefix: string[]) => prefix.every((label, i)
export default function ModelChooser() {
const [searchParams, setSearchParams] = useSearchParams();
- const [path, setPath] = useState(() => pathTo(searchParams.get('model') ?? '') ?? []);
+ // The answers so far live in the URL, one history entry per question, so the browser's Back button goes back one question.
+ const path = useMemo(() => {
+ const model = searchParams.get('model');
+ return model ? (pathTo(model) ?? []) : follow(searchParams.getAll('step')).path;
+ }, [searchParams]);
// The furthest path taken or suggested, so stepping back shows the choice made there.
- const [planned, setPlanned] = useState<{ path: string[]; suggested: boolean }>({ path, suggested: false });
+ const [planned, setPlanned] = useState<{ path: string[]; suggested: boolean }>(() => ({ path, suggested: false }));
const { node } = follow(path);
const headingRef = useRef(null);
const treeRef = useRef(null);
@@ -391,10 +395,9 @@ export default function ModelChooser() {
function go(next: string[], plan?: { path: string[]; suggested: boolean }) {
moveFocus.current = true;
- setPath(next);
setPlanned(plan ?? (startsWith(planned.path, next) ? planned : { path: next, suggested: false }));
const reached = follow(next).node;
- setSearchParams(reached.kind === 'answer' ? { model: reached.id } : {}, { replace: true });
+ setSearchParams(reached.kind === 'answer' ? { model: reached.id } : { step: next });
}
function pick(id: string) {
diff --git a/src/pages/SampleSize.test.tsx b/src/pages/SampleSize.test.tsx
index 8d6d6f7..1086f4d 100644
--- a/src/pages/SampleSize.test.tsx
+++ b/src/pages/SampleSize.test.tsx
@@ -1,16 +1,23 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { MemoryRouter } from 'react-router-dom';
+import { MemoryRouter, useNavigate } from 'react-router-dom';
import { describe, expect, test } from 'vitest';
import { findLesson } from '../content/manifest';
import SampleSize from './SampleSize';
import { DESIGNS } from './sampleSizeDesigns';
+/** Stands in for the browser's Back button. */
+function BrowserBack() {
+ const navigate = useNavigate();
+ return ;
+}
+
function setup() {
const user = userEvent.setup();
render(
+
,
);
const pick = (name: RegExp) => user.click(screen.getByRole('button', { name }));
@@ -185,6 +192,18 @@ describe('sample size page', () => {
expect(document.querySelector('.ss-meaning')!.textContent).toContain('f = 0.16');
});
+ test("the browser's Back button goes back one question, keeping the answers", async () => {
+ const { user, pick } = setup();
+ await pick(/^Compare two separate groups/);
+ await pick(/How many people do I need/);
+ expect(question()).toBe('How big is the effect you want to be able to find?');
+ await user.click(screen.getByRole('button', { name: 'Browser back' }));
+ expect(question()).toBe('What do you want to find out?');
+ expect(screen.getByRole('button', { name: /How many people do I need/ }).textContent).toContain('Your earlier choice');
+ await user.click(screen.getByRole('button', { name: 'Browser back' }));
+ expect(question()).toBe('What will you test?');
+ });
+
test('every design links to a lesson that exists', () => {
for (const design of DESIGNS) expect(findLesson(design.lessonId), design.id).toBeTruthy();
});
diff --git a/src/pages/SampleSize.tsx b/src/pages/SampleSize.tsx
index aa66247..f0d1906 100644
--- a/src/pages/SampleSize.tsx
+++ b/src/pages/SampleSize.tsx
@@ -1,5 +1,5 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
-import { Link } from 'react-router-dom';
+import { Link, useSearchParams } from 'react-router-dom';
import AvatarTip from '../components/AvatarTip';
import { pnorm } from '../stats/distributions';
import { HAS_SIDES, minN, PER_GROUP, powerAt, rCode, solveEffect, solveN, stimulusSimCode, totalN, type DesignId, type Plan } from '../stats/power';
@@ -222,7 +222,11 @@ function Choice({ options, chosen, onPick }: { options: Option[]; chosen:
export default function SampleSize() {
const [answers, setAnswers] = useState(START);
- const [at, setAt] = useState(0);
+ // The step lives in the URL, one history entry per question, so the browser's Back button goes back one question.
+ // Answers are not in the URL, so a step is only shown once it has been reached in this visit.
+ const [params, setParams] = useSearchParams();
+ const [reached, setReached] = useState(0);
+ const at = Math.min(Math.max(0, Math.floor(Number(params.get('step')) || 0)), reached);
const [copied, setCopied] = useState('');
const headingRef = useRef(null);
const moveFocus = useRef(false);
@@ -240,12 +244,12 @@ export default function SampleSize() {
function go(index: number) {
moveFocus.current = true;
- setAt(index);
+ setParams(index > 0 ? { step: String(index) } : {});
}
function answer(patch: Partial) {
- moveFocus.current = true;
setAnswers((prev) => ({ ...prev, ...patch }));
- setAt((i) => i + 1);
+ setReached(at + 1);
+ go(at + 1);
}
function type(key: string, value: string) {
setAnswers((prev) => ({ ...prev, text: { ...prev.text, [key]: value } }));
@@ -603,7 +607,7 @@ export default function SampleSize() {
-
+
)}