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:
parent
91ec6a501c
commit
271f0c9054
3 changed files with 100 additions and 40 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' } })
|
||||
|
|
|
|||
Loading…
Reference in a new issue