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-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 form > button { margin: 8px 8px 0 0; }
|
||||||
.custom-test [role=alert] { color: var(--wrong-fg); }
|
.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,26 +67,63 @@ export default function CustomQuizPage() {
|
||||||
} finally { setSubmitting(false) }
|
} 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 (
|
return (
|
||||||
<div className="custom-test">
|
<div className="custom-test">
|
||||||
<Link to="/quizzes">← Quizzes</Link>
|
<Link to="/quizzes">← Quizzes</Link>
|
||||||
<h1>Create Custom Test</h1>
|
<h1>Create Custom Test</h1>
|
||||||
<p>Choose questions from your bank, {user?.name || 'learner'}.</p>
|
<p>Choose questions from your bank, {user?.name || 'learner'}.</p>
|
||||||
<form onSubmit={submit} className="card">
|
<form onSubmit={submit} className="custom-test-layout" noValidate>
|
||||||
<fieldset disabled={submitting}>
|
<aside className="custom-test-filters card">
|
||||||
<legend>Categories</legend>
|
<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>
|
<p>Parent categories include all their subcategories.</p>
|
||||||
<div className="custom-test-categories">
|
<input type="search" value={catSearch} onChange={e => setCatSearch(e.target.value)}
|
||||||
{categories.map(cat => (
|
placeholder="Search topics…" aria-label="Search topics" className="input" />
|
||||||
<label key={cat.id}>
|
{renderTree(0)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
|
||||||
</fieldset>
|
</div>
|
||||||
<div className="custom-test-settings">
|
</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>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)}>
|
<label>Question state<select value={state} onChange={e => setState(e.target.value)}>
|
||||||
<option value="all">All</option><option value="unused">Unused</option>
|
<option value="all">All</option><option value="unused">Unused</option>
|
||||||
|
|
@ -113,6 +150,7 @@ export default function CustomQuizPage() {
|
||||||
<button type="button" className="btn btn-secondary" disabled={submitting} onClick={() => setRefresh(v => v + 1)}>Refresh count</button>
|
<button type="button" className="btn btn-secondary" disabled={submitting} onClick={() => setRefresh(v => v + 1)}>Refresh count</button>
|
||||||
{error && <p role="alert">{error}</p>}
|
{error && <p role="alert">{error}</p>}
|
||||||
<button className="btn btn-primary" type="submit" disabled={submitting || !ready || !validCount || !title.trim()}>{submitting ? 'Creating…' : 'Create Test'}</button>
|
<button className="btn btn-primary" type="submit" disabled={submitting || !ready || !validCount || !title.trim()}>{submitting ? 'Creating…' : 'Create Test'}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ describe('CustomQuizPage', () => {
|
||||||
expect(screen.getByLabelText(/Share with/)).not.toBeChecked()
|
expect(screen.getByLabelText(/Share with/)).not.toBeChecked()
|
||||||
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
|
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
|
||||||
await userEvent.click(screen.getByLabelText('Pediatrics (30)'))
|
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('Question state'), 'incorrect')
|
||||||
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
|
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
|
||||||
fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })
|
fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue