+
+ {menu && (
+
+ {active ? (
+
+ Current objective
+ {active.name}
+
+ ) : (
+
+ No objective set
+ Everything is in scope until you pick one
+
+ )}
+
+ {recent.length > 0 && (
+ <>
+
Recently
+ {recent.map(exam => (
+
+ ))}
+ >
+ )}
+
+
+ {error && !open &&
{error}
}
+
+ )}
+
{open && (
e.target === e.currentTarget && setOpen(false)}>
@@ -94,15 +175,9 @@ export default function ExamSwitcher({ onChange }) {
onChange={e => setQuery(e.target.value)} />
-
-
+ {/* There is no unscoped choice. Studying for nothing in
+ particular is not an objective, and the whole bank at once
+ makes the filters and the analysis mean less, not more. */}
{families.map(([family, list]) => (
{family}
@@ -133,12 +208,13 @@ export default function ExamSwitcher({ onChange }) {
{error && {error}
}
-
+
)}
- >
+
)
}
diff --git a/frontend/src/components/ExamSwitcher.test.jsx b/frontend/src/components/ExamSwitcher.test.jsx
new file mode 100644
index 0000000..76df576
--- /dev/null
+++ b/frontend/src/components/ExamSwitcher.test.jsx
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ExamSwitcher from './ExamSwitcher'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), put: vi.fn() } }))
+
+const EXAMS = [
+ { id: 1, name: 'Pediatrics Boards', family: 'Boards', question_count: 2948 },
+ { id: 2, name: 'Shelf – Psychiatry', family: 'Shelf', question_count: 140 },
+ { id: 3, name: 'USMLE Step 2 CK', family: 'USMLE', question_count: 0 },
+]
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ api.get.mockResolvedValue({ data: { exams: EXAMS, active_exam_id: 1 } })
+ api.put.mockResolvedValue({ data: {} })
+})
+
+const openMenu = async () => {
+ render(
)
+ await userEvent.click(await screen.findByRole('button', { name: /Pediatrics Boards/ }))
+}
+
+describe('ExamSwitcher', () => {
+ it('opens a short menu before the full picker', async () => {
+ await openMenu()
+ const menu = screen.getByRole('menu')
+ expect(within(menu).getByText('Current objective')).toBeInTheDocument()
+ expect(within(menu).getByText('Pediatrics Boards')).toBeInTheDocument()
+ // The whole list is a step further in, not the first thing you meet.
+ expect(screen.queryByRole('dialog')).toBeNull()
+ await userEvent.click(within(menu).getByRole('menuitem', { name: 'Choose a new study objective' }))
+ expect(await screen.findByRole('dialog', { name: 'Current study objective' })).toBeInTheDocument()
+ })
+
+ it('switches straight back to one you were on lately', async () => {
+ localStorage.setItem('pedshub.recentObjectives', JSON.stringify([2]))
+ await openMenu()
+ const menu = screen.getByRole('menu')
+ await userEvent.click(within(menu).getByRole('menuitem', { name: /Shelf – Psychiatry/ }))
+ expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 2 })
+ })
+
+ it('offers no unscoped choice, and no objective with nothing behind it', async () => {
+ await openMenu()
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Choose a new study objective' }))
+ const dialog = await screen.findByRole('dialog')
+ // Studying for nothing in particular is not an objective.
+ expect(within(dialog).queryByText('All content')).toBeNull()
+ expect(within(dialog).getByRole('radio', { name: /USMLE Step 2 CK/ })).toBeDisabled()
+ })
+})
diff --git a/frontend/src/components/QuestionEditors.jsx b/frontend/src/components/QuestionEditors.jsx
index 7cd2c3e..652d9b3 100644
--- a/frontend/src/components/QuestionEditors.jsx
+++ b/frontend/src/components/QuestionEditors.jsx
@@ -1,9 +1,10 @@
/* Question authoring modals shared by the question bank and the question manager. */
-import { useState, useEffect, lazy, Suspense } from 'react'
+import { useState, useEffect, Suspense } from 'react'
+import lazyPage from '../utils/lazyPage'
import api from '../api/client'
import CategoryTree from './CategoryTree'
-const RichEditor = lazy(() => import('../components/RichEditor'))
+const RichEditor = lazyPage(() => import('../components/RichEditor'))
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
diff --git a/frontend/src/components/QuestionPreview.jsx b/frontend/src/components/QuestionPreview.jsx
index 8cf7ab9..a253595 100644
--- a/frontend/src/components/QuestionPreview.jsx
+++ b/frontend/src/components/QuestionPreview.jsx
@@ -1,10 +1,11 @@
-import { Suspense, lazy } from 'react'
+import { Suspense } from 'react'
+import lazyPage from '../utils/lazyPage'
import { Link } from 'react-router-dom'
import RichText from './RichText'
import QuestionReadingLinks from './QuestionReadingLinks'
import { uploadUrl } from '../utils/uploads'
-const TeachChat = lazy(() => import('./TeachChat'))
+const TeachChat = lazyPage(() => import('./TeachChat'))
/**
* One question, shown whole, without leaving the page you found it on.
diff --git a/frontend/src/pages/CourseEditorPage.jsx b/frontend/src/pages/CourseEditorPage.jsx
index 2083c5a..d337457 100644
--- a/frontend/src/pages/CourseEditorPage.jsx
+++ b/frontend/src/pages/CourseEditorPage.jsx
@@ -1,10 +1,11 @@
-import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'
+import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
+import lazyPage from '../utils/lazyPage'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
-const RichEditor = lazy(() => import('../components/RichEditor'))
+const RichEditor = lazyPage(() => import('../components/RichEditor'))
function stripHtml(html) {
if (!html) return ''
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx
index d6b461d..0ca8ece 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -1,6 +1,7 @@
import { uploadUrl } from '../utils/uploads'
import QuestionReadingLinks from '../components/QuestionReadingLinks'
-import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
+import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
+import lazyPage from '../utils/lazyPage'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import RichText from '../components/RichText'
import { mergeTextRanges } from '../utils/highlightOffsets'
@@ -14,7 +15,7 @@ import '../components/Feedback.css'
import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css'
-const TeachChat = lazy(() => import('../components/TeachChat'))
+const TeachChat = lazyPage(() => import('../components/TeachChat'))
const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
const QUESTION_HIGHLIGHT_WORDS = 8
@@ -508,6 +509,11 @@ export default function QuizPage() {
}, [id, manualHighlights])
const [leaveTarget, setLeaveTarget] = useState(null)
+
+ // Suspending is not abandoning: it ends on the session's own analysis, where
+ // what has been answered so far is scored and the Resume button sits. A
+ // course quiz still returns to the course it belongs to.
+ const exitTarget = () => returnTo || (attemptId ? `/sessions/${attemptId}` : '/')
const questions = quiz?.questions || []
const current = questions[currentIdx]
const isStudy = quizMode === 'study'
@@ -1240,7 +1246,7 @@ const timerStarted = timeLeft !== null
{timeLeft !== null &&
}
-