fix: rebuild the custom test builder as AMBOSS facet rows

Interacted with AMBOSS's custom session builder to copy the actual pattern.
It never shows facet options inline: each facet is one row carrying its current
selection, and clicking it opens a panel with a search box, the helper line
"By default, all X are included unless filters are selected", an "Include
questions from:" checklist, and Reset / Done. A row with several selections
reads "Cardiovascular System +1", and the available count updates live.

Ours had the opposite: a narrow sidebar of oversized stacked headings with two
separate inner scroll panes, so Disciplines and Symptoms each showed their own
scrollbar and the whole column fought the form beside it.

- New FacetPicker + FacetRow components: slide-in panel on desktop, bottom
  sheet on mobile, Escape and backdrop close, per-facet search and reset.
- CustomQuizPage is now a single 720px column: Set test topics (filter search +
  Exams / Systems / Disciplines / Symptoms / Articles / Saved rows), Test
  criteria (title, adaptive toggle, Difficulty and Status rows, sharing),
  Question count with the live pool, and Test type as two radio cards.
- One cross-facet "Filter search" lists matching options from every facet and
  toggles them in place, matching AMBOSS's search-first entry point.
- Difficulty and Status moved out of the sidebar into their own pickers; mode
  is radio cards rather than a select.

The builder payload and the /questions/builder contract are unchanged.

Tests: 4 new frontend tests (facet summary including +N, close-keeps-selection
and per-facet reset, cross-facet search toggling, reset-all-topics); the
existing builder tests now drive the pickers. Full suites green: 88 backend,
120 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
This commit is contained in:
Daniel 2026-09-09 18:59:27 +02:00
parent 145c1802c6
commit 075fd3a648
4 changed files with 619 additions and 212 deletions

View file

