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:
parent
0d4042169a
commit
6b6c5e1b49
13 changed files with 177 additions and 312 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ const COLUMNS = [
|
|||
links: [
|
||||
{ to: '/home', label: 'About' },
|
||||
{ to: '/home#contact', label: 'Contact' },
|
||||
{ to: '/account', label: 'Account' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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} ·
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue