fix: two-card builder layout with a sticky mode and Start bar
Matches the AMBOSS screenshot: "Set session topics" and "Session criteria" sit
side by side, the question count lives at the bottom of the criteria card as
"20 /64", and session type plus Start are a sticky bar at the foot of the
viewport so neither is behind a scroll.
- Two-column grid, stacking to one column under 900px.
- Facet rows now lead with a + affordance and show the selection as a chip
("Cardiology" "+1"); an unset facet stays plain "All".
- Reset moved to the top right with its ↺ icon.
- Adaptive session is its own row inside the criteria card, with the spark mark
and toggle on the right.
- A More expander holds what AMBOSS does not have — sharing, the exam time
limit, and the unused/incorrect explanation — instead of stacking them.
- Mode is a segmented control in the bar rather than radio cards in the flow.
Tests: 2 new frontend tests pinning the count to the criteria card and mode plus
Create Test to the sticky bar, and the time limit appearing under More only in
exam mode. Full suites green: 88 backend, 122 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
075fd3a648
commit
1f2d0a81a1
4 changed files with 285 additions and 127 deletions
|
|
@ -1,15 +1,20 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Collapsed facet row: label on the left, current selection on the right. */
|
||||
export function FacetRow({ label, summary, extra, onOpen }) {
|
||||
/**
|
||||
* Collapsed facet row: a + affordance, the label, and the current selection on
|
||||
* the right — plain "All" when nothing is picked, a chip (plus a +N chip) once
|
||||
* something is.
|
||||
*/
|
||||
export function FacetRow({ label, summary, extra, chip, onOpen }) {
|
||||
const selected = chip ?? summary !== 'All'
|
||||
return (
|
||||
<button type="button" className="facet-row" onClick={onOpen}>
|
||||
<span className="facet-row-icon" aria-hidden="true">+</span>
|
||||
<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 className={selected ? 'facet-chip' : 'facet-row-value'}>{summary}</span>
|
||||
{extra > 0 && <span className="facet-chip is-more">+{extra}</span>}
|
||||
</span>
|
||||
<span className="facet-row-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,159 @@
|
|||
/* 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; }
|
||||
.custom-test { max-width: 1120px; margin: 0 auto; padding-bottom: 100px; }
|
||||
|
||||
/* ── 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-top {
|
||||
display: grid; grid-template-columns: 1fr auto; align-items: end;
|
||||
gap: 6px 16px; margin-bottom: 16px;
|
||||
}
|
||||
.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-back { grid-column: 1 / -1; font-size: 0.85rem; color: var(--primary); text-decoration: none; }
|
||||
.custom-test-top h1 { margin: 8px 0 4px; font-size: 1.5rem; }
|
||||
.custom-test-intro { margin: 0; color: var(--text-muted); font-size: 0.9rem; }
|
||||
.custom-test-reset {
|
||||
background: none; border: none; padding: 0; cursor: pointer; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; gap: 6px; align-self: end;
|
||||
background: none; border: none; padding: 4px; cursor: pointer;
|
||||
font: inherit; font-size: 0.74rem; font-weight: 700; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: var(--primary);
|
||||
text-transform: uppercase; color: var(--text-muted); white-space: nowrap;
|
||||
}
|
||||
.custom-test-reset:hover { color: var(--primary); }
|
||||
|
||||
/* ── Two cards side by side ───────────────────────────────────────── */
|
||||
.custom-test-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
|
||||
.custom-test-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.custom-test-card > h2 {
|
||||
margin: 0; padding: 16px 18px; font-size: 1.05rem; font-weight: 650;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.custom-test-card-pad { padding: 14px 18px; }
|
||||
.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 { position: relative; }
|
||||
.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);
|
||||
background: var(--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 { border-top: 1px solid var(--border); }
|
||||
.custom-test-hits label {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 13px;
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 18px;
|
||||
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; }
|
||||
.custom-test-hits-empty { padding: 12px 18px; color: var(--text-muted); font-size: 0.86rem; margin: 0; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ── Facet rows ───────────────────────────────────────────────────── */
|
||||
.facet-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--card-bg); }
|
||||
.facet-list { border-top: 1px solid var(--border); }
|
||||
.facet-row {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
padding: 13px 15px; background: none; border: none; border-bottom: 1px solid var(--border);
|
||||
padding: 14px 18px; 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-icon { color: var(--text-subtle); font-size: 1.15rem; line-height: 1; flex-shrink: 0; width: 14px; }
|
||||
.facet-row:hover .facet-row-icon { color: var(--primary); }
|
||||
.facet-row-label { font-size: 0.95rem; }
|
||||
.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-value { font-size: 0.9rem; color: var(--text-muted); white-space: nowrap; }
|
||||
.facet-chip {
|
||||
font-size: 0.82rem; border-radius: 6px; padding: 3px 9px;
|
||||
background: var(--option-sel-bg); color: var(--primary); white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis; max-width: 190px;
|
||||
}
|
||||
.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-chip.is-more { flex-shrink: 0; font-weight: 700; }
|
||||
.facet-row.is-fixed { cursor: default; }
|
||||
.facet-row.is-fixed:hover { background: none; }
|
||||
.facet-row.is-fixed .facet-row-icon { color: var(--text-subtle); }
|
||||
|
||||
/* ── Criteria ─────────────────────────────────────────────────────── */
|
||||
.custom-test-field { display: block; }
|
||||
.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 {
|
||||
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-adaptive {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.custom-test-adaptive > div { flex: 1; min-width: 0; }
|
||||
.custom-test-adaptive strong { display: block; font-size: 0.98rem; font-weight: 650; color: #6d28d9; }
|
||||
.custom-test-adaptive p { margin: 2px 0 0; font-size: 0.82rem; color: var(--text-muted); }
|
||||
.custom-test-spark { margin-right: 4px; }
|
||||
.custom-test-toggle { display: inline-flex; align-items: center; cursor: pointer; flex-shrink: 0; }
|
||||
.custom-test-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.custom-test-switch {
|
||||
width: 40px; 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: #7c3aed; }
|
||||
.custom-test-toggle input:checked + .custom-test-switch > span { transform: translateX(18px); }
|
||||
.custom-test-toggle input:focus-visible + .custom-test-switch { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
|
||||
.custom-test-more {
|
||||
display: flex; align-items: center; gap: 6px; width: 100%;
|
||||
padding: 12px 18px; background: none; border: none; border-top: 1px solid var(--border);
|
||||
font: inherit; font-size: 0.76rem; font-weight: 700; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.custom-test-more:hover { color: var(--primary); }
|
||||
.custom-test-check { display: flex; align-items: center; gap: 9px; font-size: 0.88rem; cursor: pointer; }
|
||||
|
||||
.custom-test-countbox { padding: 14px 18px; border-top: 1px solid var(--border); }
|
||||
.custom-test-count-row { display: flex; align-items: center; gap: 10px; }
|
||||
.custom-test-count-row input {
|
||||
width: 92px; padding: 10px 12px; font-size: 1rem;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
.custom-test-count-of { color: var(--text-muted); font-size: 1rem; }
|
||||
.custom-test-count-note { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-subtle); }
|
||||
.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.84rem; margin: 8px 0 0; }
|
||||
|
||||
/* ── Sticky bottom bar: mode and Start stay reachable without scrolling ── */
|
||||
.custom-test-bar {
|
||||
grid-column: 1 / -1;
|
||||
position: sticky; bottom: 0; z-index: 40;
|
||||
margin: 4px -16px -100px; padding: 0 16px;
|
||||
background: var(--card-bg); border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.custom-test-bar-inner {
|
||||
max-width: 1120px; margin: 0 auto;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 12px 0 calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.custom-test-bar-label { margin: 0; }
|
||||
.custom-test-modes { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
.custom-test-modes label { cursor: pointer; }
|
||||
.custom-test-modes input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.custom-test-modes span {
|
||||
display: block; padding: 9px 18px; font-size: 0.9rem; font-weight: 600;
|
||||
color: var(--text-muted); background: var(--card-bg); white-space: nowrap;
|
||||
}
|
||||
.custom-test-modes label + label span { border-left: 1px solid var(--border); }
|
||||
.custom-test-modes input:checked + span { background: var(--option-sel-bg); color: var(--primary); }
|
||||
.custom-test-modes input:focus-visible + span { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||
.custom-test-start { margin-left: auto; min-width: 150px; }
|
||||
|
||||
/* ── Picker panel ─────────────────────────────────────────────────── */
|
||||
.facet-overlay {
|
||||
|
|
@ -153,10 +241,20 @@
|
|||
.custom-test-actions { display: flex; gap: 8px; margin-top: 18px; }
|
||||
.custom-test-actions .btn:last-child { flex: 1; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.custom-test-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.custom-test-modes label { min-width: 100%; }
|
||||
.custom-test { padding-bottom: 120px; }
|
||||
.custom-test-top { grid-template-columns: 1fr; }
|
||||
.custom-test-reset { justify-self: start; padding-left: 0; }
|
||||
.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; }
|
||||
.facet-chip { max-width: 40vw; }
|
||||
.custom-test-bar-label { display: none; }
|
||||
.custom-test-bar-inner { padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); }
|
||||
.custom-test-modes { flex: 1; }
|
||||
.custom-test-modes label { flex: 1; }
|
||||
.custom-test-modes span { text-align: center; padding: 9px 10px; }
|
||||
.custom-test-start { width: 100%; margin-left: 0; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export default function CustomQuizPage() {
|
|||
const [collections, setCollections] = useState([])
|
||||
const [openFacet, setOpenFacet] = useState(null)
|
||||
const [globalSearch, setGlobalSearch] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
|
||||
const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, articleIds, tagIds, refresh])
|
||||
|
||||
|
|
@ -211,28 +212,34 @@ export default function CustomQuizPage() {
|
|||
|
||||
return (
|
||||
<div className="custom-test">
|
||||
<Link to="/quizzes" className="custom-test-back">← Quizzes</Link>
|
||||
<h1>Create Custom Test</h1>
|
||||
<p className="custom-test-intro">Choose questions from your bank, {user?.name || 'learner'}.</p>
|
||||
<div className="custom-test-top">
|
||||
<Link to="/quizzes" className="custom-test-back">← Quizzes</Link>
|
||||
<div>
|
||||
<h1>Create Custom Test</h1>
|
||||
<p className="custom-test-intro">Choose questions from your bank, {user?.name || 'learner'}.</p>
|
||||
</div>
|
||||
<button type="button" className="custom-test-reset" aria-label="Reset all topics" onClick={resetTopics}>
|
||||
<span aria-hidden="true">↺</span> Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<form onSubmit={submit} noValidate className="custom-test-grid">
|
||||
{/* ── Topics ───────────────────────────────────────────────── */}
|
||||
<section className="custom-test-card">
|
||||
<h2>Set test topics</h2>
|
||||
|
||||
<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 className="custom-test-card-pad">
|
||||
<span className="custom-test-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>
|
||||
</div>
|
||||
|
||||
{hits !== null && (
|
||||
hits.length === 0
|
||||
? <div className="custom-test-hits"><p className="custom-test-hits-empty">Nothing matches “{globalSearch}”.</p></div>
|
||||
? <p className="custom-test-hits-empty">Nothing matches “{globalSearch}”.</p>
|
||||
: (
|
||||
<div className="custom-test-hits">
|
||||
{hits.map(hit => (
|
||||
|
|
@ -248,100 +255,119 @@ export default function CustomQuizPage() {
|
|||
|
||||
<div className="facet-list">
|
||||
<div className="facet-row is-fixed">
|
||||
<span className="facet-row-icon" aria-hidden="true">+</span>
|
||||
<span className="facet-row-label">Exams</span>
|
||||
<span className="facet-row-summary"><span className="facet-row-value">Pediatrics Boards</span></span>
|
||||
<span className="facet-row-summary"><span className="facet-chip">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="Symptoms" {...symptoms} onOpen={() => setOpenFacet('symptoms')} />
|
||||
<FacetRow label="Articles" {...articleSummary} onOpen={() => setOpenFacet('articles')} />
|
||||
<FacetRow label="Saved" {...saved} onOpen={() => setOpenFacet('saved')} />
|
||||
<FacetRow label="Saved questions" {...saved} onOpen={() => setOpenFacet('saved')} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Criteria ───────────────────────────────────────────── */}
|
||||
<section className="custom-test-section">
|
||||
<div className="custom-test-section-head"><h2>Test criteria</h2></div>
|
||||
{/* ── Criteria ─────────────────────────────────────────────── */}
|
||||
<section className="custom-test-card">
|
||||
<h2>Test criteria</h2>
|
||||
|
||||
<label className="custom-test-field">
|
||||
<span>Title</span>
|
||||
<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<div className="custom-test-card-pad">
|
||||
<label className="custom-test-field">
|
||||
<span>Test title</span>
|
||||
<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="custom-test-adaptive">
|
||||
<div>
|
||||
<strong><span className="custom-test-spark" aria-hidden="true">✦</span> Adaptive session</strong>
|
||||
<p>Questions prioritized by impact</p>
|
||||
</div>
|
||||
<label className="custom-test-toggle">
|
||||
<input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)}
|
||||
aria-label="Adaptive session" />
|
||||
<span className="custom-test-switch" aria-hidden="true"><span /></span>
|
||||
</label>
|
||||
</div>
|
||||
{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 className="custom-test-note custom-test-card-pad">
|
||||
Adaptive 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 className="facet-list">
|
||||
<FacetRow label="Difficulty" summary={DIFFICULTY_LABEL[difficulty]} extra={0}
|
||||
chip={!!difficulty} onOpen={() => setOpenFacet('difficulty')} />
|
||||
<FacetRow label="Status" summary={STATE_LABEL[state]} extra={0}
|
||||
chip={state !== 'all'} 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 type="button" className="custom-test-more" aria-expanded={moreOpen}
|
||||
onClick={() => setMoreOpen(v => !v)}>
|
||||
More <span aria-hidden="true">{moreOpen ? '⌃' : '⌄'}</span>
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<div className="custom-test-card-pad">
|
||||
<label className="custom-test-check">
|
||||
<input type="checkbox" checked={shared} onChange={e => setShared(e.target.checked)} />
|
||||
Share with other learners (only shareable questions)
|
||||
</label>
|
||||
{mode === 'timed' && (
|
||||
<label className="custom-test-field" style={{ marginTop: 10 }}>
|
||||
<span>Time limit (minutes, optional)</span>
|
||||
<input type="number" min="1" step="1" value={time} onChange={e => setTime(e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<p className="custom-test-note">
|
||||
Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome,
|
||||
including skipped questions.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="custom-test-countbox">
|
||||
<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}` : 'Counting…'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="custom-test-count-note">
|
||||
{ready ? `${available} questions available` : 'Counting available questions…'}
|
||||
</p>
|
||||
{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>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sticky action bar ────────────────────────────────────── */}
|
||||
<div className="custom-test-bar">
|
||||
<div className="custom-test-bar-inner">
|
||||
<span className="custom-test-label custom-test-bar-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')} />
|
||||
<span>Study mode</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="mode" value="timed" checked={mode === 'timed'} onChange={() => setMode('timed')} />
|
||||
<span>Exam mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" disabled={submitting}
|
||||
onClick={() => setRefresh(v => v + 1)}>Refresh count</button>
|
||||
<button className="btn btn-primary custom-test-start" type="submit"
|
||||
disabled={submitting || !ready || !validCount || !title.trim()}>
|
||||
{submitting ? 'Creating…' : 'Create Test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="custom-test-error" role="alert">{error}</p>}
|
||||
</form>
|
||||
|
||||
{/* ── Facet pickers ────────────────────────────────────────── */}
|
||||
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setCategoryIds([])}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ describe('CustomQuizPage', () => {
|
|||
renderBuilder()
|
||||
await screen.findByText('30 questions available')
|
||||
expect(screen.getByRole('radio', { name: 'Study mode' })).toBeChecked()
|
||||
// Sharing is our own addition to AMBOSS's criteria, so it lives under More.
|
||||
expect(screen.queryByLabelText(/Share with/)).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: /^More/ }))
|
||||
expect(screen.getByLabelText(/Share with/)).not.toBeChecked()
|
||||
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
|
||||
|
||||
|
|
@ -157,6 +160,32 @@ describe('CustomQuizPage', () => {
|
|||
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
|
||||
})
|
||||
|
||||
it('keeps the count in the criteria card and mode plus Start in a sticky bar', async () => {
|
||||
renderBuilder()
|
||||
await screen.findByText('30 questions available')
|
||||
|
||||
// Question count sits inside the criteria card, shown as "20 /30".
|
||||
const countInput = screen.getByLabelText('Number of questions')
|
||||
expect(countInput.closest('.custom-test-card')).toContainElement(screen.getByRole('heading', { name: 'Test criteria' }))
|
||||
expect(screen.getByText('/30')).toBeInTheDocument()
|
||||
|
||||
// Mode and the submit button live in the sticky bar, not in the cards.
|
||||
const bar = document.querySelector('.custom-test-bar')
|
||||
expect(bar).toBeInTheDocument()
|
||||
expect(bar).toContainElement(screen.getByRole('radio', { name: 'Study mode' }))
|
||||
expect(bar).toContainElement(screen.getByRole('radio', { name: 'Exam mode' }))
|
||||
expect(bar).toContainElement(screen.getByRole('button', { name: 'Create Test' }))
|
||||
})
|
||||
|
||||
it('reveals the time limit under More once exam mode is picked', async () => {
|
||||
renderBuilder()
|
||||
await screen.findByText('30 questions available')
|
||||
await userEvent.click(screen.getByRole('button', { name: /^More/ }))
|
||||
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Exam mode' }))
|
||||
expect(screen.getByLabelText(/Time limit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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 }))
|
||||
|
|
|
|||
Loading…
Reference in a new issue