@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
/** Collapsed facet row: label on the left, current selection on the right. */
export function FacetRow({ label, summary, extra, onOpen }) {
return (
<button type="button" className="facet-row" onClick={onOpen}>
<span className="facet-row-label">{label}</span>
<span className="facet-row-summary">
<span className="facet-row-value">{summary}</span>
{extra > 0 && <span className="facet-row-more">+{extra}</span>}
</span>
<span className="facet-row-chevron" aria-hidden="true"></span>
</button>
)
}
/**
* Panel for choosing inside one facet: search, the options, then Reset / Done.
* `children` is called with the current search query so each facet decides how
* to filter its own options.
*/
export default function FacetPicker({ title, helper, open, onClose, onReset, searchLabel, children }) {
const [query, setQuery] = useState('')
useEffect(() => {
if (!open) return
setQuery('')
const onKey = e => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div className="facet-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="facet-panel" role="dialog" aria-modal="true" aria-label={title}>
<div className="facet-panel-head">
<h2>{title}</h2>
<button type="button" onClick={onClose} aria-label={`Close ${title}`}></button>
</div>
<div className="facet-panel-search">
<input type="search" value={query} onChange={e => setQuery(e.target.value)}
placeholder="Search" aria-label={searchLabel || `Search ${title}`} />
</div>
{helper && <p className="facet-panel-helper">{helper}</p>}
<p className="facet-panel-legend">Include questions from:</p>
<div className="facet-panel-body">{children(query.trim().toLowerCase())}</div>
<div className="facet-panel-foot">
{onReset && <button type="button" className="btn btn-secondary btn-sm" onClick={onReset}>Reset</button>}
<button type="button" className="btn btn-primary btn-sm" onClick={onClose}>Done</button>
</div>
</div>
</div>
)
}

View file

@ -1,57 +1,162 @@
.custom-test { max-width: 960px; margin: auto; } /* Custom test builder AMBOSS-style facet rows that open a picker panel,
.custom-test h1 { margin: 16px 0; } instead of a cramped sidebar of nested scrolling checkbox lists. */
.custom-test fieldset { border: 1px solid var(--border); border-radius: 8px; padding: 16px; min-width: 0; }
.custom-test p { margin: 12px 0; } .custom-test { max-width: 720px; margin: 0 auto; padding-bottom: 40px; }
.custom-test-categories { display: grid; gap: 10px; max-height: 320px; overflow: auto; margin: 12px 0; } .custom-test-back { font-size: 0.85rem; color: var(--primary); text-decoration: none; }
.custom-test-categories label, .custom-test-share { display: flex; align-items: baseline; gap: 8px; } .custom-test h1 { margin: 10px 0 4px; font-size: 1.5rem; }
.custom-test input[type=checkbox] { width: auto; flex-shrink: 0; } .custom-test-intro { margin: 0 0 20px; color: var(--text-muted); font-size: 0.9rem; }
.custom-test-settings { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
.custom-test-settings label { display: flex; flex-direction: column; gap: 6px; } /* ── Section headings ─────────────────────────────────────────────── */
.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-section { margin-bottom: 22px; }
.custom-test form > button { margin: 8px 8px 0 0; } .custom-test-section-head {
.custom-test [role=alert] { color: var(--wrong-fg); } display: flex; align-items: center; gap: 12px; margin: 0 0 10px;
.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-section-head h2 { margin: 0; font-size: 1rem; font-weight: 650; white-space: nowrap; }
.custom-test-filters h2 { margin: 0 0 4px; font-size: 1rem; } .custom-test-section-head::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.custom-test-filters p { color: var(--text-muted); font-size: .78rem; margin: 0 0 10px; } .custom-test-reset {
.custom-test-filters-toggle { display: none; } background: none; border: none; padding: 0; cursor: pointer; flex-shrink: 0;
.custom-test-tree { list-style: none; margin: 8px 0; padding: 0; max-height: 60vh; overflow-y: auto; } font: inherit; font-size: 0.74rem; font-weight: 700; letter-spacing: 0.06em;
.custom-test-tree ul { list-style: none; margin: 0 0 0 16px; padding: 0; } text-transform: uppercase; color: var(--primary);
.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-label {
.custom-test-tree summary { cursor: pointer; list-style: none; } display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em;
.custom-test-tree summary::-webkit-details-marker { display: none; } text-transform: uppercase; color: var(--text-subtle); margin: 0 0 6px;
.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; } /* ── Global facet search ──────────────────────────────────────────── */
.custom-test-settings { display: flex; flex-direction: column; gap: 10px; padding: 16px; } .custom-test-search { position: relative; margin-bottom: 12px; }
.custom-test-settings label { display: flex; flex-direction: column; gap: 4px; font-size: .84rem; } .custom-test-search input {
@media (max-width: 760px) { width: 100%; padding: 10px 13px 10px 34px; font-size: 0.9rem;
.custom-test-layout { grid-template-columns: 1fr; } border: 1px solid var(--border); border-radius: 8px;
.custom-test-filters-toggle { display: inline-block; margin-bottom: 8px; } background: var(--input-bg); color: var(--text);
.custom-test-filters-body { display: none; } }
.custom-test-filters-body.open { display: block; } .custom-test-search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-subtle); }
.custom-test-hits { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
.custom-test-hits label {
display: flex; align-items: center; gap: 10px; padding: 10px 13px;
font-size: 0.88rem; cursor: pointer; border-bottom: 1px solid var(--border);
}
.custom-test-hits label:last-child { border-bottom: 0; }
.custom-test-hits label:hover { background: var(--bg); }
.custom-test-hit-facet {
margin-left: auto; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em;
text-transform: uppercase; color: var(--text-subtle);
}
.custom-test-hits-empty { padding: 14px; color: var(--text-muted); font-size: 0.86rem; }
/* ── Facet rows ───────────────────────────────────────────────────── */
.facet-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--card-bg); }
.facet-row {
display: flex; align-items: center; gap: 12px; width: 100%;
padding: 13px 15px; background: none; border: none; border-bottom: 1px solid var(--border);
font: inherit; text-align: left; cursor: pointer; color: var(--text);
}
.facet-row:last-child { border-bottom: 0; }
.facet-row:hover { background: var(--bg); }
.facet-row-label { font-size: 0.9rem; font-weight: 600; white-space: nowrap; }
.facet-row-summary { margin-left: auto; display: flex; align-items: center; gap: 6px; min-width: 0; }
.facet-row-value {
font-size: 0.86rem; color: var(--text-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46vw;
}
.facet-row-more {
flex-shrink: 0; font-size: 0.72rem; font-weight: 700;
background: var(--primary); color: var(--primary-fg); border-radius: 20px; padding: 1px 8px;
}
.facet-row-chevron { color: var(--text-subtle); font-size: 1.1rem; line-height: 1; flex-shrink: 0; }
.facet-row.is-fixed { cursor: default; }
.facet-row.is-fixed:hover { background: none; }
/* ── Picker panel ─────────────────────────────────────────────────── */
.facet-overlay {
position: fixed; inset: 0; z-index: 1100;
background: rgba(15, 23, 42, 0.4);
display: flex; justify-content: flex-end;
}
.facet-panel {
background: var(--card-bg); width: min(460px, 94vw); height: 100%;
display: flex; flex-direction: column; box-shadow: -14px 0 44px rgba(0, 0, 0, 0.2);
animation: facet-slide 0.16s ease;
}
@keyframes facet-slide { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
.facet-panel-head {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 14px 18px; border-bottom: 1px solid var(--border);
}
.facet-panel-head h2 { margin: 0; font-size: 1.05rem; }
.facet-panel-head button { background: none; border: none; font-size: 1.1rem; cursor: pointer; color: var(--text-muted); }
.facet-panel-search { padding: 12px 18px 0; }
.facet-panel-search input {
width: 100%; padding: 9px 12px; font-size: 0.88rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text);
}
.facet-panel-helper { margin: 10px 18px 0; color: var(--text-muted); font-size: 0.8rem; line-height: 1.5; }
.facet-panel-legend {
margin: 12px 18px 6px; font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle);
}
.facet-panel-body { flex: 1; overflow-y: auto; padding: 0 18px 14px; }
.facet-panel-body label {
display: flex; align-items: center; gap: 10px;
padding: 9px 2px; font-size: 0.9rem; cursor: pointer;
}
.facet-panel-body label:hover { color: var(--primary); }
.facet-panel-body input[type='checkbox'], .facet-panel-body input[type='radio'] { width: 16px; height: 16px; flex-shrink: 0; }
.facet-panel-count { margin-left: auto; color: var(--text-subtle); font-size: 0.78rem; }
.facet-panel-empty { color: var(--text-muted); font-size: 0.86rem; padding: 10px 0; }
.facet-panel-foot {
display: flex; gap: 8px; justify-content: flex-end;
padding: 12px 18px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
}
.facet-tree { list-style: none; margin: 0; padding: 0; }
.facet-tree .facet-tree { margin-left: 18px; border-left: 1px solid var(--border); padding-left: 8px; }
/* ── Criteria ─────────────────────────────────────────────────────── */
.custom-test-field { display: block; margin-bottom: 14px; }
.custom-test-field > span { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); margin-bottom: 6px; }
.custom-test-field input, .custom-test-field select {
width: 100%; padding: 10px 13px; font-size: 0.95rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text);
}
.custom-test-toggle { display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 10px 0; }
.custom-test-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.custom-test-switch {
width: 38px; height: 22px; border-radius: 999px; background: var(--border);
position: relative; flex-shrink: 0; transition: background 0.15s;
}
.custom-test-switch > span {
position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.15s;
}
.custom-test-toggle input:checked + .custom-test-switch { background: var(--primary); }
.custom-test-toggle input:checked + .custom-test-switch > span { transform: translateX(16px); }
.custom-test-toggle input:focus-visible + .custom-test-switch { outline: 2px solid var(--primary); outline-offset: 2px; }
.custom-test-toggle-text strong { display: block; font-size: 0.92rem; font-weight: 650; }
.custom-test-toggle-text span { font-size: 0.78rem; color: var(--text-muted); }
.custom-test-check { display: flex; align-items: center; gap: 9px; padding: 9px 0; font-size: 0.88rem; cursor: pointer; }
.custom-test-count-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.custom-test-count-row input { width: 110px; }
.custom-test-count-of { color: var(--text-muted); font-size: 0.9rem; }
.custom-test-modes { display: flex; gap: 8px; flex-wrap: wrap; }
.custom-test-modes label {
flex: 1; min-width: 130px; display: flex; align-items: center; gap: 9px;
border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px;
font-size: 0.9rem; cursor: pointer; background: var(--card-bg);
}
.custom-test-modes label:has(input:checked) { border-color: var(--primary); background: var(--option-sel-bg); }
.custom-test-note { color: var(--text-muted); font-size: 0.8rem; line-height: 1.6; margin: 8px 0 0; }
.custom-test-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 10px 0 0; }
.custom-test-actions { display: flex; gap: 8px; margin-top: 18px; }
.custom-test-actions .btn:last-child { flex: 1; }
@media (max-width: 640px) {
.custom-test-modes label { min-width: 100%; }
.facet-overlay { align-items: flex-end; }
.facet-panel { width: 100%; height: 88vh; border-radius: 16px 16px 0 0; }
.facet-row-value { max-width: 40vw; }
.custom-test-actions { flex-direction: column; }
} }
.custom-test-search { position: relative; margin-bottom: 8px; }
.custom-test-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: .8rem; opacity: .6; pointer-events: none; }
.custom-test-search input { width: 100%; padding: 7px 10px 7px 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: .84rem; }
.custom-test-search input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.custom-test-chevron { display: inline-block; width: 8px; height: 8px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); margin-right: 7px; transition: transform .15s ease; vertical-align: middle; }
details[open] > .custom-test-branch .custom-test-chevron { transform: rotate(45deg); }
.custom-test-title-input { font-size: 1.15rem; font-weight: 600; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; }
.custom-test-adaptive { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.custom-test-adaptive input { position: absolute; opacity: 0; width: 0; height: 0; }
.custom-test-switch { width: 38px; height: 21px; border-radius: 999px; background: var(--border); position: relative; transition: background .15s ease; flex-shrink: 0; }
.custom-test-switch span { position: absolute; top: 2px; left: 2px; width: 17px; height: 17px; border-radius: 50%; background: #fff; transition: transform .15s ease; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
.custom-test-adaptive input:checked + .custom-test-switch { background: var(--primary); }
.custom-test-adaptive input:checked + .custom-test-switch span { transform: translateX(17px); }
.custom-test-branch { display: flex; align-items: center; }
.custom-test-main { gap: 0; }
.custom-test-settings > p, .custom-test-settings > .custom-test-share { margin: 0; }
.custom-test-actions { display: flex; gap: 10px; align-items: center; margin-top: 12px; }
.custom-test-actions .btn-primary { flex: 1; background: var(--primary); color: var(--primary-fg); font-weight: 600; padding: 10px 16px; }
.custom-test-main > p { margin: 8px 0 0; }
.custom-test-main > .custom-test-share { margin-top: 8px; }
.custom-test-tags { display: flex; flex-direction: column; gap: 2px; max-height: 24vh; overflow-y: auto; }
.custom-test-tags label { display: flex; gap: 6px; align-items: baseline; font-size: .82rem; cursor: pointer; }
.custom-test-exam { display: flex; gap: 6px; align-items: center; font-size: .84rem; font-weight: 600; color: var(--primary); margin: 4px 0; }

View file

@ -1,9 +1,19 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import FacetPicker, { FacetRow } from '../components/FacetPicker'
import './CustomQuizPage.css' import './CustomQuizPage.css'
const STATE_LABEL = { all: 'All', unused: 'Unused', incorrect: 'Incorrect', bookmarked: 'Saved' }
const DIFFICULTY_LABEL = { '': 'Any', easy: 'Easy', medium: 'Medium', hard: 'Hard' }
/** "All", one name, or the first name with a +N badge — the AMBOSS row summary. */
const summarise = (names) => ({
summary: names.length === 0 ? 'All' : names[0],
extra: Math.max(0, names.length - 1),
})
export default function CustomQuizPage() { export default function CustomQuizPage() {
const { user } = useAuth() const { user } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@ -30,7 +40,17 @@ export default function CustomQuizPage() {
const [countError, setCountError] = useState('') const [countError, setCountError] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [refresh, setRefresh] = useState(0) const [refresh, setRefresh] = useState(0)
const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, refresh])
const [articleIds, setArticleIds] = useState([])
const [tagIds, setTagIds] = useState([])
const [articles, setArticles] = useState([])
const [tags, setTags] = useState({ subjects: [], keywords: [] })
const [presetIds, setPresetIds] = useState([])
const [collections, setCollections] = useState([])
const [openFacet, setOpenFacet] = useState(null)
const [globalSearch, setGlobalSearch] = useState('')
const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, articleIds, tagIds, refresh])
useEffect(() => { useEffect(() => {
let active = true let active = true
@ -39,6 +59,12 @@ export default function CustomQuizPage() {
return () => { active = false } return () => { active = false }
}, []) }, [])
useEffect(() => {
api.get('/articles/').then(res => setArticles(Array.isArray(res.data) ? res.data : [])).catch(() => setArticles([]))
api.get('/tags').then(res => setTags(res.data && res.data.subjects ? res.data : { subjects: [], keywords: [] })).catch(() => { })
api.get('/collections/').then(res => setCollections(Array.isArray(res.data) ? res.data : [])).catch(() => setCollections([]))
}, [])
useEffect(() => { useEffect(() => {
let active = true let active = true
setAvailable(null) setAvailable(null)
@ -56,6 +82,17 @@ export default function CustomQuizPage() {
const ready = countKey === filterKey && available !== null const ready = countKey === filterKey && available !== null
const validCount = Number.isInteger(Number(count)) && Number(count) >= 1 && Number(count) <= 200 && Number(count) <= available const validCount = Number.isInteger(Number(count)) && Number(count) >= 1 && Number(count) <= 200 && Number(count) <= available
const togglePreset = async (collection) => {
if (presetIds.includes(collection.id)) { setPresetIds(ids => ids.filter(id => id !== collection.id)); return }
setPresetIds(ids => [...ids, collection.id])
if (!collection._loaded) {
const res = await api.get(`/collections/${collection.id}/questions`)
setCollections(prev => prev.map(c => c.id === collection.id ? { ...c, _loaded: true, question_ids: res.data.map(q => q.id) } : c))
}
}
const explicitIds = [...new Set(collections.filter(c => presetIds.includes(c.id)).flatMap(c => c.question_ids || []))]
const submit = async e => { const submit = async e => {
e.preventDefault() e.preventDefault()
if (!ready || !validCount || submitting) return if (!ready || !validCount || submitting) return
@ -76,164 +113,299 @@ export default function CustomQuizPage() {
} finally { setSubmitting(false) } } finally { setSubmitting(false) }
} }
const [catSearch, setCatSearch] = useState('') // Facet helpers
const [filtersOpen, setFiltersOpen] = useState(true) const toggleIn = (setter) => (id, on) => setter(ids => on ? [...new Set([...ids, id])] : ids.filter(v => v !== id))
const [articleIds, setArticleIds] = useState([]) const toggleCategory = toggleIn(setCategoryIds)
const [tagIds, setTagIds] = useState([]) const toggleTag = toggleIn(setTagIds)
const [articles, setArticles] = useState([]) const toggleArticle = toggleIn(setArticleIds)
const [tags, setTags] = useState({ subjects: [], keywords: [] })
const [presetIds, setPresetIds] = useState([]) const childrenOf = useMemo(() => {
const [collections, setCollections] = useState([]) const map = {}
useEffect(() => { for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
api.get('/articles/').then(res => setArticles(Array.isArray(res.data) ? res.data : [])).catch(() => setArticles([])) return map
api.get('/tags').then(res => setTags(res.data && res.data.subjects ? res.data : { subjects: [], keywords: [] })).catch(() => {}) }, [categories])
api.get('/collections/').then(res => setCollections(Array.isArray(res.data) ? res.data : [])).catch(() => setCollections([]))
}, []) const nameById = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c.name])), [categories])
const togglePreset = async (collection) => { const subjectTags = tags.subjects || []
if (presetIds.includes(collection.id)) { setPresetIds(ids => ids.filter(id => id !== collection.id)); return } const keywordTags = tags.keywords || []
setPresetIds(ids => [...ids, collection.id]) const tagName = (id) => [...subjectTags, ...keywordTags].find(t => t.id === id)?.name
if (!collection._loaded) {
const res = await api.get(`/collections/${collection.id}/questions`) const systems = summarise(categoryIds.map(id => nameById[id]).filter(Boolean))
setCollections(prev => prev.map(c => c.id === collection.id ? { ...c, _loaded: true, question_ids: res.data.map(q => q.id) } : c)) const disciplines = summarise(tagIds.map(tagName).filter(n => n && subjectTags.some(t => t.name === n)))
} const symptoms = summarise(tagIds.map(tagName).filter(n => n && keywordTags.some(t => t.name === n)))
} const articleSummary = summarise(articleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
const explicitIds = [...new Set(collections.filter(c => presetIds.includes(c.id)).flatMap(c => c.question_ids || []))] const savedNames = [
const childrenOf = {} ...(state === 'bookmarked' ? ['Bookmarked questions'] : []),
for (const cat of categories) { ...collections.filter(c => presetIds.includes(c.id)).map(c => c.title),
;(childrenOf[cat.parent_id || 0] ||= []).push(cat) ]
} const saved = summarise(savedNames)
const visibleCategories = categories.filter(cat =>
[cat.name, ...(cat.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(catSearch.toLowerCase())) const renderTree = (parentId, query) => {
const descendantSelected = (cat) => { const branch = childrenOf[parentId] || []
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 if (!branch.length) return null
return <ul className="custom-test-tree"> const matches = (cat) => !query || cat.name.toLowerCase().includes(query)
{branch.map(cat => { || (childrenOf[cat.id] || []).some(matches)
const kids = (childrenOf[cat.id] || []).filter(child => visibleCategories.includes(child)) const shown = branch.filter(matches)
const node = ( if (!shown.length) return null
<label> return (
<input type="checkbox" checked={categoryIds.includes(cat.id)} onChange={e => setCategoryIds(ids => e.target.checked ? [...ids, cat.id] : ids.filter(id => id !== cat.id))} /> <ul className="facet-tree">
{cat.name} ({cat.question_count}) {shown.map(cat => (
</label> <li key={cat.id}>
) <label>
if (!kids.length) return <li key={cat.id}>{node}</li> <input type="checkbox" checked={categoryIds.includes(cat.id)}
return <li key={cat.id}> onChange={e => toggleCategory(cat.id, e.target.checked)} />
<details open={!!catSearch || categoryIds.includes(cat.id) || descendantSelected(cat)}> {cat.name}
<summary className="custom-test-branch"><span className="custom-test-chevron" aria-hidden="true" />{node}</summary> <span className="facet-panel-count">{cat.question_count}</span>
{renderTree(cat.id)} </label>
</details> {renderTree(cat.id, query)}
</li> </li>
})} ))}
</ul> </ul>
)
}
const checkList = (items, isOn, onToggle, query, labelOf = i => i.name, countOf = () => null) => {
const shown = items.filter(item => !query || labelOf(item).toLowerCase().includes(query))
if (!shown.length) return <p className="facet-panel-empty">Nothing matches that search.</p>
return shown.map(item => (
<label key={item.id}>
<input type="checkbox" checked={isOn(item)} onChange={e => onToggle(item, e.target.checked)} />
{labelOf(item)}
{countOf(item) !== null && <span className="facet-panel-count">{countOf(item)}</span>}
</label>
))
}
// Cross-facet search: one list of matching options, each toggled in place.
const hits = useMemo(() => {
const query = globalSearch.trim().toLowerCase()
if (query.length < 2) return null
const out = []
for (const cat of categories) {
if (cat.name.toLowerCase().includes(query)) {
out.push({ key: `c${cat.id}`, name: cat.name, facet: 'Systems', on: categoryIds.includes(cat.id), toggle: on => toggleCategory(cat.id, on) })
}
}
for (const tag of subjectTags) {
if (tag.name.toLowerCase().includes(query)) {
out.push({ key: `s${tag.id}`, name: tag.name, facet: 'Disciplines', on: tagIds.includes(tag.id), toggle: on => toggleTag(tag.id, on) })
}
}
for (const tag of keywordTags) {
if (tag.name.toLowerCase().includes(query)) {
out.push({ key: `k${tag.id}`, name: tag.name, facet: 'Symptoms', on: tagIds.includes(tag.id), toggle: on => toggleTag(tag.id, on) })
}
}
for (const article of articles) {
if ((article.title || '').toLowerCase().includes(query)) {
out.push({ key: `a${article.id}`, name: article.title, facet: 'Articles', on: articleIds.includes(article.id), toggle: on => toggleArticle(article.id, on) })
}
}
return out.slice(0, 40)
}, [globalSearch, categories, subjectTags, keywordTags, articles, categoryIds, tagIds, articleIds])
const resetTopics = () => {
setCategoryIds([]); setTagIds([]); setArticleIds([]); setPresetIds([])
if (state === 'bookmarked') setState('all')
} }
return ( return (
<div className="custom-test"> <div className="custom-test">
<Link to="/quizzes"> Quizzes</Link> <Link to="/quizzes" className="custom-test-back"> Quizzes</Link>
<h1>Create Custom Test</h1> <h1>Create Custom Test</h1>
<p>Choose questions from your bank, {user?.name || 'learner'}.</p> <p className="custom-test-intro">Choose questions from your bank, {user?.name || 'learner'}.</p>
<form onSubmit={submit} className="custom-test-layout" noValidate>
<aside className="custom-test-filters card"> <form onSubmit={submit} noValidate>
<button type="button" className="custom-test-filters-toggle" aria-expanded={filtersOpen} {/* ── Topics ─────────────────────────────────────────────── */}
onClick={() => setFiltersOpen(v => !v)}>{filtersOpen ? '✕ Hide filters' : '☰ Filters'}</button> <section className="custom-test-section">
<div className={`custom-test-filters-body ${filtersOpen ? 'open' : ''}`}> <div className="custom-test-section-head">
<h2>Filters</h2> <h2>Set test topics</h2>
<h3>Status</h3> <button type="button" className="custom-test-reset" aria-label="Reset all topics" onClick={resetTopics}>Reset</button>
<div className="bank-state-buttons">
{[['all', 'All'], ['unused', 'Unused'], ['incorrect', 'Incorrect'], ['bookmarked', 'Saved']].map(([value, label]) => (
<button key={value} type="button" className={`btn btn-sm ${state === value ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setState(value)}>{label}</button>
))}
</div>
<h3>Difficulty</h3>
<select value={difficulty} onChange={e => setDifficulty(e.target.value)} className="input" aria-label="Difficulty">
<option value="">Any</option><option value="easy">Easy</option>
<option value="medium">Medium</option><option value="hard">Hard</option>
</select>
<h3>Exams</h3>
<label className="custom-test-exam"><input type="checkbox" checked readOnly /> Pediatrics Boards</label>
<h3>Disciplines</h3>
<div className="custom-test-tags">
{(tags.subjects || []).slice(0, 40).map(tag => (
<label key={tag.id}>
<input type="checkbox" checked={tagIds.includes(tag.id)}
onChange={e => setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
{tag.name}
</label>
))}
</div>
<h3>Symptoms & keywords</h3>
<div className="custom-test-tags">
{(tags.keywords || []).slice(0, 40).map(tag => (
<label key={tag.id}>
<input type="checkbox" checked={tagIds.includes(tag.id)}
onChange={e => setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
{tag.name}
</label>
))}
</div>
<h3>Systems</h3>
<p>Parent categories include all their subcategories.</p>
<div className="custom-test-search">
<span className="custom-test-search-icon" aria-hidden="true">🔍</span>
<input type="search" value={catSearch} onChange={e => setCatSearch(e.target.value)}
placeholder="Search topics…" aria-label="Search topics" className="input" />
</div>
{renderTree(0)}
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
<h3>Saved</h3>
<div className="custom-test-tags">
<label><input type="checkbox" checked={state === 'bookmarked'} onChange={e => setState(e.target.checked ? 'bookmarked' : 'all')} /> Bookmarked questions</label>
{collections.map(collection => (
<label key={collection.id}>
<input type="checkbox" checked={presetIds.includes(collection.id)} onChange={() => togglePreset(collection)} />
{collection.title} ({collection.question_count})
</label>
))}
</div>
<h3>Articles</h3>
<div className="custom-test-articles">
{(articles || []).map(article => (
<label key={article.id}>
<input type="checkbox" checked={articleIds.includes(article.id)}
onChange={e => setArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
{article.title}
</label>
))}
{(articles || []).length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '.8rem' }}>No articles yet.</p>}
</div>
</div> </div>
</aside>
<div className="custom-test-main"> <span className="custom-test-label" id="facet-search-label">Filter search</span>
<div className="custom-test-settings card"> <div className="custom-test-search">
<label>Title<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} className="custom-test-title-input" /></label> <span className="custom-test-search-icon" aria-hidden="true">🔍</span>
<label className="custom-test-adaptive"><input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} /> <span className="custom-test-switch" aria-hidden="true"><span /></span> Adaptive session</label> <input type="search" value={globalSearch} onChange={e => setGlobalSearch(e.target.value)}
{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>} placeholder="E.g. systems, disciplines, keywords" aria-label="Filter search" />
<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> </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> {hits !== null && (
<p role="status" aria-live="polite">{ready ? `${available} questions available` : 'Counting available questions…'}</p> hits.length === 0
{ready && available === 0 && <p>No questions match these filters.</p>} ? <div className="custom-test-hits"><p className="custom-test-hits-empty">Nothing matches {globalSearch}.</p></div>
{ready && !validCount && available > 0 && <p>Choose 1{Math.min(200, available)} questions.</p>} : (
{countError && <p role="alert">{countError}</p>} <div className="custom-test-hits">
<div className="custom-test-actions"> {hits.map(hit => (
<button type="button" className="btn btn-secondary" disabled={submitting} onClick={() => setRefresh(v => v + 1)}>Refresh count</button> <label key={hit.key}>
<button className="btn btn-primary" type="submit" disabled={submitting || !ready || !validCount || !title.trim()}>{submitting ? 'Creating…' : 'Create Test'}</button> <input type="checkbox" checked={hit.on} onChange={e => hit.toggle(e.target.checked)} />
{hit.name}
<span className="custom-test-hit-facet">{hit.facet}</span>
</label>
))}
</div>
)
)}
<div className="facet-list">
<div className="facet-row is-fixed">
<span className="facet-row-label">Exams</span>
<span className="facet-row-summary"><span className="facet-row-value">Pediatrics Boards</span></span>
</div>
<FacetRow label="Systems" {...systems} onOpen={() => setOpenFacet('systems')} />
<FacetRow label="Disciplines" {...disciplines} onOpen={() => setOpenFacet('disciplines')} />
<FacetRow label="Symptoms &amp; keywords" {...symptoms} onOpen={() => setOpenFacet('symptoms')} />
<FacetRow label="Articles" {...articleSummary} onOpen={() => setOpenFacet('articles')} />
<FacetRow label="Saved" {...saved} onOpen={() => setOpenFacet('saved')} />
</div> </div>
{error && <p role="alert">{error}</p>} </section>
{/* ── Criteria ───────────────────────────────────────────── */}
<section className="custom-test-section">
<div className="custom-test-section-head"><h2>Test criteria</h2></div>
<label className="custom-test-field">
<span>Title</span>
<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} />
</label>
<label className="custom-test-toggle">
<input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} />
<span className="custom-test-switch" aria-hidden="true"><span /></span>
<span className="custom-test-toggle-text">
<strong>Adaptive session</strong>
<span>Questions prioritized by impact</span>
</span>
</label>
{adaptive && (
<p className="custom-test-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>
)}
<div className="facet-list" style={{ margin: '10px 0 14px' }}>
<FacetRow label="Difficulty" summary={DIFFICULTY_LABEL[difficulty]} extra={0} onOpen={() => setOpenFacet('difficulty')} />
<FacetRow label="Status" summary={STATE_LABEL[state]} extra={0} onOpen={() => setOpenFacet('status')} />
</div>
<label className="custom-test-check">
<input type="checkbox" checked={shared} onChange={e => setShared(e.target.checked)} />
Share with other learners (only shareable questions)
</label>
<p className="custom-test-note">
Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome,
including skipped questions.
</p>
</section>
{/* ── Count and type ─────────────────────────────────────── */}
<section className="custom-test-section">
<span className="custom-test-label">Question count</span>
<div className="custom-test-count-row">
<input type="number" required min="1" max="200" step="1" value={count}
aria-label="Number of questions" onChange={e => setCount(e.target.value)} />
<span className="custom-test-count-of" role="status" aria-live="polite">
{ready ? `${available} questions available` : 'Counting available questions…'}
</span>
</div>
{ready && available === 0 && <p className="custom-test-error">No questions match these filters.</p>}
{ready && !validCount && available > 0 && <p className="custom-test-error">Choose 1{Math.min(200, available)} questions.</p>}
{countError && <p className="custom-test-error" role="alert">{countError}</p>}
</section>
<section className="custom-test-section">
<span className="custom-test-label">Test type</span>
<div className="custom-test-modes" role="radiogroup" aria-label="Mode">
<label>
<input type="radio" name="mode" value="learning" checked={mode === 'learning'} onChange={() => setMode('learning')} />
Study mode
</label>
<label>
<input type="radio" name="mode" value="timed" checked={mode === 'timed'} onChange={() => setMode('timed')} />
Exam mode
</label>
</div>
{mode === 'timed' && (
<label className="custom-test-field" style={{ marginTop: 12 }}>
<span>Time limit (minutes, optional)</span>
<input type="number" min="1" step="1" value={time} onChange={e => setTime(e.target.value)} />
</label>
)}
</section>
<div className="custom-test-actions">
<button type="button" className="btn btn-secondary" disabled={submitting} onClick={() => setRefresh(v => v + 1)}>Refresh count</button>
<button className="btn btn-primary" type="submit" disabled={submitting || !ready || !validCount || !title.trim()}>
{submitting ? 'Creating…' : 'Create Test'}
</button>
</div> </div>
{error && <p className="custom-test-error" role="alert">{error}</p>}
</form> </form>
{/* ── Facet pickers ────────────────────────────────────────── */}
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
onReset={() => setCategoryIds([])}
helper="By default, all systems are included unless filters are selected. A parent includes its subcategories.">
{query => renderTree(0, query) || <p className="facet-panel-empty">Nothing matches that search.</p>}
</FacetPicker>
<FacetPicker title="Disciplines" open={openFacet === 'disciplines'} onClose={() => 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)}
</FacetPicker>
<FacetPicker title="Symptoms &amp; keywords" open={openFacet === 'symptoms'} onClose={() => 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)}
</FacetPicker>
<FacetPicker title="Articles" open={openFacet === 'articles'} onClose={() => setOpenFacet(null)}
onReset={() => setArticleIds([])}
helper="Pick the reading whose linked questions you want.">
{query => articles.length === 0
? <p className="facet-panel-empty">No articles yet.</p>
: checkList(articles, a => articleIds.includes(a.id), (a, on) => toggleArticle(a.id, on), query, a => a.title)}
</FacetPicker>
<FacetPicker title="Saved" open={openFacet === 'saved'} onClose={() => setOpenFacet(null)}
onReset={() => { setPresetIds([]); if (state === 'bookmarked') setState('all') }}
helper="Your bookmarks and personal libraries.">
{query => (
<>
{'bookmarked questions'.includes(query) && (
<label>
<input type="checkbox" checked={state === 'bookmarked'}
onChange={e => setState(e.target.checked ? 'bookmarked' : 'all')} />
Bookmarked questions
</label>
)}
{checkList(collections, c => presetIds.includes(c.id), c => togglePreset(c), query,
c => c.title, c => c.question_count)}
</>
)}
</FacetPicker>
<FacetPicker title="Difficulty" open={openFacet === 'difficulty'} onClose={() => setOpenFacet(null)}
onReset={() => setDifficulty('')} helper="Applies to every question in the test.">
{() => ['', 'easy', 'medium', 'hard'].map(value => (
<label key={value || 'any'}>
<input type="radio" name="difficulty-facet" checked={difficulty === value} onChange={() => setDifficulty(value)} />
{DIFFICULTY_LABEL[value]}
</label>
))}
</FacetPicker>
<FacetPicker title="Status" open={openFacet === 'status'} onClose={() => setOpenFacet(null)}
onReset={() => setState('all')} helper="Filter by how you have answered these questions before.">
{() => ['all', 'unused', 'incorrect', 'bookmarked'].map(value => (
<label key={value}>
<input type="radio" name="status-facet" checked={state === value} onChange={() => setState(value)} />
{STATE_LABEL[value]}
</label>
))}
</FacetPicker>
</div> </div>
) )
} }

View file

@ -12,6 +12,7 @@ const categories = [
{ id: 1, name: 'Pediatrics', question_count: 30, breadcrumbs: [{ id: 1, name: 'Pediatrics' }] }, { 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' }] }, { 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) { function setupCount(count = 30) {
api.get.mockImplementation(url => { api.get.mockImplementation(url => {
if (url === '/question-categories/') return Promise.resolve({ data: categories }) if (url === '/question-categories/') return Promise.resolve({ data: categories })
@ -33,13 +34,21 @@ describe('CustomQuizPage', () => {
api.post.mockResolvedValue({ data: { id: 123 } }) api.post.mockResolvedValue({ data: { id: 123 } })
renderBuilder() renderBuilder()
await screen.findByText('30 questions available') 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.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('Neonatal (10)')) // Systems and Status are chosen inside their facet pickers, not inline.
await userEvent.click(screen.getByRole('button', { name: 'Unused' })) await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed') 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(/Time limit/), { target: { value: '15' } })
fireEvent.change(screen.getByLabelText('Number of questions'), { target: { value: '10' } }) fireEvent.change(screen.getByLabelText('Number of questions'), { target: { value: '10' } })
await userEvent.click(screen.getByLabelText(/Share with/)) await userEvent.click(screen.getByLabelText(/Share with/))
@ -89,14 +98,75 @@ describe('CustomQuizPage', () => {
expect(screen.queryByRole('heading', { name: 'Saved test' })).not.toBeInTheDocument() 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 () => { it('ignores outdated count responses after filters change', async () => {
let resolveOld let resolveOld
api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: categories }) : new Promise(resolve => { resolveOld = resolve })) api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: categories }) : new Promise(resolve => { resolveOld = resolve }))
renderBuilder() renderBuilder()
await screen.findByLabelText('Pediatrics (30)') await screen.findByRole('button', { name: /^Status/ })
const old = resolveOld const old = resolveOld
setupCount(4) 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') await screen.findByText('4 questions available')
old({ data: { count: 100 } }) old({ data: { count: 100 } })
await waitFor(() => expect(screen.queryByText('100 questions available')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('100 questions available')).not.toBeInTheDocument())