-
-
- {adaptive &&
Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.
}
-
-
- {mode === 'timed' &&
}
+
+
Filter search
+
+ 🔍
+ setGlobalSearch(e.target.value)}
+ placeholder="E.g. systems, disciplines, keywords" aria-label="Filter search" />
-
Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, including skipped questions.
-
-
{ready ? `${available} questions available` : 'Counting available questions…'}
- {ready && available === 0 &&
No questions match these filters.
}
- {ready && !validCount && available > 0 &&
Choose 1–{Math.min(200, available)} questions.
}
- {countError &&
{countError}
}
-
-
-
+
+ {hits !== null && (
+ hits.length === 0
+ ?
Nothing matches “{globalSearch}”.
+ : (
+
+ {hits.map(hit => (
+
+ ))}
+
+ )
+ )}
+
+
+
+ Exams
+ Pediatrics Boards
+
+
setOpenFacet('systems')} />
+ setOpenFacet('disciplines')} />
+ setOpenFacet('symptoms')} />
+ setOpenFacet('articles')} />
+ setOpenFacet('saved')} />
- {error &&
{error}
}
+
+
+ {/* ── Criteria ───────────────────────────────────────────── */}
+
+
+ {/* ── Count and type ─────────────────────────────────────── */}
+
+ Question count
+
+ setCount(e.target.value)} />
+
+ {ready ? `${available} questions available` : 'Counting available questions…'}
+
+
+ {ready && available === 0 && No questions match these filters.
}
+ {ready && !validCount && available > 0 && Choose 1–{Math.min(200, available)} questions.
}
+ {countError && {countError}
}
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+ {/* ── Facet pickers ────────────────────────────────────────── */}
+
setOpenFacet(null)}
+ onReset={() => setCategoryIds([])}
+ helper="By default, all systems are included unless filters are selected. A parent includes its subcategories.">
+ {query => renderTree(0, query) || Nothing matches that search.
}
+
+
+
setOpenFacet(null)}
+ onReset={() => setTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))}
+ helper="By default, all disciplines are included unless filters are selected.">
+ {query => checkList(subjectTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)}
+
+
+
setOpenFacet(null)}
+ onReset={() => setTagIds(ids => ids.filter(id => !keywordTags.some(t => t.id === id)))}
+ helper="By default, all keywords are included unless filters are selected.">
+ {query => checkList(keywordTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)}
+
+
+
setOpenFacet(null)}
+ onReset={() => setArticleIds([])}
+ helper="Pick the reading whose linked questions you want.">
+ {query => articles.length === 0
+ ? No articles yet.
+ : checkList(articles, a => articleIds.includes(a.id), (a, on) => toggleArticle(a.id, on), query, a => a.title)}
+
+
+
setOpenFacet(null)}
+ onReset={() => { setPresetIds([]); if (state === 'bookmarked') setState('all') }}
+ helper="Your bookmarks and personal libraries.">
+ {query => (
+ <>
+ {'bookmarked questions'.includes(query) && (
+
+ )}
+ {checkList(collections, c => presetIds.includes(c.id), c => togglePreset(c), query,
+ c => c.title, c => c.question_count)}
+ >
+ )}
+
+
+
setOpenFacet(null)}
+ onReset={() => setDifficulty('')} helper="Applies to every question in the test.">
+ {() => ['', 'easy', 'medium', 'hard'].map(value => (
+
+ ))}
+
+
+
setOpenFacet(null)}
+ onReset={() => setState('all')} helper="Filter by how you have answered these questions before.">
+ {() => ['all', 'unused', 'incorrect', 'bookmarked'].map(value => (
+
+ ))}
+
)
}
diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx
index 8f54a8c..6723fe8 100644
--- a/frontend/src/pages/CustomQuizPage.test.jsx
+++ b/frontend/src/pages/CustomQuizPage.test.jsx
@@ -12,6 +12,7 @@ const categories = [
{ id: 1, name: 'Pediatrics', question_count: 30, breadcrumbs: [{ id: 1, name: 'Pediatrics' }] },
{ id: 2, name: 'Neonatal', question_count: 10, breadcrumbs: [{ id: 1, name: 'Pediatrics' }, { id: 2, name: 'Neonatal' }] },
]
+const TAGS = { subjects: [{ id: 7, name: 'Cardiology' }], keywords: [{ id: 8, name: 'PREP 2019' }] }
function setupCount(count = 30) {
api.get.mockImplementation(url => {
if (url === '/question-categories/') return Promise.resolve({ data: categories })
@@ -33,13 +34,21 @@ describe('CustomQuizPage', () => {
api.post.mockResolvedValue({ data: { id: 123 } })
renderBuilder()
await screen.findByText('30 questions available')
- expect(screen.getByLabelText('Mode')).toHaveValue('learning')
+ expect(screen.getByRole('radio', { name: 'Study mode' })).toBeChecked()
expect(screen.getByLabelText(/Share with/)).not.toBeChecked()
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
- await userEvent.click(screen.getByLabelText('Pediatrics (30)'))
- await userEvent.click(screen.getByLabelText('Neonatal (10)'))
- await userEvent.click(screen.getByRole('button', { name: 'Unused' }))
- await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
+
+ // Systems and Status are chosen inside their facet pickers, not inline.
+ await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
+ await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
+ await userEvent.click(screen.getByRole('checkbox', { name: /Neonatal/ }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
+
+ await userEvent.click(screen.getByRole('button', { name: /^Status/ }))
+ await userEvent.click(await screen.findByRole('radio', { name: 'Unused' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Exam mode' }))
fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })
fireEvent.change(screen.getByLabelText('Number of questions'), { target: { value: '10' } })
await userEvent.click(screen.getByLabelText(/Share with/))
@@ -89,14 +98,75 @@ describe('CustomQuizPage', () => {
expect(screen.queryByRole('heading', { name: 'Saved test' })).not.toBeInTheDocument()
})
+ it('summarises each facet as All, a name, or a name with +N', async () => {
+ renderBuilder()
+ const systems = await screen.findByRole('button', { name: /^Systems/ })
+ expect(systems).toHaveTextContent('All')
+
+ await userEvent.click(systems)
+ await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
+ expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
+ expect(screen.queryByText('+1')).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
+ await userEvent.click(await screen.findByRole('checkbox', { name: /Neonatal/ }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
+ expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('+1')
+ })
+
+ it('closes a picker without losing the selection and resets it on demand', async () => {
+ renderBuilder()
+ await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
+ await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
+ await userEvent.click(screen.getByRole('button', { name: 'Close Systems' }))
+ expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
+
+ await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
+ await userEvent.click(screen.getByRole('button', { name: 'Reset' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
+ expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
+ })
+
+ it('searches across facets and toggles a hit in place', async () => {
+ api.get.mockImplementation(url => {
+ if (url === '/question-categories/') return Promise.resolve({ data: categories })
+ if (url.startsWith('/tags')) return Promise.resolve({ data: TAGS })
+ if (url.startsWith('/articles') || url.startsWith('/collections')) return Promise.resolve({ data: [] })
+ return Promise.resolve({ data: { count: 30 } })
+ })
+ renderBuilder()
+ await screen.findByText('30 questions available')
+
+ await userEvent.type(screen.getByLabelText('Filter search'), 'cardio')
+ const hit = await screen.findByRole('checkbox', { name: /Cardiology/ })
+ await userEvent.click(hit)
+ expect(screen.getByRole('button', { name: /^Disciplines/ })).toHaveTextContent('Cardiology')
+
+ await userEvent.clear(screen.getByLabelText('Filter search'))
+ await userEvent.type(screen.getByLabelText('Filter search'), 'zzzz')
+ expect(await screen.findByText(/Nothing matches/)).toBeInTheDocument()
+ })
+
+ it('resets every topic facet at once', async () => {
+ renderBuilder()
+ await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
+ await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Reset all topics' }))
+ expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
+ })
+
it('ignores outdated count responses after filters change', async () => {
let resolveOld
api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: categories }) : new Promise(resolve => { resolveOld = resolve }))
renderBuilder()
- await screen.findByLabelText('Pediatrics (30)')
+ await screen.findByRole('button', { name: /^Status/ })
const old = resolveOld
setupCount(4)
- await userEvent.click(screen.getByRole('button', { name: 'Saved' }))
+ await userEvent.click(screen.getByRole('button', { name: /^Status/ }))
+ await userEvent.click(await screen.findByRole('radio', { name: 'Saved' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
await screen.findByText('4 questions available')
old({ data: { count: 100 } })
await waitFor(() => expect(screen.queryByText('100 questions available')).not.toBeInTheDocument())