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:
parent
145c1802c6
commit
075fd3a648
4 changed files with 619 additions and 212 deletions
60
frontend/src/components/FacetPicker.jsx
Normal file
60
frontend/src/components/FacetPicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 <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"><span className="custom-test-chevron" aria-hidden="true" />{node}</summary>
|
||||
{renderTree(cat.id)}
|
||||
</details>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
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 (
|
||||
<ul className="facet-tree">
|
||||
{shown.map(cat => (
|
||||
<li key={cat.id}>
|
||||
<label>
|
||||
<input type="checkbox" checked={categoryIds.includes(cat.id)}
|
||||
onChange={e => toggleCategory(cat.id, e.target.checked)} />
|
||||
{cat.name}
|
||||
<span className="facet-panel-count">{cat.question_count}</span>
|
||||
</label>
|
||||
{renderTree(cat.id, query)}
|
||||
</li>
|
||||
))}
|
||||
</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 (
|
||||
<div className="custom-test">
|
||||
<Link to="/quizzes">← Quizzes</Link>
|
||||
<Link to="/quizzes" className="custom-test-back">← Quizzes</Link>
|
||||
<h1>Create Custom Test</h1>
|
||||
<p>Choose questions from your bank, {user?.name || 'learner'}.</p>
|
||||
<form onSubmit={submit} className="custom-test-layout" noValidate>
|
||||
<aside className="custom-test-filters card">
|
||||
<button type="button" className="custom-test-filters-toggle" aria-expanded={filtersOpen}
|
||||
onClick={() => setFiltersOpen(v => !v)}>{filtersOpen ? '✕ Hide filters' : '☰ Filters'}</button>
|
||||
<div className={`custom-test-filters-body ${filtersOpen ? 'open' : ''}`}>
|
||||
<h2>Filters</h2>
|
||||
<h3>Status</h3>
|
||||
<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>
|
||||
<p className="custom-test-intro">Choose questions from your bank, {user?.name || 'learner'}.</p>
|
||||
|
||||
<form onSubmit={submit} noValidate>
|
||||
{/* ── Topics ─────────────────────────────────────────────── */}
|
||||
<section className="custom-test-section">
|
||||
<div className="custom-test-section-head">
|
||||
<h2>Set test topics</h2>
|
||||
<button type="button" className="custom-test-reset" aria-label="Reset all topics" onClick={resetTopics}>Reset</button>
|
||||
</div>
|
||||
</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)} className="custom-test-title-input" /></label>
|
||||
<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>
|
||||
{adaptive && <p className="custom-test-adaptive-note">Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.</p>}
|
||||
<label>Number of questions<input type="number" required min="1" max="200" step="1" value={count} onChange={e => setCount(e.target.value)} /></label>
|
||||
<label>Mode<select value={mode} onChange={e => setMode(e.target.value)}>
|
||||
<option value="learning">Study</option><option value="timed">Exam</option>
|
||||
</select></label>
|
||||
{mode === 'timed' && <label>Time limit (minutes, optional)<input type="number" min="1" step="1" value={time} onChange={e => setTime(e.target.value)} /></label>}
|
||||
|
||||
<span className="custom-test-label" id="facet-search-label">Filter search</span>
|
||||
<div className="custom-test-search">
|
||||
<span className="custom-test-search-icon" aria-hidden="true">🔍</span>
|
||||
<input type="search" value={globalSearch} onChange={e => setGlobalSearch(e.target.value)}
|
||||
placeholder="E.g. systems, disciplines, keywords" aria-label="Filter search" />
|
||||
</div>
|
||||
<p>Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, including skipped questions.</p>
|
||||
<label className="custom-test-share"><input type="checkbox" checked={shared} onChange={e => setShared(e.target.checked)} /> Share with other learners (only shareable questions)</label>
|
||||
<p role="status" aria-live="polite">{ready ? `${available} questions available` : 'Counting available questions…'}</p>
|
||||
{ready && available === 0 && <p>No questions match these filters.</p>}
|
||||
{ready && !validCount && available > 0 && <p>Choose 1–{Math.min(200, available)} questions.</p>}
|
||||
{countError && <p role="alert">{countError}</p>}
|
||||
<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>
|
||||
|
||||
{hits !== null && (
|
||||
hits.length === 0
|
||||
? <div className="custom-test-hits"><p className="custom-test-hits-empty">Nothing matches “{globalSearch}”.</p></div>
|
||||
: (
|
||||
<div className="custom-test-hits">
|
||||
{hits.map(hit => (
|
||||
<label key={hit.key}>
|
||||
<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 & keywords" {...symptoms} onOpen={() => setOpenFacet('symptoms')} />
|
||||
<FacetRow label="Articles" {...articleSummary} onOpen={() => setOpenFacet('articles')} />
|
||||
<FacetRow label="Saved" {...saved} onOpen={() => setOpenFacet('saved')} />
|
||||
</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>
|
||||
{error && <p className="custom-test-error" role="alert">{error}</p>}
|
||||
</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 & 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue