feat: AMBOSS-style topic picker for custom tests

Systems live in a collapsible side box with search and drill-down subcategories; filters apply live. Mobile collapses the panel. 97 frontend tests pass.
This commit is contained in:
Daniel 2026-09-09 03:06:24 +02:00
parent 91ec6a501c
commit 271f0c9054
3 changed files with 100 additions and 40 deletions

View file

@ -10,3 +10,25 @@
.custom-test-settings input, .custom-test-settings select { width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--input-bg); color: var(--text); }
.custom-test form > button { margin: 8px 8px 0 0; }
.custom-test [role=alert] { color: var(--wrong-fg); }
.custom-test-layout { display: grid; grid-template-columns: 300px 1fr; gap: 16px; align-items: start; border: none; background: none; box-shadow: none; padding: 0; }
.custom-test-filters { padding: 14px; }
.custom-test-filters h2 { margin: 0 0 4px; font-size: 1rem; }
.custom-test-filters p { color: var(--text-muted); font-size: .78rem; margin: 0 0 10px; }
.custom-test-filters-toggle { display: none; }
.custom-test-tree { list-style: none; margin: 8px 0; padding: 0; max-height: 60vh; overflow-y: auto; }
.custom-test-tree ul { list-style: none; margin: 0 0 0 16px; padding: 0; }
.custom-test-tree li { margin: 2px 0; }
.custom-test-tree label { display: flex; gap: 6px; align-items: baseline; font-size: .84rem; cursor: pointer; }
.custom-test-tree summary { cursor: pointer; list-style: none; }
.custom-test-tree summary::-webkit-details-marker { display: none; }
.custom-test-branch::before { content: '▸ '; font-size: .7rem; color: var(--text-muted); }
details[open] > .custom-test-branch::before { content: '▾ '; }
.custom-test-main { display: flex; flex-direction: column; gap: 10px; }
.custom-test-settings { display: flex; flex-direction: column; gap: 10px; padding: 16px; }
.custom-test-settings label { display: flex; flex-direction: column; gap: 4px; font-size: .84rem; }
@media (max-width: 760px) {
.custom-test-layout { grid-template-columns: 1fr; }
.custom-test-filters-toggle { display: inline-block; margin-bottom: 8px; }
.custom-test-filters-body { display: none; }
.custom-test-filters-body.open { display: block; }
}

View file

@ -67,52 +67,90 @@ export default function CustomQuizPage() {
} finally { setSubmitting(false) }
}
const [catSearch, setCatSearch] = useState('')
const [filtersOpen, setFiltersOpen] = useState(true)
const childrenOf = {}
for (const cat of categories) {
;(childrenOf[cat.parent_id || 0] ||= []).push(cat)
}
const visibleCategories = categories.filter(cat =>
[cat.name, ...(cat.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(catSearch.toLowerCase()))
const descendantSelected = (cat) => {
const ids = []
const walk = (id) => { for (const child of childrenOf[id] || []) { ids.push(child.id); walk(child.id) } }
walk(cat.id)
return ids.some(id => categoryIds.includes(id))
}
const renderTree = (parentId) => {
const branch = (childrenOf[parentId] || []).filter(cat => visibleCategories.includes(cat))
if (!branch.length) return null
return <ul className="custom-test-tree">
{branch.map(cat => {
const kids = (childrenOf[cat.id] || []).filter(child => visibleCategories.includes(child))
const node = (
<label>
<input type="checkbox" checked={categoryIds.includes(cat.id)} onChange={e => setCategoryIds(ids => e.target.checked ? [...ids, cat.id] : ids.filter(id => id !== cat.id))} />
{cat.name} ({cat.question_count})
</label>
)
if (!kids.length) return <li key={cat.id}>{node}</li>
return <li key={cat.id}>
<details open={!!catSearch || categoryIds.includes(cat.id) || descendantSelected(cat)}>
<summary className="custom-test-branch">{node}</summary>
{renderTree(cat.id)}
</details>
</li>
})}
</ul>
}
return (
<div className="custom-test">
<Link to="/quizzes"> Quizzes</Link>
<h1>Create Custom Test</h1>
<p>Choose questions from your bank, {user?.name || 'learner'}.</p>
<form onSubmit={submit} className="card">
<fieldset disabled={submitting}>
<legend>Categories</legend>
<p>Parent categories include all their subcategories.</p>
<div className="custom-test-categories">
{categories.map(cat => (
<label key={cat.id}>
<input type="checkbox" checked={categoryIds.includes(cat.id)} onChange={e => setCategoryIds(ids => e.target.checked ? [...ids, cat.id] : ids.filter(id => id !== cat.id))} />
{(cat.breadcrumbs || []).map(c => c.name).join(' ') || cat.name} ({cat.question_count})
</label>
))}
<form onSubmit={submit} className="custom-test-layout" noValidate>
<aside className="custom-test-filters card">
<button type="button" className="custom-test-filters-toggle" aria-expanded={filtersOpen}
onClick={() => setFiltersOpen(v => !v)}>{filtersOpen ? '✕ Hide filters' : '☰ Filters'}</button>
<div className={`custom-test-filters-body ${filtersOpen ? 'open' : ''}`}>
<h2>Topics</h2>
<p>Parent categories include all their subcategories.</p>
<input type="search" value={catSearch} onChange={e => setCatSearch(e.target.value)}
placeholder="Search topics…" aria-label="Search topics" className="input" />
{renderTree(0)}
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
</div>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
</fieldset>
<div className="custom-test-settings">
<label>Title<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} /></label>
<label>Question state<select value={state} onChange={e => setState(e.target.value)}>
<option value="all">All</option><option value="unused">Unused</option>
<option value="incorrect">Incorrect</option><option value="bookmarked">Bookmarked</option>
</select></label>
<label>Difficulty<select value={difficulty} onChange={e => setDifficulty(e.target.value)}>
<option value="">Any</option><option value="easy">Easy</option>
<option value="medium">Medium</option><option value="hard">Hard</option>
</select></label>
<label className="custom-test-adaptive"><input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} /> Adaptive session</label>
{adaptive && <p className="custom-test-adaptive-note">Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.</p>}
<label>Number of questions<input type="number" required min="1" max="200" step="1" value={count} onChange={e => setCount(e.target.value)} /></label>
<label>Mode<select value={mode} onChange={e => setMode(e.target.value)}>
<option value="learning">Study</option><option value="timed">Exam</option>
</select></label>
{mode === 'timed' && <label>Time limit (minutes, optional)<input type="number" min="1" step="1" value={time} onChange={e => setTime(e.target.value)} /></label>}
</aside>
<div className="custom-test-main">
<div className="custom-test-settings card">
<label>Title<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} /></label>
<label>Question state<select value={state} onChange={e => setState(e.target.value)}>
<option value="all">All</option><option value="unused">Unused</option>
<option value="incorrect">Incorrect</option><option value="bookmarked">Bookmarked</option>
</select></label>
<label>Difficulty<select value={difficulty} onChange={e => setDifficulty(e.target.value)}>
<option value="">Any</option><option value="easy">Easy</option>
<option value="medium">Medium</option><option value="hard">Hard</option>
</select></label>
<label className="custom-test-adaptive"><input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} /> Adaptive session</label>
{adaptive && <p className="custom-test-adaptive-note">Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.</p>}
<label>Number of questions<input type="number" required min="1" max="200" step="1" value={count} onChange={e => setCount(e.target.value)} /></label>
<label>Mode<select value={mode} onChange={e => setMode(e.target.value)}>
<option value="learning">Study</option><option value="timed">Exam</option>
</select></label>
{mode === 'timed' && <label>Time limit (minutes, optional)<input type="number" min="1" step="1" value={time} onChange={e => setTime(e.target.value)} /></label>}
</div>
<p>Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, including skipped questions.</p>
<label className="custom-test-share"><input type="checkbox" checked={shared} onChange={e => setShared(e.target.checked)} /> Share with other learners (only shareable questions)</label>
<p role="status" aria-live="polite">{ready ? `${available} questions available` : 'Counting available questions…'}</p>
{ready && available === 0 && <p>No questions match these filters.</p>}
{ready && !validCount && available > 0 && <p>Choose 1{Math.min(200, available)} questions.</p>}
{countError && <p role="alert">{countError}</p>}
<button type="button" className="btn btn-secondary" disabled={submitting} onClick={() => setRefresh(v => v + 1)}>Refresh count</button>
{error && <p role="alert">{error}</p>}
<button className="btn btn-primary" type="submit" disabled={submitting || !ready || !validCount || !title.trim()}>{submitting ? 'Creating…' : 'Create Test'}</button>
</div>
<p>Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, including skipped questions.</p>
<label className="custom-test-share"><input type="checkbox" checked={shared} onChange={e => setShared(e.target.checked)} /> Share with other learners (only shareable questions)</label>
<p role="status" aria-live="polite">{ready ? `${available} questions available` : 'Counting available questions…'}</p>
{ready && available === 0 && <p>No questions match these filters.</p>}
{ready && !validCount && available > 0 && <p>Choose 1{Math.min(200, available)} questions.</p>}
{countError && <p role="alert">{countError}</p>}
<button type="button" className="btn btn-secondary" disabled={submitting} onClick={() => setRefresh(v => v + 1)}>Refresh count</button>
{error && <p role="alert">{error}</p>}
<button className="btn btn-primary" type="submit" disabled={submitting || !ready || !validCount || !title.trim()}>{submitting ? 'Creating…' : 'Create Test'}</button>
</form>
</div>
)

View file

@ -33,7 +33,7 @@ describe('CustomQuizPage', () => {
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('Pediatrics Neonatal (10)'))
await userEvent.click(screen.getByLabelText('Neonatal (10)'))
await userEvent.selectOptions(screen.getByLabelText('Question state'), 'incorrect')
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })