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() {
- +
)}