From 075fd3a648e352fd980dc40b0c5e7ebe8fc5fa83 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 18:59:27 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA --- frontend/src/components/FacetPicker.jsx | 60 +++ frontend/src/pages/CustomQuizPage.css | 217 +++++++--- frontend/src/pages/CustomQuizPage.jsx | 470 ++++++++++++++------- frontend/src/pages/CustomQuizPage.test.jsx | 84 +++- 4 files changed, 619 insertions(+), 212 deletions(-) create mode 100644 frontend/src/components/FacetPicker.jsx diff --git a/frontend/src/components/FacetPicker.jsx b/frontend/src/components/FacetPicker.jsx new file mode 100644 index 0000000..32cffeb --- /dev/null +++ b/frontend/src/components/FacetPicker.jsx @@ -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 ( + + ) +} + +/** + * 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 ( +
e.target === e.currentTarget && onClose()}> +
+
+

{title}

+ +
+ +
+ setQuery(e.target.value)} + placeholder="Search" aria-label={searchLabel || `Search ${title}`} /> +
+ + {helper &&

{helper}

} +

Include questions from:

+ +
{children(query.trim().toLowerCase())}
+ +
+ {onReset && } + +
+
+
+ ) +} diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 34f23af..11df2c6 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -1,57 +1,162 @@ -.custom-test { max-width: 960px; margin: auto; } -.custom-test h1 { margin: 16px 0; } -.custom-test fieldset { border: 1px solid var(--border); border-radius: 8px; padding: 16px; min-width: 0; } -.custom-test p { margin: 12px 0; } -.custom-test-categories { display: grid; gap: 10px; max-height: 320px; overflow: auto; margin: 12px 0; } -.custom-test-categories label, .custom-test-share { display: flex; align-items: baseline; gap: 8px; } -.custom-test input[type=checkbox] { width: auto; flex-shrink: 0; } -.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; } -.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; } +/* Custom test builder — AMBOSS-style facet rows that open a picker panel, + instead of a cramped sidebar of nested scrolling checkbox lists. */ + +.custom-test { max-width: 720px; margin: 0 auto; padding-bottom: 40px; } +.custom-test-back { font-size: 0.85rem; color: var(--primary); text-decoration: none; } +.custom-test h1 { margin: 10px 0 4px; font-size: 1.5rem; } +.custom-test-intro { margin: 0 0 20px; color: var(--text-muted); font-size: 0.9rem; } + +/* ── Section headings ─────────────────────────────────────────────── */ +.custom-test-section { margin-bottom: 22px; } +.custom-test-section-head { + display: flex; align-items: center; gap: 12px; margin: 0 0 10px; +} +.custom-test-section-head h2 { margin: 0; font-size: 1rem; font-weight: 650; white-space: nowrap; } +.custom-test-section-head::after { content: ''; flex: 1; height: 1px; background: var(--border); } +.custom-test-reset { + background: none; border: none; padding: 0; cursor: pointer; flex-shrink: 0; + font: inherit; font-size: 0.74rem; font-weight: 700; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--primary); +} +.custom-test-label { + display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); margin: 0 0 6px; +} + +/* ── Global facet search ──────────────────────────────────────────── */ +.custom-test-search { position: relative; margin-bottom: 12px; } +.custom-test-search input { + width: 100%; padding: 10px 13px 10px 34px; font-size: 0.9rem; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.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; } diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index f62a660..ca4fc74 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -1,9 +1,19 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' +import FacetPicker, { FacetRow } from '../components/FacetPicker' 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() { const { user } = useAuth() const navigate = useNavigate() @@ -30,7 +40,17 @@ export default function CustomQuizPage() { const [countError, setCountError] = useState('') const [submitting, setSubmitting] = useState(false) 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(() => { let active = true @@ -39,6 +59,12 @@ export default function CustomQuizPage() { 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(() => { let active = true setAvailable(null) @@ -56,6 +82,17 @@ export default function CustomQuizPage() { const ready = countKey === filterKey && available !== null 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 => { e.preventDefault() if (!ready || !validCount || submitting) return @@ -76,164 +113,299 @@ export default function CustomQuizPage() { } finally { setSubmitting(false) } } - const [catSearch, setCatSearch] = useState('') - const [filtersOpen, setFiltersOpen] = useState(true) - 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([]) - 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([])) - }, []) - 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 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)) + // ── Facet helpers ─────────────────────────────────────────────── + const toggleIn = (setter) => (id, on) => setter(ids => on ? [...new Set([...ids, id])] : ids.filter(v => v !== id)) + const toggleCategory = toggleIn(setCategoryIds) + const toggleTag = toggleIn(setTagIds) + const toggleArticle = toggleIn(setArticleIds) + + const childrenOf = useMemo(() => { + const map = {} + for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat) + return map + }, [categories]) + + const nameById = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c.name])), [categories]) + const subjectTags = tags.subjects || [] + const keywordTags = tags.keywords || [] + const tagName = (id) => [...subjectTags, ...keywordTags].find(t => t.id === id)?.name + + const systems = summarise(categoryIds.map(id => nameById[id]).filter(Boolean)) + 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 savedNames = [ + ...(state === 'bookmarked' ? ['Bookmarked questions'] : []), + ...collections.filter(c => presetIds.includes(c.id)).map(c => c.title), + ] + const saved = summarise(savedNames) + + const renderTree = (parentId, query) => { + const branch = childrenOf[parentId] || [] if (!branch.length) return null - return + const matches = (cat) => !query || cat.name.toLowerCase().includes(query) + || (childrenOf[cat.id] || []).some(matches) + const shown = branch.filter(matches) + if (!shown.length) return null + return ( + + ) + } + + 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

Nothing matches that search.

+ return shown.map(item => ( + + )) + } + + // 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 (
- ← Quizzes + ← Quizzes

Create Custom Test

-

Choose questions from your bank, {user?.name || 'learner'}.

-
- -
-
- - - {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 ───────────────────────────────────────────── */} +
+

Test criteria

+ + + + + {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. +

+ )} + +
+ setOpenFacet('difficulty')} /> + setOpenFacet('status')} /> +
+ + +

+ Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, + including skipped questions. +

+
+ + {/* ── 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}

} +
+ +
+ Test type +
+ + +
+ {mode === 'timed' && ( + + )} +
+ +
+ +
+ {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())