+ Scopes the question bank, the filters and your performance analysis.
+ Material linked to no exam stays visible whichever you pick.
+
+
+ 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}
+
+ {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 (
+
+ )
+ })}
+
+
+ ))}
+ {families.length === 0 &&
Nothing matches that.
}
+
+
+ {error &&
{error}
}
+
+
+
+
+
+
+ ) : 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 (
+
- Scopes the question bank, the filters and your performance analysis.
- Material linked to no exam stays visible whichever you pick.
-
-
- 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}
-
- {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 (
-
- )
- })}
-
-
- ))}
- {families.length === 0 &&
Nothing matches that.
}
-
-
- {error &&
{error}
}
-
-
-
-
-
-
- )}
+ {dialog}
)
}
diff --git a/frontend/src/components/ExamSwitcher.test.jsx b/frontend/src/components/ExamSwitcher.test.jsx
index 76df576..0874428 100644
--- a/frontend/src/components/ExamSwitcher.test.jsx
+++ b/frontend/src/components/ExamSwitcher.test.jsx
@@ -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()
+ 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' }))
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 497f45c..41632b4 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -164,8 +164,14 @@ function AccountMenu({ user, onLogout }) {
{user?.email}
setOpen(false)}>Dashboard
- setOpen(false)}>Account
+ {/* Settings opens on the account, so a separate Account entry was
+ two doors to the same room. */}
setOpen(false)}>Settings
+ {user?.role === 'admin' && (
+ setOpen(false)}>
+ Administration
+
+ )}
diff --git a/frontend/src/components/Navbar.test.jsx b/frontend/src/components/Navbar.test.jsx
index 56b0135..328eb9a 100644
--- a/frontend/src/components/Navbar.test.jsx
+++ b/frontend/src/components/Navbar.test.jsx
@@ -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()
})
diff --git a/frontend/src/components/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx
index d3fb007..6801483 100644
--- a/frontend/src/components/SiteFooter.jsx
+++ b/frontend/src/components/SiteFooter.jsx
@@ -38,7 +38,6 @@ const COLUMNS = [
links: [
{ to: '/home', label: 'About' },
{ to: '/home#contact', label: 'Contact' },
- { to: '/account', label: 'Account' },
{ to: '/settings', label: 'Settings' },
],
},
diff --git a/frontend/src/components/SiteFooter.test.jsx b/frontend/src/components/SiteFooter.test.jsx
index 67f1e67..ccc569e 100644
--- a/frontend/src/components/SiteFooter.test.jsx
+++ b/frontend/src/components/SiteFooter.test.jsx
@@ -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', () => {
diff --git a/frontend/src/pages/AccountPage.jsx b/frontend/src/pages/AccountPage.jsx
deleted file mode 100644
index 72b235f..0000000
--- a/frontend/src/pages/AccountPage.jsx
+++ /dev/null
@@ -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 (
-
-
-
My Account
-
- {user?.email} ·
- {user?.role}
-
-
-
- {error &&
{error}
}
- {success &&
{success}
}
-
-
-
-
-
- )
-}
diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx
index c7cf1e4..b2c34fe 100644
--- a/frontend/src/pages/CategoriesPage.jsx
+++ b/frontend/src/pages/CategoriesPage.jsx
@@ -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() {
<>
-
+ onClick={() => { setEditing(null); setDeleting(row.id); setMoveTo('') }}>Delete
>
)}
@@ -309,38 +270,6 @@ export default function CategoriesPage() {
)}
- {attaching === row.id && (
-