feat: a QBank door on every article, which says why it is empty

The offer to practise a topic was a paragraph under the last section: it
appeared only where questions happened to be linked, explained itself at length
to everybody including the reader who never wanted questions, and sat where
nobody looks.

Now it is a button in the controls row, on every article whether or not
anything is linked — and that is the point of it. A reader's question is "are
there questions on this?", and a control that vanishes when the answer is no
cannot answer it. With nothing linked it opens a dialog that names the study
objective, says there is nothing for *that* objective, and offers a custom
session; the objective is usually the whole reason, and a learner who cannot
see which one they are on has no way to tell that from "nothing exists on this
topic at all". With questions behind it, it is the builder it always was.

Reading pages still never show a stem, an answer or an explanation.

Also: the two selects stack and the dialog scrolls inside itself under 520px,
and every control is left to the site-wide 16px rule for coarse pointers — a
field under 16px makes iOS Safari zoom in on focus and never zoom back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 19:04:51 +02:00
parent ce8074cbe3
commit 1be6d10550
8 changed files with 342 additions and 142 deletions

View file

@ -101,6 +101,10 @@ export default function ArticleReader({
// report is the page's business and fetches its own; the reader knows the
// shape of the article and nothing about who is reading it.
sectionTools = null,
// The QBank door, drawn in the controls row beside the reading switches.
// Passed in rather than built here for the same reason as the toolbar: what
// is behind it depends on who is reading, which the reader does not know.
qbank = null,
children,
}) {
// Which sections are open. Everything starts closed: an article is a reference
@ -317,12 +321,18 @@ export default function ArticleReader({
{hasSummary && fallback && (
/* "High yield", not "Summary": the section it shows is already called
Summary, and a control naming the thing it reveals reads as a label
for it rather than a switch over it. This says what it is for. */
for it rather than a switch over it. This says what it is for.
And no "on"/"off" after it. A switch that is lit is on saying so
in words as well makes the reader parse a sentence to learn what the
colour already told them, and "High yield off" reads for a moment
like a thing you could be looking at. `aria-pressed` carries the
state for anyone who cannot see the lit state. */
<button type="button" className={`aview-only${view === SUMMARY ? ' is-on' : ''}`}
aria-pressed={view === SUMMARY}
onClick={() => choose(view === SUMMARY ? fallback : SUMMARY)}>
<span aria-hidden="true"></span>
High yield {view === SUMMARY ? 'on' : 'off'}
High yield
</button>
)}
{full.length > 1 && (
@ -423,6 +433,7 @@ export default function ArticleReader({
article you have got to once the title has gone. */}
<div className="asec-controls">
<div className="asec-controls-left">
{qbank}
<button type="button" className="article-drawer-toggle" onClick={() => setDrawerOpen(v => !v)}
aria-expanded={drawerOpen} aria-controls={`${idPrefix}article-sections`}>
{drawerOpen ? '✕ Close contents' : '☰ Contents'}

View file

@ -1,109 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
const COUNTS = [5, 10, 20, 30]
/**
* Turns an article into a test instead of listing its questions.
*
* Reading pages must not show the questions themselves: the linked list used to
* print every stem with its correct answer and explanation, which spoiled the
* questions before the learner ever attempted them.
*
* A learner's control, and only that. It also carried the educator's list of
* linked questions for a while, which put a "practise this" offer in front of
* the person maintaining the article; managing those links belongs in the
* article editor, beside the rest of what they change.
*/
export default function PractiseTopic({ article }) {
const [available, setAvailable] = useState(null)
const [count, setCount] = useState(10)
const [mode, setMode] = useState('learning')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
useEffect(() => {
let active = true
api.get('/questions/builder/count', { params: { article_ids: article.id, state: 'all' } })
.then(res => { if (active) setAvailable(res.data.count) })
.catch(() => { if (active) setAvailable(null) })
return () => { active = false }
}, [article.id])
const create = async () => {
setBusy(true)
setError('')
try {
const res = await api.post('/questions/builder', {
title: `${article.title} — practice`,
category_ids: [], state: 'all', count: Math.min(count, available),
expected_count: available, mode, time_limit_minutes: null, is_shared: false,
difficulty: null, algorithm: 'random',
article_ids: [article.id], tag_ids: [], explicit_ids: [],
})
navigate(`/study/${res.data.id}`)
} catch (err) {
const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Could not create a test from this topic.')
} finally { setBusy(false) }
}
// The count never came back. Nothing to say about a number we do not have.
if (available === null) return null
// No questions, said rather than hidden. It used to render nothing at all,
// which reads as a page that has not finished loading and a reader who has
// just read the article is the one person entitled to know why there is
// nothing to sit on it. Reading is open to everyone; questions are drawn
// from the bank for the exam you are studying for, and both of those facts
// are worth stating here rather than leaving somebody to infer them.
if (!available) {
return (
<section className="article-practise is-empty">
<h3>Nothing to practise here yet</h3>
<p>
No questions in your bank are linked to this topic. Reading is open to
everyone; questions are drawn from the bank for the exam you are
studying for, so a topic can be readable long before it is examinable.
</p>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => navigate('/study/new')}>
Build a session on something else
</button>
</section>
)
}
return (
<section className="article-practise">
<div className="article-practise-head">
<h3>Practise this topic</h3>
<span className="article-practise-count">
{available} question{available === 1 ? '' : 's'} linked
</span>
</div>
<div className="article-practise-controls">
<label>
<span>Questions</span>
<select value={count} onChange={e => setCount(Number(e.target.value))} aria-label="Questions">
{COUNTS.filter(n => n <= available).map(n => <option key={n} value={n}>{n}</option>)}
{!COUNTS.includes(available) && <option value={available}>All {available}</option>}
</select>
</label>
<label>
<span>Mode</span>
<select value={mode} onChange={e => setMode(e.target.value)} aria-label="Mode">
<option value="learning">Study</option>
<option value="timed">Exam</option>
</select>
</label>
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
{busy ? 'Creating…' : 'Create test'}
</button>
</div>
{error && <p className="article-practise-error" role="alert">{error}</p>}
</section>
)
}

View file

@ -0,0 +1,79 @@
/* The QBank door on an article, and what opens behind it. */
.qbank-btn {
display: inline-flex; align-items: center; gap: 7px;
min-height: 34px; padding: 6px 13px; cursor: pointer;
font: inherit; font-size: 0.83rem; font-weight: 600;
color: var(--primary); background: color-mix(in srgb, var(--primary) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--primary) 35%, transparent); border-radius: 8px;
}
.qbank-btn:hover { background: color-mix(in srgb, var(--primary) 13%, transparent); }
.qbank-btn span { font-size: 0.95rem; line-height: 1; }
.qbank-overlay {
position: fixed; inset: 0; z-index: 1000; padding: 16px;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 23, 42, .55);
}
.qbank {
position: relative; width: 100%; max-width: 520px;
padding: 26px 28px 22px; border-radius: 14px;
background: var(--card-bg); box-shadow: 0 24px 60px rgba(15, 23, 42, .35);
}
.qbank h2 { margin: 0 0 4px; font-size: 1.15rem; letter-spacing: -0.01em; }
.qbank-sub { margin: 0 0 18px; font-size: 0.9rem; font-weight: 600; color: var(--text-muted); }
.qbank-count { margin: 0 0 14px; font-size: 0.86rem; color: var(--text-muted); }
.qbank-close {
position: absolute; top: 12px; right: 12px;
width: 30px; height: 30px; border: 0; border-radius: 8px; cursor: pointer;
background: none; color: var(--text-muted); font-size: 0.95rem;
}
.qbank-close:hover { background: var(--bg); color: var(--text); }
.qbank-objective {
padding-top: 14px; border-top: 1px solid var(--border);
display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px;
}
.qbank-objective span {
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-muted);
}
.qbank-objective a { font-size: 0.95rem; font-weight: 600; color: var(--primary); }
/* The explanation, boxed and quiet: it is the answer to "why is this empty",
not an announcement. */
.qbank-note {
padding: 13px 15px; border-radius: 9px; margin-bottom: 18px;
background: color-mix(in srgb, var(--primary) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent);
}
.qbank-note p { margin: 0; font-size: 0.86rem; line-height: 1.55; color: var(--text); }
.qbank-note p + p { margin-top: 6px; color: var(--text-muted); }
.qbank-controls { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 16px; }
.qbank-controls label { display: flex; flex-direction: column; gap: 5px; font-size: 0.78rem; color: var(--text-muted); }
.qbank-controls select {
min-height: 36px; padding: 6px 10px; font: inherit; font-size: 0.88rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text);
}
.qbank-error { margin: 0 0 12px; font-size: 0.84rem; color: var(--wrong-fg); }
.qbank-actions { display: flex; justify-content: flex-end; }
/* Narrow screens. The dialog keeps its own scroll rather than growing past the
window a phone in landscape is 380px tall and the button was below it
and the controls stack instead of squeezing two selects onto one line.
Field sizes are left alone: the site-wide `pointer: coarse` rule puts every
control at 16px, which is what stops iOS Safari zooming in on focus and
never zooming back out. */
@media (max-width: 520px) {
.qbank-overlay { padding: 10px; align-items: flex-end; }
.qbank {
padding: 20px 18px 18px; border-radius: 14px 14px 10px 10px;
max-height: 88dvh; overflow-y: auto;
}
.qbank h2 { font-size: 1.05rem; padding-right: 28px; }
.qbank-controls { flex-direction: column; gap: 10px; }
.qbank-controls label { width: 100%; }
.qbank-controls select { width: 100%; }
.qbank-actions .btn { width: 100%; }
}

View file

@ -0,0 +1,165 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import './QBank.css'
const COUNTS = [5, 10, 20, 30]
/**
* The questions behind a topic, reached from the article itself.
*
* Reading pages must never show the questions: the linked list once printed
* every stem with its correct answer and explanation, which spoiled them
* before the learner had attempted anything. So this is a door, not a list.
*
* It is on every article, whether or not anything is linked, and that is the
* point of it. A button that appears only when there happen to be questions
* teaches a reader to scan for it; one that is always there, and says plainly
* why it has nothing for them, answers the question they actually have
* *are* there questions on this, and if not, why not. What they are studying
* for is usually the reason, so the dialog names it and offers the way round
* it rather than leaving them to work that out.
*
* A paragraph under the article used to carry this. It explained itself at
* length to everybody, including the reader who never wanted questions, and it
* sat where nobody looks after the last section.
*/
export default function QBank({ article }) {
//: null while unknown, then the number this learner may actually be shown
//: the count is scoped by the same exam predicate the builder uses, so it is
//: never a promise the session cannot keep.
const [available, setAvailable] = useState(null)
const [open, setOpen] = useState(false)
const [count, setCount] = useState(10)
const [mode, setMode] = useState('learning')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [objective, setObjective] = useState(null)
const navigate = useNavigate()
useEffect(() => {
let active = true
api.get('/questions/builder/count', { params: { article_ids: article.id, state: 'all' } })
.then(res => { if (active) setAvailable(res.data.count) })
.catch(() => { if (active) setAvailable(0) })
return () => { active = false }
}, [article.id])
// Asked for only when there is nothing to practise, because that is the only
// dialog that names it and it is the likeliest reason for the nothing.
useEffect(() => {
if (!open || available || objective !== null) return
api.get('/exams/')
.then(res => {
const active = (res.data.exams || []).find(e => e.id === res.data.active_exam_id)
setObjective(active?.name || '')
})
.catch(() => setObjective(''))
}, [open, available, objective])
useEffect(() => {
if (!open) return undefined
const onKey = e => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
const create = async () => {
setBusy(true)
setError('')
try {
const res = await api.post('/questions/builder', {
title: `${article.title} — practice`,
category_ids: [], state: 'all', count: Math.min(count, available),
expected_count: available, mode, time_limit_minutes: null, is_shared: false,
difficulty: null, algorithm: 'random',
article_ids: [article.id], tag_ids: [], explicit_ids: [],
})
navigate(`/study/${res.data.id}`)
} catch (err) {
const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Could not create a test from this topic.')
} finally { setBusy(false) }
}
// Until the count is known the button would be a guess about what it opens.
if (available === null) return null
return (
<>
<button type="button" className="qbank-btn" onClick={() => setOpen(true)}>
<span aria-hidden="true"></span> QBank
</button>
{open && (
<div className="qbank-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
<div className="qbank" role="dialog" aria-modal="true" aria-labelledby="qbank-heading">
<button type="button" className="qbank-close" aria-label="Close"
onClick={() => setOpen(false)}></button>
{available > 0 ? (
<>
<h2 id="qbank-heading">Practise this topic</h2>
<p className="qbank-sub">{article.title}</p>
<p className="qbank-count">
{available} question{available === 1 ? '' : 's'} linked
</p>
<div className="qbank-controls">
<label>
<span>Questions</span>
<select value={count} onChange={e => setCount(Number(e.target.value))} aria-label="Questions">
{COUNTS.filter(n => n <= available).map(n => <option key={n} value={n}>{n}</option>)}
{!COUNTS.includes(available) && <option value={available}>All {available}</option>}
</select>
</label>
<label>
<span>Mode</span>
<select value={mode} onChange={e => setMode(e.target.value)} aria-label="Mode">
<option value="learning">Study</option>
<option value="timed">Exam</option>
</select>
</label>
</div>
{error && <p className="qbank-error" role="alert">{error}</p>}
<div className="qbank-actions">
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
{busy ? 'Creating…' : 'Create test'}
</button>
</div>
</>
) : (
<>
<h2 id="qbank-heading">No matching questions for this article</h2>
<p className="qbank-sub">{article.title}</p>
{/* Named, and linked to where it is changed. The objective is
usually the whole reason there is nothing here, and a
reader who cannot see which one they are on has no way to
tell that from "this topic has no questions at all". */}
{objective ? (
<div className="qbank-objective">
<span>Study objective</span>
<Link to="/settings" onClick={() => setOpen(false)}>{objective}</Link>
</div>
) : null}
<div className="qbank-note">
<p>
{objective
? <>There are no questions for this article that meet your study objective <strong>{objective}</strong> criteria.</>
: <>There are no questions linked to this article yet.</>}
</p>
<p>Create a custom session with related articles, disciplines, or systems.</p>
</div>
<div className="qbank-actions">
<button type="button" className="btn btn-primary"
onClick={() => { setOpen(false); navigate('/study/new') }}>
Create a custom session
</button>
</div>
</>
)}
</div>
</div>
)}
</>
)
}

View file

@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { expect, it, vi, beforeEach } from 'vitest'
import PractiseTopic from './PractiseTopic'
import QBank from './QBank'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
@ -12,33 +12,38 @@ const ARTICLE = { id: 7, title: 'Kawasaki disease', sections: [{ id: 'sec1', tit
const mount = () => render(
<MemoryRouter initialEntries={['/articles/7']}>
<Routes>
<Route path="/articles/7" element={<PractiseTopic article={ARTICLE} />} />
<Route path="/articles/7" element={<QBank article={ARTICLE} />} />
<Route path="/study/:id" element={<h1>Test ready</h1>} />
<Route path="/study/new" element={<h1>Custom session</h1>} />
</Routes>
</MemoryRouter>
)
const answers = (count, exams) => api.get.mockImplementation(url =>
url === '/exams/'
? Promise.resolve({ data: exams })
: Promise.resolve({ data: { count } }))
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: { count: 12 } })
answers(12, { exams: [], active_exam_id: null })
})
it('offers a test instead of listing the questions and their answers', async () => {
mount()
await userEvent.click(await screen.findByRole('button', { name: /QBank/ }))
expect(await screen.findByText('12 questions linked')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create test' })).toBeInTheDocument()
// Reading the article must never reveal a stem, its answer or its explanation.
expect(screen.queryByText(/A 3-year-old with 6 days of fever/)).not.toBeInTheDocument()
expect(screen.queryByText(/IVIG/)).not.toBeInTheDocument()
expect(screen.queryByText(/Because/)).not.toBeInTheDocument()
// Nor is it a place to manage links; that is the article editor's job.
expect(screen.queryByRole('button', { name: /Linked questions/ })).not.toBeInTheDocument()
})
it('builds a test scoped to this article', async () => {
api.post.mockResolvedValue({ data: { id: 55 } })
mount()
await userEvent.click(await screen.findByRole('button', { name: /QBank/ }))
await screen.findByText('12 questions linked')
await userEvent.selectOptions(screen.getByLabelText('Questions'), '12') // the "All 12" option
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
@ -53,14 +58,31 @@ it('builds a test scoped to this article', async () => {
it('surfaces a builder failure without navigating', async () => {
api.post.mockRejectedValue({ response: { data: { detail: 'Available count changed. Refresh and try again' } } })
mount()
await userEvent.click(await screen.findByRole('button', { name: /QBank/ }))
await screen.findByText('12 questions linked')
await userEvent.click(screen.getByRole('button', { name: 'Create test' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Available count changed')
expect(screen.queryByRole('heading', { name: 'Test ready' })).not.toBeInTheDocument()
})
it('stays hidden for a learner when nothing is linked', async () => {
api.get.mockResolvedValue({ data: { count: 0 } })
const { container } = mount()
await waitFor(() => expect(container).toBeEmptyDOMElement())
it('is still there with nothing linked, and names the objective that is why', async () => {
answers(0, { exams: [{ id: 3, name: 'USMLE Step 2 CK' }], active_exam_id: 3 })
mount()
// The door is on every article. A button that appears only where there
// happen to be questions cannot answer "are there any?".
await userEvent.click(await screen.findByRole('button', { name: /QBank/ }))
expect(await screen.findByRole('heading', { name: 'No matching questions for this article' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'USMLE Step 2 CK' })).toBeInTheDocument()
expect(screen.getByText(/no questions for this article that meet your study objective/i)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Create a custom session' }))
expect(await screen.findByRole('heading', { name: 'Custom session' })).toBeInTheDocument()
})
it('says the plain thing when no objective is set', async () => {
answers(0, { exams: [], active_exam_id: null })
mount()
await userEvent.click(await screen.findByRole('button', { name: /QBank/ }))
expect(await screen.findByText('There are no questions linked to this article yet.')).toBeInTheDocument()
expect(screen.queryByText(/study objective/i)).not.toBeInTheDocument()
})

View file

@ -197,12 +197,6 @@
/* ── Practise this topic ──────────────────────────────────────────── */
.article-practise { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; }
/* Quieter than the offer it replaces: it is an explanation, not a control. */
.article-practise.is-empty h3 { margin: 0 0 6px; font-size: 0.95rem; }
.article-practise.is-empty p {
margin: 0 0 12px; max-width: 62ch;
font-size: 0.86rem; line-height: 1.6; color: var(--text-muted);
}
.article-practise-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.article-practise-head h3 { margin: 0; font-size: 1rem; }
.article-practise-count { font-size: .78rem; color: var(--text-muted); }
@ -335,7 +329,9 @@
background: color-mix(in srgb, var(--primary) 8%, transparent);
}
.aview-switch { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
/* Six pixels, not two. At two the selected tab's fill ran straight into the
one beside it and the pair read as one wide control with a coloured half. */
.aview-switch { display: inline-flex; gap: 6px; padding: 3px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
.aview {
min-height: 34px; padding: 6px 13px; border: 0; border-radius: 7px; cursor: pointer;
background: none; font: inherit; font-size: 0.83rem; font-weight: 600; color: var(--text-muted);
@ -371,3 +367,28 @@
/* A view that is one section is shown as prose: no heading repeating the tab
above it, no chevron hiding the only thing there is to read. */
.asec-sole { padding-top: 4px; }
/* Coming from Editorial to write one. The queue is where that person was
working; this page is a library, and without a way back the only route is
the browser's own button. */
.articles-back {
display: inline-block; margin-bottom: 6px;
font-size: 0.82rem; font-weight: 600; color: var(--text-muted); text-decoration: none;
}
.articles-back:hover { color: var(--primary); }
.articles-form-actions { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
/* A link, not a third button: leaving is not the same weight as the two
decisions about the form in front of it. */
.articles-leave {
margin-left: auto; font-size: 0.82rem; font-weight: 600;
color: var(--text-muted); text-decoration: none;
}
.articles-leave:hover { color: var(--primary); text-decoration: underline; }
@media (max-width: 560px) {
/* Three controls on one line becomes two lines rather than three squeezed
ones, and the way out stops being pushed off the right edge. */
.articles-form-actions { flex-wrap: wrap; }
.articles-leave { margin-left: 0; width: 100%; }
}

View file

@ -12,7 +12,7 @@ import { resolveArticleId } from '../components/ArticleLink'
import ArticleReader from '../components/ArticleReader'
import ArticleSaveButton from '../components/ArticleSaveButton'
import ArticleSplitPane from '../components/ArticleSplitPane'
import PractiseTopic from '../components/PractiseTopic'
import QBank from '../components/QBank'
import SectionNote, { OrphanedNotes } from '../components/SectionNote'
import ArticleFeedbackPanel from '../components/ArticleFeedbackPanel'
import { plainWikiText } from '../components/RichText'
@ -39,6 +39,11 @@ export default function ArticlesPage() {
const [params] = useSearchParams()
const [showCreate, setShowCreate] = useState(params.get('new') === '1')
const [showAi, setShowAi] = useState(params.get('draft') === '1')
// Whether this visit came from Editorial meaning to write. Read once, from
// the parameter that opened the form: somebody sent here to start an article
// needs the way back to the queue they came from, and cancelling a form is
// not the same as being finished with the errand.
const [cameToWrite] = useState(params.get('new') === '1' || params.get('draft') === '1')
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [error, setError] = useState('')
@ -97,6 +102,9 @@ export default function ArticlesPage() {
<div className="articles-page">
<div className="articles-header">
<div>
{cameToWrite && (
<Link to="/editorial" className="articles-back"> Editorial</Link>
)}
<h1>Topic reading</h1>
<p className="articles-subtitle">Educator-reviewed articles linked to questions and cards.</p>
</div>
@ -116,9 +124,13 @@ export default function ArticlesPage() {
<label className="form-label" htmlFor="ai-instructions">Instructions (optional)</label>
<textarea id="ai-instructions" className="input" rows={2} value={aiInstructions} onChange={e => setAiInstructions(e.target.value)} placeholder="Include an initial workup section" />
{aiStatus && <p className="articles-subtitle" role="status">{aiStatus}</p>}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<div className="articles-form-actions">
<button className="btn btn-primary btn-sm" onClick={aiDraft} disabled={!!aiStatus}>Draft article</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowAi(false)}>Cancel</button>
{/* Cancel shuts the form and leaves you on a page about reading.
This is the way back to the queue, which is where somebody who
came here to write was actually working. */}
<Link to="/editorial" className="articles-leave">Back to editorial</Link>
</div>
</div>
)}
@ -130,9 +142,10 @@ export default function ArticlesPage() {
<label className="form-label" htmlFor="article-slug">Slug (lowercase, hyphens)</label>
<input id="article-slug" className="input" value={slug} onChange={e => setSlug(e.target.value)} placeholder="febrile-seizures" />
{error && <div className="form-error" role="alert">{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<div className="articles-form-actions">
<button className="btn btn-primary btn-sm" onClick={create}>Create draft</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreate(false)}>Cancel</button>
<Link to="/editorial" className="articles-leave">Back to editorial</Link>
</div>
</div>
)}
@ -408,6 +421,7 @@ export function ArticlePage() {
{editorActions}
</>
}
qbank={<QBank article={article} />}
aside={splitSlug ? (
<SplitViewProvider value={paneView}>
<ArticleSplitPane slug={splitSlug} depth={splitTrail.length - 1}
@ -415,10 +429,6 @@ export function ArticlePage() {
onClose={() => setSplitTrail([])} />
</SplitViewProvider>
) : null}>
{/* Reading, not editing. Whoever maintains this article is not being
told to sit its questions their linked-question list lives in the
editor now, beside everything else they change here. */}
{!canEdit && <PractiseTopic article={article} />}
<OrphanedNotes articleId={article.id} notes={notes} onChanged={loadNotes} />
{/* Beside the prose it is about, not in a queue elsewhere: almost
every report is answered by an edit to this page. */}

View file

@ -116,8 +116,9 @@ describe('topic reading', () => {
expect(screen.queryByRole('button', { name: /Expand all|Collapse all/ })).not.toBeInTheDocument()
expect(document.querySelector('.article-sections .atoc')).toBeNull()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// Reading a topic offers a test; it never prints the stem, answer or explanation.
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
// Reading a topic offers a test behind the QBank door; it never prints the
// stem, answer or explanation on the page itself.
expect(await screen.findByRole('button', { name: /QBank/ })).toBeInTheDocument()
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
expect(screen.queryByText('Why')).not.toBeInTheDocument()
})
@ -272,18 +273,18 @@ describe('topic reading', () => {
const toc = () => document.querySelector('.article-sections')
expect(within(toc()).getByRole('button', { name: 'Key points' })).toBeInTheDocument()
expect(within(toc()).queryByRole('button', { name: 'Pathophysiology' })).toBeNull()
// It names its own state, so a reader can tell why two thirds of the
// contents are not there.
const summary = () => screen.getByRole('button', { name: /^High yield (on|off)$/ })
// The label is the same in both states it is lit or it is not. What it
// must still say out loud is which of the two it is in, so a reader who
// cannot see the lit state can tell why two thirds of the contents are
// not there.
const summary = () => screen.getByRole('button', { name: 'High yield' })
expect(summary()).toHaveAttribute('aria-pressed', 'true')
expect(summary()).toHaveTextContent('High yield on')
await userEvent.click(summary())
expect(within(toc()).getByRole('button', { name: 'Pathophysiology' })).toBeInTheDocument()
expect(within(toc()).queryByRole('button', { name: 'Key points' })).toBeNull()
expect(screen.queryByText('Key body')).not.toBeInTheDocument()
expect(summary()).toHaveAttribute('aria-pressed', 'false')
expect(summary()).toHaveTextContent('High yield off')
})
it('nests a sub-section under its parent and keeps references last', async () => {