fix: the objective menu was clipped out of existence; one door to settings

The section bar is 46px tall with overflow hidden, so an absolutely
positioned menu inside it was cropped to a strip and never appeared —
the same reason it looked wrong in a settings panel, which also clips
its own overflow. The menu is fixed now and the component measures the
button and places it, following resize and scroll.

In a settings panel there is no menu at all: the objective is stated
plainly with a Change button that opens the picker, which is an overlay
fixed to the viewport and so cannot be clipped by anything.

The taxonomy page no longer searches the question bank. Filing a
question is done where the question is; a search box on a taxonomy row
was a second, worse question bank.

The account page is gone. Settings opens on the account, so Account and
Settings were two doors to the same room; /account redirects. Admins get
an Administration entry in the person menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 20:03:11 +02:00
parent 0d4042169a
commit 6b6c5e1b49
13 changed files with 177 additions and 312 deletions

View file

@ -15,7 +15,6 @@ const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
const QuizPage = lazyPage(() => import('./pages/QuizPage'))
const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage'))
const ResultsPage = lazyPage(() => import('./pages/ResultsPage'))
const AccountPage = lazyPage(() => import('./pages/AccountPage'))
const SettingsPage = lazyPage(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage'))
const QuestionManagerPage = lazyPage(() => import('./pages/QuestionManagerPage'))
@ -133,7 +132,8 @@ function AppRoutes() {
<Route path="/study/:id" element={<QuizPage />} />
<Route path="/results/:id" element={<ResultsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/account" element={<AccountPage />} />
{/* Settings opens on the account; one door, not two. */}
<Route path="/account" element={<Navigate to="/settings" replace />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/questions/manage" element={<QuestionManagerPage />} />

View file

@ -22,10 +22,13 @@
/* The short menu
What you are on, where you have been lately, and a way to the full
list so changing objective does not start with a full-screen dialog. */
.exam-switcher { position: relative; display: inline-flex; }
.exam-switcher { display: inline-flex; }
.exm {
position: absolute; top: calc(100% + 6px); left: 0; z-index: 1090;
min-width: 260px; max-width: min(320px, calc(100vw - 24px));
/* Fixed, not absolute. The section bar it lives in is 46px tall with
overflow hidden, which clipped an absolutely positioned menu out of
existence; the component measures the button and places this itself. */
position: fixed; top: 0; left: 0; z-index: 1090;
width: 280px; max-width: calc(100vw - 24px);
padding: 6px; background: var(--card-bg);
border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.18);
@ -55,10 +58,23 @@
}
.exm-error { margin: 6px 11px 2px; font-size: 0.78rem; color: var(--wrong-fg); }
/* On a phone the menu is the width of the screen rather than of the button. */
@media (max-width: 520px) {
.exm { left: auto; right: 0; min-width: 240px; }
/* In a settings panel
Plain, full width, and no pop-out menu: the panel clips anything that
hangs out of it, and there is room to just say what the objective is. */
.exam-switcher-inline {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
padding: 14px 16px;
border: 1px solid var(--border); border-radius: 10px;
background: var(--bg);
}
.exam-switcher-inline small {
display: block;
font-size: 0.64rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-subtle);
}
.exam-switcher-inline strong { display: block; margin-top: 3px; font-size: 1rem; font-weight: 650; }
.exam-switcher-inline > div > span { font-size: 0.8rem; color: var(--text-muted); }
/* ── The dialog ───────────────────────────────────────────────────── */
.exo-overlay {

View file

@ -37,7 +37,7 @@ function rememberRecent(id) {
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
} catch { /* private browsing, or storage turned off */ }
}
export default function ExamSwitcher({ onChange }) {
export default function ExamSwitcher({ onChange, inline = false }) {
const [exams, setExams] = useState([])
const [activeId, setActiveId] = useState(null)
const [menu, setMenu] = useState(false)
@ -48,6 +48,8 @@ export default function ExamSwitcher({ onChange }) {
const [error, setError] = useState('')
const search = useRef(null)
const wrap = useRef(null)
const trigger = useRef(null)
const [anchor, setAnchor] = useState(null)
const load = () => api.get('/exams/')
.then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) })
@ -57,11 +59,29 @@ export default function ExamSwitcher({ onChange }) {
useEffect(() => {
if (!menu) return undefined
// The menu is fixed rather than absolute. It lives inside the section bar,
// which is 46px tall with overflow hidden an absolutely positioned menu
// is clipped to that strip and simply never appears. Fixed positioning
// escapes every overflowing ancestor, so it has to be told where to go.
const place = () => {
const box = trigger.current?.getBoundingClientRect()
if (!box) return
const width = 280
setAnchor({
top: Math.round(box.bottom + 6),
left: Math.round(Math.min(box.left, window.innerWidth - width - 12)),
})
}
place()
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
const away = e => { if (!wrap.current?.contains(e.target)) setMenu(false) }
const onKey = e => { if (e.key === 'Escape') setMenu(false) }
document.addEventListener('mousedown', away)
document.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
document.removeEventListener('mousedown', away)
document.removeEventListener('keydown', onKey)
}
@ -114,9 +134,88 @@ export default function ExamSwitcher({ onChange }) {
.filter(e => e && e.question_count > 0)
.slice(0, RECENT_MAX)
// The full picker. Shared by both shapes: an overlay is fixed to the
// viewport, so nothing that clips the trigger can clip it.
const dialog = open ? (
<div className="exo-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
<div className="exo" role="dialog" aria-modal="true" aria-labelledby="exo-heading">
<div className="exo-head">
<h2 id="exo-heading">Current study objective</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close"></button>
</div>
<p className="exo-lead">
Scopes the question bank, the filters and your performance analysis.
Material linked to no exam stays visible whichever you pick.
</p>
<input ref={search} className="exo-search" type="search" value={query}
placeholder="Search" aria-label="Search study objectives"
onChange={e => setQuery(e.target.value)} />
<div className="exo-body" role="radiogroup" aria-labelledby="exo-heading">
{/* 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]) => (
<section key={family}>
<h3 className="exo-family">{family}</h3>
<div className="exo-grid">
{list.map(exam => {
// An objective with nothing behind it would empty the
// bank. Shown, so an educator can see it exists, but not
// selectable until it has questions.
const empty = exam.question_count === 0
return (
<label key={exam.id}
className={`exo-option${chosen === exam.id ? ' is-on' : ''}${empty ? ' is-empty' : ''}`}>
<input type="radio" name="objective" disabled={empty}
checked={chosen === exam.id} onChange={() => setChosen(exam.id)} />
<span>
<strong>{exam.name}</strong>
<small>{empty ? 'No questions yet' : `${exam.question_count} questions`}</small>
</span>
</label>
)
})}
</div>
</section>
))}
{families.length === 0 && <p className="exo-empty">Nothing matches that.</p>}
</div>
{error && <p className="exo-error" role="alert">{error}</p>}
<div className="exo-foot">
<button type="button" className="btn btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
<button type="button" className="btn btn-primary"
disabled={busy || chosen === null || chosen === activeId}
onClick={() => apply(chosen)}>{busy ? 'Saving…' : 'Save changes'}</button>
</div>
</div>
</div>
) : null
// In a settings panel there is room to say the thing plainly, and the panel
// clips anything that hangs out of it so the short menu, which is a
// navbar affordance, is skipped and the picker opens directly.
if (inline) {
return (
<div className="exam-switcher-inline">
<div>
<small>Current objective</small>
<strong>{active ? active.name : 'None chosen'}</strong>
{active && <span>{active.question_count} questions</span>}
</div>
<button type="button" className="btn btn-secondary" onClick={() => setOpen(true)}>
{active ? 'Change' : 'Choose'}
</button>
{dialog}
</div>
)
}
return (
<div className="exam-switcher" ref={wrap}>
<button type="button" className="exam-switcher-button" aria-haspopup="menu"
<button type="button" ref={trigger} className="exam-switcher-button" aria-haspopup="menu"
aria-expanded={menu} onClick={() => setMenu(v => !v)}>
<span className="exam-switcher-label">Studying for</span>
<span className="exam-switcher-name">{active ? active.name : 'Choose an objective'}</span>
@ -124,7 +223,8 @@ export default function ExamSwitcher({ onChange }) {
</button>
{menu && (
<div className="exm" role="menu">
<div className="exm" role="menu"
style={anchor ? { top: anchor.top, left: anchor.left } : undefined}>
{active ? (
<div className="exm-current">
<small>Current objective</small>
@ -158,63 +258,7 @@ export default function ExamSwitcher({ onChange }) {
</div>
)}
{open && (
<div className="exo-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
<div className="exo" role="dialog" aria-modal="true" aria-labelledby="exo-heading">
<div className="exo-head">
<h2 id="exo-heading">Current study objective</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close"></button>
</div>
<p className="exo-lead">
Scopes the question bank, the filters and your performance analysis.
Material linked to no exam stays visible whichever you pick.
</p>
<input ref={search} className="exo-search" type="search" value={query}
placeholder="Search" aria-label="Search study objectives"
onChange={e => setQuery(e.target.value)} />
<div className="exo-body" role="radiogroup" aria-labelledby="exo-heading">
{/* 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]) => (
<section key={family}>
<h3 className="exo-family">{family}</h3>
<div className="exo-grid">
{list.map(exam => {
// An objective with nothing behind it would empty the
// bank. Shown, so an educator can see it exists, but not
// selectable until it has questions.
const empty = exam.question_count === 0
return (
<label key={exam.id}
className={`exo-option${chosen === exam.id ? ' is-on' : ''}${empty ? ' is-empty' : ''}`}>
<input type="radio" name="objective" disabled={empty}
checked={chosen === exam.id} onChange={() => setChosen(exam.id)} />
<span>
<strong>{exam.name}</strong>
<small>{empty ? 'No questions yet' : `${exam.question_count} questions`}</small>
</span>
</label>
)
})}
</div>
</section>
))}
{families.length === 0 && <p className="exo-empty">Nothing matches that.</p>}
</div>
{error && <p className="exo-error" role="alert">{error}</p>}
<div className="exo-foot">
<button type="button" className="btn btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
<button type="button" className="btn btn-primary"
disabled={busy || chosen === null || chosen === activeId}
onClick={() => apply(chosen)}>{busy ? 'Saving…' : 'Save changes'}</button>
</div>
</div>
</div>
)}
{dialog}
</div>
)
}

View file

@ -44,6 +44,30 @@ describe('ExamSwitcher', () => {
expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 2 })
})
it('places the menu itself, because the bar it sits in clips its own overflow', async () => {
// .navbar-sections is 46px tall with overflow:hidden. An absolutely
// positioned menu was clipped to that strip and never appeared at all.
await openMenu()
const menu = screen.getByRole('menu')
// The stylesheet is not loaded here, so `position: fixed` cannot be read
// back. What can be checked is the part the component owns: it measured
// the trigger and wrote coordinates, which is only needed because the
// menu is taken out of the clipped flow.
expect(menu.style.top).not.toBe('')
expect(menu.style.left).not.toBe('')
})
it('says the objective plainly in a settings panel, with no menu to clip', async () => {
render(<ExamSwitcher inline />)
expect(await screen.findByText('Pediatrics Boards')).toBeInTheDocument()
expect(screen.getByText('2948 questions')).toBeInTheDocument()
// No pop-out at all: the panel has overflow:hidden, so Change opens the
// full picker, which is an overlay fixed to the viewport.
expect(screen.queryByRole('menu')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Change' }))
expect(await screen.findByRole('dialog', { name: 'Current study objective' })).toBeInTheDocument()
})
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' }))

View file

@ -164,8 +164,14 @@ function AccountMenu({ user, onLogout }) {
<span>{user?.email}</span>
</div>
<Link role="menuitem" to="/" onClick={() => setOpen(false)}>Dashboard</Link>
<Link role="menuitem" to="/account" onClick={() => setOpen(false)}>Account</Link>
{/* Settings opens on the account, so a separate Account entry was
two doors to the same room. */}
<Link role="menuitem" to="/settings" onClick={() => setOpen(false)}>Settings</Link>
{user?.role === 'admin' && (
<Link role="menuitem" to="/settings?s=people" onClick={() => setOpen(false)}>
Administration
</Link>
)}
<button type="button" role="menuitem" className="acct-signout"
onClick={() => { setOpen(false); onLogout() }}>Sign out</button>
</div>

View file

@ -125,7 +125,9 @@ describe('two-bar header', () => {
const menu = screen.getByRole('menu')
expect(within(menu).getByRole('menuitem', { name: 'Dashboard' })).toHaveAttribute('href', '/')
expect(within(menu).getByRole('menuitem', { name: 'Settings' })).toHaveAttribute('href', '/settings')
expect(within(menu).getByRole('menuitem', { name: 'Account' })).toHaveAttribute('href', '/account')
// Settings opens on the account, so there is no second door to it.
expect(within(menu).queryByRole('menuitem', { name: 'Account' })).toBeNull()
expect(within(menu).getByRole('menuitem', { name: 'Settings' })).toHaveAttribute('href', '/settings')
expect(within(menu).getByRole('menuitem', { name: 'Sign out' })).toBeInTheDocument()
})

View file

@ -38,7 +38,6 @@ const COLUMNS = [
links: [
{ to: '/home', label: 'About' },
{ to: '/home#contact', label: 'Contact' },
{ to: '/account', label: 'Account' },
{ to: '/settings', label: 'Settings' },
],
},

View file

@ -5,7 +5,7 @@ import SiteFooter from './SiteFooter'
const ROUTES = ['/home', '/login', '/register', '/', '/sessions', '/question-bank',
'/questions/manage', '/flashcards', '/search', '/ai', '/media', '/study-plans', '/articles',
'/courses', '/account', '/settings', '/categories', '/editorial']
'/courses', '/settings', '/categories', '/editorial']
describe('site footer', () => {
it('is a way around, grouped by what you are trying to do', () => {

View file

@ -1,129 +0,0 @@
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function AccountPage() {
const { user, login } = useAuth()
const [name, setName] = useState(user?.name || '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess('')
if (newPassword && newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword && newPassword.length < 8) {
setError('New password must be at least 8 characters')
return
}
setLoading(true)
try {
const payload = {}
if (name !== user.name) payload.name = name
if (newPassword) {
payload.current_password = currentPassword
payload.new_password = newPassword
}
if (!Object.keys(payload).length) {
setError('No changes to save')
setLoading(false)
return
}
await api.put('/auth/me', payload)
setSuccess('Account updated successfully')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
// Refresh user info
await api.get('/auth/me')
// Update local token display by refreshing
window.location.reload()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update account')
} finally {
setLoading(false)
}
}
return (
<div style={{ maxWidth: 520, margin: '0 auto' }}>
<div className="card">
<h2>My Account</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: 4 }}>
{user?.email} &middot;
<span style={{
marginLeft: 6, fontSize: '0.75rem', fontWeight: 600,
background: user?.role === 'admin' ? '#fee2e2' : user?.role === 'moderator' ? '#ede9fe' : '#dbeafe',
color: user?.role === 'admin' ? '#dc2626' : user?.role === 'moderator' ? '#7c3aed' : '#2563eb',
padding: '1px 8px', borderRadius: 10,
}}>{user?.role}</span>
</p>
</div>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
<div className="card">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<hr style={{ border: 'none', borderTop: '1px solid #e2e8f0', margin: '20px 0' }} />
<p style={{ fontWeight: 600, fontSize: '0.9rem', color: '#374151', margin: '0 0 12px' }}>
Change Password <span style={{ fontWeight: 400, color: '#94a3b8' }}>(leave blank to keep current)</span>
</p>
<div className="form-group">
<label>Current Password</label>
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Required to change password"
/>
</div>
<div className="form-group">
<label>New Password</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
/>
</div>
<div className="form-group">
<label>Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
</div>
<button className="btn btn-primary" type="submit" disabled={loading} style={{ marginTop: 8 }}>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</form>
</div>
</div>
)
}

View file

@ -57,12 +57,6 @@ export default function CategoriesPage() {
const [newParent, setNewParent] = useState('')
const [busy, setBusy] = useState(false)
// Attaching questions to whichever entry is open for it.
const [attaching, setAttaching] = useState(null)
const [attachQuery, setAttachQuery] = useState('')
const [attachResults, setAttachResults] = useState([])
const [attachPicked, setAttachPicked] = useState([])
const [attachSearching, setAttachSearching] = useState(false)
const facet = FACETS.find(f => f.key === facetKey) || FACETS[0]
@ -90,7 +84,7 @@ export default function CategoriesPage() {
// search does not follow you onto a different axis.
useEffect(() => {
setQuery(''); setExpanded({}); setEditing(null); setDeleting(null)
setCreating(false); setAttaching(null); setError(''); setNotice('')
setCreating(false); setError(''); setNotice('')
}, [facetKey])
/** Rows for the open facet, normalised to one shape whichever table they came from. */
@ -173,7 +167,7 @@ export default function CategoriesPage() {
const endpoint = facet.source === 'category' ? '/question-categories' : '/tags'
const startEdit = (row) => {
setDeleting(null); setAttaching(null)
setDeleting(null)
setEditing(row.id); setDraftName(row.name); setDraftParent(row.parent_id ?? '')
}
@ -221,37 +215,6 @@ export default function CategoriesPage() {
finally { setBusy(false) }
}
const startAttach = (row) => {
setEditing(null); setDeleting(null)
setAttaching(row.id); setAttachQuery(''); setAttachResults([]); setAttachPicked([])
}
const searchQuestions = async () => {
if (!attachQuery.trim()) return
setAttachSearching(true); setError('')
try {
const res = await api.get('/questions/bank', { params: { q: attachQuery.trim(), limit: 25 } })
setAttachResults(res.data?.questions || res.data?.items || [])
} catch (err) { setError(apiError(err, 'Could not search questions')) }
finally { setAttachSearching(false) }
}
const attach = async (row) => {
if (!attachPicked.length) return
setBusy(true); setError('')
try {
if (facet.source === 'category') {
await api.post('/questions/bulk-category',
{ question_ids: attachPicked, category_id: row.id })
} else {
await api.post(`/tags/${row.id}/questions`, { question_ids: attachPicked })
}
setNotice(`Added ${attachPicked.length} question${attachPicked.length === 1 ? '' : 's'} to “${row.name}”.`)
setAttaching(null); setAttachPicked([]); setAttachResults([])
load()
} catch (err) { setError(apiError(err, 'Could not attach those questions')) }
finally { setBusy(false) }
}
// Deleting a category with subcategories is refused server-side; tags instead
// pull their children up a level, so only one facet needs the guard.
@ -268,11 +231,9 @@ export default function CategoriesPage() {
<>
<button className="btn btn-secondary btn-sm"
aria-label={`Edit ${row.name}`} onClick={() => startEdit(row)}>Edit</button>
<button className="btn btn-secondary btn-sm"
aria-label={`Add questions to ${row.name}`} onClick={() => startAttach(row)}>+ Questions</button>
<button className="btn btn-secondary btn-sm"
aria-label={`Delete ${row.name}`}
onClick={() => { setEditing(null); setAttaching(null); setDeleting(row.id); setMoveTo('') }}>Delete</button>
onClick={() => { setEditing(null); setDeleting(row.id); setMoveTo('') }}>Delete</button>
</>
)}
</span>
@ -309,38 +270,6 @@ export default function CategoriesPage() {
</div>
)}
{attaching === row.id && (
<div className="cat-attach">
<div className="cat-attach-search">
<input value={attachQuery} autoFocus placeholder="Search questions…"
aria-label={`Search questions to add to ${row.name}`}
onChange={e => setAttachQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') searchQuestions() }} />
<button className="btn btn-secondary btn-sm" disabled={attachSearching}
onClick={searchQuestions}>{attachSearching ? 'Searching…' : 'Search'}</button>
<button className="btn btn-secondary btn-sm" onClick={() => setAttaching(null)}>Cancel</button>
</div>
{attachResults.length > 0 && (
<ul className="cat-attach-list">
{attachResults.map(q => (
<li key={q.id}>
<label>
<input type="checkbox" checked={attachPicked.includes(q.id)}
onChange={e => setAttachPicked(prev =>
e.target.checked ? [...prev, q.id] : prev.filter(id => id !== q.id))} />
<span>{(q.question_text || '').slice(0, 140)}</span>
</label>
</li>
))}
</ul>
)}
{attachPicked.length > 0 && (
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => attach(row)}>
Add {attachPicked.length} question{attachPicked.length === 1 ? '' : 's'}
</button>
)}
</div>
)}
</>
)

View file

@ -213,38 +213,11 @@ it('deletes a tag, saying its children rise rather than refusing', async () => {
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/tags/21', { params: {} }))
})
it('attaches searched questions to a tag', async () => {
it('does not search the question bank from here', async () => {
mount()
await screen.findByText('Root')
await openFacet('Diseases')
api.get.mockResolvedValueOnce({ data: { questions: [
{ id: 5, question_text: 'A 4-year-old with wheeze…' },
{ id: 6, question_text: 'An infant with stridor…' },
] } })
api.post.mockResolvedValue({ data: { added: 1 } })
await userEvent.click(await screen.findByRole('button', { name: 'Add questions to Asthma' }))
await userEvent.type(screen.getByLabelText('Search questions to add to Asthma'), 'wheeze')
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await userEvent.click(await screen.findByRole('checkbox', { name: /wheeze/ }))
await userEvent.click(screen.getByRole('button', { name: 'Add 1 question' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tags/30/questions', { question_ids: [5] }))
})
it('files questions into a topic through the category endpoint instead', async () => {
mount()
await screen.findByText('Root')
api.get.mockResolvedValueOnce({ data: { questions: [{ id: 5, question_text: 'A newborn…' }] } })
api.post.mockResolvedValue({ data: { updated: 1 } })
await userEvent.click(screen.getByRole('button', { name: 'Add questions to Root' }))
await userEvent.type(screen.getByLabelText('Search questions to add to Root'), 'newborn')
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await userEvent.click(await screen.findByRole('checkbox', { name: /newborn/ }))
await userEvent.click(screen.getByRole('button', { name: 'Add 1 question' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/questions/bulk-category',
{ question_ids: [5], category_id: 1 }))
// Filing a question is done where the question is. Searching the whole
// bank from a taxonomy row was a second, worse question bank.
expect(screen.queryByRole('button', { name: /Add questions to/ })).toBeNull()
expect(screen.queryByPlaceholderText('Search questions…')).toBeNull()
})

View file

@ -123,7 +123,7 @@ function StudySection() {
return (
<Section title="Studying for"
description="Scopes the question bank, the filters and your performance analysis. Material not linked to any exam always stays visible.">
<ExamSwitcher />
<ExamSwitcher inline />
</Section>
)
}
@ -461,7 +461,6 @@ export default function SettingsPage() {
<div className="set-layout">
<div className="set-head">
<h1>Settings</h1>
<p>Your account, how the site looks, and if it is yours to set how the site behaves.</p>
</div>
<nav className="set-nav" aria-label="Settings sections">

View file

@ -82,9 +82,11 @@ describe('settings', () => {
it('carries the exam objective, which was only reachable from the navbar', async () => {
mount('/settings?s=study')
expect(await screen.findByRole('heading', { name: 'Studying for' })).toBeInTheDocument()
// The control names the current objective rather than being a <select>
// capped at 220px that clipped "Pediatrics Boards (2948)" mid-word.
expect(await screen.findByRole('button', { name: /Boards/ })).toBeInTheDocument()
// Stated plainly rather than as the navbar's pill: the panel clips its
// own overflow, so a pop-out menu here would be invisible.
expect(await screen.findByText('Boards')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Change' })).toBeInTheDocument()
expect(screen.queryByRole('menu')).toBeNull()
})
it('asks for a typed word before wiping practice data, and says what survives', async () => {