feat: practise a topic from its article; tidy the lab panel
Article page — cross-checked against AMBOSS's Library and article pages, which drill Library → Clinical knowledge → Pediatrics → topic → article and never list a topic's questions. You reach questions the other way, via "Start Qbank". Ours printed every linked question on the reading page with its correct answer and explanation, so opening an article spoiled its questions before the learner ever attempted them. Replaced with a "Practise this topic" panel: how many questions are linked, a count and mode picker, and a Create test button that builds a test scoped to the article. Educators keep a collapsible membership list for unlinking — stems only, no answers. Lab values panel: - Removed the Sources footer and its article deep links, as asked. - One weight per row: the test name carries it, the range no longer competes. Ranges use tabular figures and the age column lines up in a grid instead of wrapping raggedly; the reference note no longer promises source links. - Dropped the CSS for markup that no longer exists. Tests: 5 new frontend tests for the practise panel (offers a test without revealing stems/answers/explanations, article-scoped builder payload, error handling, hidden for learners when nothing is linked, educator unlink); the article and lab tests now assert those leaks are gone. Full suites green: 88 backend, 127 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
1f2d0a81a1
commit
d893b77cc8
8 changed files with 259 additions and 55 deletions
113
frontend/src/components/PractiseTopic.jsx
Normal file
113
frontend/src/components/PractiseTopic.jsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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. Educators still get the
|
||||
* membership list so they can unlink, but without the answers.
|
||||
*/
|
||||
export default function PractiseTopic({ article, canEdit, questions, onUnlink }) {
|
||||
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 [listOpen, setListOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.get('/questions/builder/count', { params: { article_ids: article.id, state: 'all', is_shared: 'false' } })
|
||||
.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(`/quizzes/${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) }
|
||||
}
|
||||
|
||||
if (!available && !canEdit) return null
|
||||
|
||||
return (
|
||||
<section className="article-practise">
|
||||
<div className="article-practise-head">
|
||||
<h3>Practise this topic</h3>
|
||||
<span className="article-practise-count">
|
||||
{available === null ? 'Counting…' : `${available} question${available === 1 ? '' : 's'} linked`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{available === 0 ? (
|
||||
<p className="article-practise-empty">
|
||||
No questions are linked to this article yet{canEdit ? ' — link some below.' : '.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<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 => available === null || n <= available).map(n => <option key={n} value={n}>{n}</option>)}
|
||||
{available !== null && !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 || !available} onClick={create}>
|
||||
{busy ? 'Creating…' : 'Create test'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="article-practise-error" role="alert">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && questions.length > 0 && (
|
||||
<div className="article-practise-manage">
|
||||
<button type="button" aria-expanded={listOpen} onClick={() => setListOpen(v => !v)}>
|
||||
Linked questions ({questions.length}) {listOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
{listOpen && (
|
||||
<ul>
|
||||
{questions.map(q => (
|
||||
<li key={`${q.question_id}-${q.section_id}`}>
|
||||
<span>{q.question_text.slice(0, 110)}{q.question_text.length > 110 ? '…' : ''}</span>
|
||||
{q.section_id && (
|
||||
<em>{(article.sections || []).find(s => s.id === q.section_id)?.title || 'removed section'}</em>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm btn-secondary"
|
||||
onClick={() => onUnlink(q.question_id, q.section_id)}>Unlink</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
84
frontend/src/components/PractiseTopic.test.jsx
Normal file
84
frontend/src/components/PractiseTopic.test.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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 api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
const ARTICLE = { id: 7, title: 'Kawasaki disease', sections: [{ id: 'sec1', title: 'Clinical features' }] }
|
||||
const QUESTIONS = [
|
||||
{ question_id: 1, section_id: null, question_text: 'A 3-year-old with 6 days of fever…', correct_answer: 'IVIG', explanation: 'Because…' },
|
||||
{ question_id: 2, section_id: 'sec1', question_text: 'Which feature is required?', correct_answer: 'Fever', explanation: 'Because…' },
|
||||
]
|
||||
|
||||
const mount = (props = {}) => render(
|
||||
<MemoryRouter initialEntries={['/articles/7']}>
|
||||
<Routes>
|
||||
<Route path="/articles/7" element={
|
||||
<PractiseTopic article={ARTICLE} canEdit={false} questions={QUESTIONS} onUnlink={vi.fn()} {...props} />} />
|
||||
<Route path="/quizzes/:id" element={<h1>Test ready</h1>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: { count: 12 } })
|
||||
})
|
||||
|
||||
it('offers a test instead of listing the questions and their answers', async () => {
|
||||
mount()
|
||||
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()
|
||||
})
|
||||
|
||||
it('builds a test scoped to this article', async () => {
|
||||
api.post.mockResolvedValue({ data: { id: 55 } })
|
||||
mount()
|
||||
await screen.findByText('12 questions linked')
|
||||
await userEvent.selectOptions(screen.getByLabelText('Questions'), '12') // the "All 12" option
|
||||
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create test' }))
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/questions/builder', expect.objectContaining({
|
||||
title: 'Kawasaki disease — practice', article_ids: [7], count: 12, expected_count: 12, mode: 'timed',
|
||||
})))
|
||||
expect(await screen.findByRole('heading', { name: 'Test ready' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces a builder failure without navigating', async () => {
|
||||
api.post.mockRejectedValue({ response: { data: { detail: 'Available count changed. Refresh and try again' } } })
|
||||
mount()
|
||||
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('lets an educator review and unlink without showing answers', async () => {
|
||||
api.get.mockResolvedValue({ data: { count: 0 } })
|
||||
const onUnlink = vi.fn()
|
||||
mount({ canEdit: true, onUnlink })
|
||||
expect(await screen.findByText(/No questions are linked/)).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Linked questions \(2\)/ }))
|
||||
expect(screen.getByText(/A 3-year-old with 6 days of fever/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('IVIG')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Clinical features')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Unlink' })[0])
|
||||
expect(onUnlink).toHaveBeenCalledWith(1, null)
|
||||
})
|
||||
|
|
@ -38,15 +38,14 @@
|
|||
.quiz-lab-group { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.quiz-lab-group-title { margin: 0; padding: 8px 14px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--primary); background: var(--primary-soft, #eef4fb); border-bottom: 1px solid var(--border); }
|
||||
.quiz-lab-rows { list-style: none; margin: 0; padding: 0; }
|
||||
.quiz-lab-row { display: grid; grid-template-columns: minmax(140px, 1.2fr) minmax(130px, 1fr) minmax(110px, .9fr) minmax(140px, 1.1fr); gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--border); font-size: .84rem; align-items: center; }
|
||||
.quiz-lab-row { display: grid; gap: 6px 16px; padding: 11px 14px; border-bottom: 1px solid var(--border); font-size: .84rem; align-items: start; }
|
||||
.quiz-lab-row:last-child { border-bottom: none; }
|
||||
.quiz-lab-name strong { display: block; font-size: .88rem; }
|
||||
.quiz-lab-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.quiz-lab-test { font-size: .88rem; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.quiz-lab-name small { color: var(--text-muted); font-size: .72rem; }
|
||||
.quiz-lab-range strong { font-size: .9rem; }
|
||||
.quiz-lab-name .article-status-draft { align-self: flex-start; margin: 2px 0 0; }
|
||||
.quiz-lab-range { font-size: .88rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.quiz-lab-units { color: var(--text-muted); font-size: .74rem; margin-left: 5px; }
|
||||
.quiz-lab-pop { color: var(--text-muted); }
|
||||
.quiz-lab-source a { color: var(--primary); text-decoration: none; font-size: .78rem; }
|
||||
.quiz-lab-source a:hover { text-decoration: underline; }
|
||||
.quiz-lab-cards { display: flex; gap: 5px; flex-wrap: wrap; grid-column: 1 / -1; padding-top: 4px; }
|
||||
.quiz-lab-card { display: inline-flex; align-items: center; gap: 5px; font-size: .72rem; background: var(--bg, #f1f5f9); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; }
|
||||
.quiz-lab-card a { color: var(--primary); text-decoration: none; }
|
||||
|
|
@ -57,15 +56,11 @@
|
|||
.quiz-lab-actions { display: flex; gap: 6px; flex-wrap: wrap; grid-column: 1 / -1; }
|
||||
.quiz-lab-actions button { font-size: .72rem; }
|
||||
@media (max-width: 640px) {
|
||||
.quiz-lab-row { grid-template-columns: 1fr 1fr; }
|
||||
.quiz-lab-source, .quiz-lab-actions, .quiz-lab-cards { grid-column: 1 / -1; }
|
||||
.quiz-lab-pop { grid-column: 1 / -1; }
|
||||
.quiz-lab-row { grid-template-columns: 1fr; }
|
||||
.quiz-lab-actions, .quiz-lab-cards { grid-column: 1 / -1; }
|
||||
}
|
||||
.quiz-labs-sources { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); display: flex; gap: 8px 14px; flex-wrap: wrap; align-items: baseline; font-size: .72rem; color: var(--text-muted); }
|
||||
.quiz-labs-sources a { color: var(--primary); text-decoration: none; }
|
||||
.quiz-labs-sources a:hover { text-decoration: underline; }
|
||||
.quiz-lab-values { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.quiz-lab-age-line { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 3px 0; border-bottom: 1px dashed var(--border); }
|
||||
.quiz-lab-age-line { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 4px 12px; padding: 4px 0; border-bottom: 1px solid var(--border); }
|
||||
.quiz-lab-age-line:last-child { border-bottom: none; }
|
||||
.quiz-lab-age { color: var(--text-muted); font-size: .78rem; min-width: 130px; }
|
||||
.quiz-lab-row { grid-template-columns: minmax(150px, 1fr) minmax(200px, 1.6fr); }
|
||||
.quiz-lab-age { color: var(--text-muted); font-size: .78rem; min-width: 0; overflow-wrap: anywhere; }
|
||||
.quiz-lab-row { grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.5fr); }
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ function LabValues() {
|
|||
const groups = ['All', ...new Set(rows.map(row => row.group))]
|
||||
const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
|
||||
return <>
|
||||
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Sources link straight to the cited document — open the section or the full source before relying on a value.</p>
|
||||
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.</p>
|
||||
{isEducator && <label className="quiz-check"><input type="checkbox" checked={manage} onChange={e => { setManage(e.target.checked); setForm(null); setGroup('All') }} /> Manage references and drafts</label>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{linkError && <p role="alert">{linkError}</p>}
|
||||
|
|
@ -142,14 +142,15 @@ function LabValues() {
|
|||
<ul className="quiz-lab-rows">
|
||||
{tests.map(test => <li key={test.name} className="quiz-lab-row">
|
||||
<div className="quiz-lab-name">
|
||||
<strong>{test.name}{test.rows.some(r => !r.is_published) && <em className="article-status-draft"> draft</em>}</strong>
|
||||
<span className="quiz-lab-test">{test.name}</span>
|
||||
{test.rows.some(r => !r.is_published) && <em className="article-status-draft">draft</em>}
|
||||
<small>{test.rows[0].specimen}</small>
|
||||
</div>
|
||||
<div className="quiz-lab-values">
|
||||
{test.rows.map(row => (
|
||||
<div key={row.id} className="quiz-lab-age-line">
|
||||
<span className="quiz-lab-age">{row.age_group}</span>
|
||||
<span className="quiz-lab-range"><strong>{row.reference_range}</strong>{row.units && <span className="quiz-lab-units">{row.units}</span>}</span>
|
||||
<span className="quiz-lab-range">{row.reference_range}{row.units && <span className="quiz-lab-units">{row.units}</span>}</span>
|
||||
{manage && <span className="quiz-lab-actions">
|
||||
<button type="button" onClick={() => setForm({ ...row, source_url: row.source_url || '', article_id: row.article_id || '', article_section_id: row.article_section_id || '' })}>Edit</button>
|
||||
{removeId === row.id ? <>
|
||||
|
|
@ -180,17 +181,6 @@ function LabValues() {
|
|||
</ul>
|
||||
</section>
|
||||
})}
|
||||
<footer className="quiz-labs-sources">
|
||||
<strong>Sources</strong>
|
||||
{[...new Map(rows.map(row => [row.source, row])).values()].map(row => (
|
||||
<a key={row.source} href={row.source_url || undefined} target="_blank" rel="noopener noreferrer">{row.source}</a>
|
||||
))}
|
||||
{[...new Map(rows.filter(row => row.article_id).map(row => [row.article_id + (row.article_section_id || ''), row])).values()].map(row => (
|
||||
<Link key={`a${row.article_id}${row.article_section_id || ''}`} to={`/articles/${row.article_id}${row.article_section_id ? `?section=${row.article_section_id}` : ''}`}>
|
||||
📖 {row.article_title}{row.article_section_title ? ` › ${row.article_section_title}` : ''}
|
||||
</Link>
|
||||
))}
|
||||
</footer>
|
||||
</div>}
|
||||
{form && manage && <form className="quiz-reference-form" onSubmit={save}>
|
||||
<h3>{form.id ? 'Edit reference' : 'New reference'}</h3>
|
||||
|
|
|
|||
|
|
@ -38,15 +38,17 @@ function renderLabs() {
|
|||
}
|
||||
|
||||
describe('lab values panel', () => {
|
||||
it('renders Orthobullets-style groups with deep source links and card links', async () => {
|
||||
it('groups values by panel and keeps card links, without a sources footer', async () => {
|
||||
renderLabs()
|
||||
expect(await screen.findByRole('heading', { name: 'Chemistries' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'CSF' })).toBeInTheDocument()
|
||||
expect(screen.getByText('135–145')).toBeInTheDocument()
|
||||
const deep = screen.getByRole('link', { name: /Cerebrospinal fluid › Cell counts/ })
|
||||
expect(deep).toHaveAttribute('href', `/articles/7?section=${'a'.repeat(32)}`)
|
||||
expect(screen.getByRole('link', { name: 'PediRounds' })).toHaveAttribute('href', 'https://www.pedirounds.com/lab-values/')
|
||||
expect(screen.getByRole('link', { name: 'CSF card front' })).toHaveAttribute('href', '/flashcards/4/study')
|
||||
|
||||
// The sources block and its article deep links were removed from the panel.
|
||||
expect(document.querySelector('.quiz-labs-sources')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Cerebrospinal fluid › Cell counts/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'PediRounds' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets an educator link and unlink a card per reference', async () => {
|
||||
|
|
|
|||
|
|
@ -31,11 +31,6 @@
|
|||
.article-drawer-toggle { display: none; margin-bottom: 10px; }
|
||||
.article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
.article-linked h3 { margin: 0 0 10px; font-size: 1rem; }
|
||||
.linked-question { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.linked-question-text { font-weight: 600; }
|
||||
.linked-answer { color: var(--correct-fg); font-size: .88rem; }
|
||||
.linked-explanation { color: var(--text-muted); font-size: .86rem; }
|
||||
.linked-section-tag { font-size: .72rem; color: var(--text-muted); }
|
||||
.linked-card { display: flex; justify-content: space-between; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.article-edit { display: flex; flex-direction: column; gap: 8px; }
|
||||
.article-edit .form-label { margin-top: 6px; }
|
||||
|
|
@ -79,3 +74,37 @@
|
|||
.article-section-overlay { align-items: flex-end; }
|
||||
.article-section-panel { width: 100%; height: 82vh; border-radius: 16px 16px 0 0; }
|
||||
}
|
||||
|
||||
/* ── Practise this topic ──────────────────────────────────────────── */
|
||||
.article-practise { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; }
|
||||
.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); }
|
||||
.article-practise-empty { color: var(--text-muted); font-size: .86rem; margin: 0; }
|
||||
.article-practise-controls { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.article-practise-controls label { display: flex; flex-direction: column; gap: 4px; }
|
||||
.article-practise-controls label span { font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-subtle); }
|
||||
.article-practise-controls select {
|
||||
padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font-size: .88rem;
|
||||
}
|
||||
.article-practise-error { color: var(--wrong-fg); font-size: .84rem; margin: 8px 0 0; }
|
||||
.article-practise-manage { margin-top: 14px; }
|
||||
.article-practise-manage > button {
|
||||
background: none; border: none; padding: 0; cursor: pointer; font: inherit;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted);
|
||||
}
|
||||
.article-practise-manage > button:hover { color: var(--primary); }
|
||||
.article-practise-manage ul { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.article-practise-manage li {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; font-size: .85rem;
|
||||
}
|
||||
.article-practise-manage li span { flex: 1; min-width: 140px; overflow-wrap: anywhere; }
|
||||
.article-practise-manage li em { font-size: .72rem; color: var(--text-muted); font-style: normal; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.article-practise-controls { align-items: stretch; }
|
||||
.article-practise-controls label { flex: 1; }
|
||||
.article-practise-controls .btn { width: 100%; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import api from '../api/client'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import RichEditor from '../components/RichEditor'
|
||||
import CommentSection from '../components/CommentSection'
|
||||
import PractiseTopic from '../components/PractiseTopic'
|
||||
import { markdownImageUrl } from '../utils/uploads'
|
||||
import './ArticlesPage.css'
|
||||
|
||||
|
|
@ -367,20 +368,7 @@ export function ArticlePage() {
|
|||
{!article.sections?.length && (!article.summary && !article.content) && (
|
||||
<div className="empty-state">Content is being prepared by educators.</div>
|
||||
)}
|
||||
{questions.length > 0 && (
|
||||
<div className="article-linked">
|
||||
<h3>Related questions</h3>
|
||||
{questions.map(q => (
|
||||
<div key={`${q.question_id}-${q.section_id}`} className="linked-question">
|
||||
<div className="linked-question-text">{q.question_text}</div>
|
||||
{q.correct_answer && <div className="linked-answer">Answer: <strong>{q.correct_answer}</strong></div>}
|
||||
{q.explanation && <div className="linked-explanation">{q.explanation}</div>}
|
||||
{q.section_id && <div className="linked-section-tag">Section: {(article.sections || []).find(s => s.id === q.section_id)?.title || 'removed'}</div>}
|
||||
{canEdit && <button className="btn btn-sm btn-secondary" onClick={() => unlinkQuestion(q.question_id, q.section_id)}>Unlink</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
|
||||
{cards.length > 0 && (
|
||||
<div className="article-linked">
|
||||
<h3>Related cards</h3>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ beforeEach(() => {
|
|||
if (url === '/articles/1') return Promise.resolve({ data: article })
|
||||
if (url === '/articles/1/questions') return Promise.resolve({ data: [{ question_id: 1, question_text: 'Linked question text', correct_answer: 'yes', explanation: 'Why', section_id: null }] })
|
||||
if (url === '/articles/1/cards') return Promise.resolve({ data: [] })
|
||||
if (url === '/questions/builder/count') return Promise.resolve({ data: { count: 4 } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
})
|
||||
|
|
@ -38,7 +39,7 @@ describe('topic reading', () => {
|
|||
expect(screen.queryByText('No articles yet. Educators add and refine articles gradually.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders sections, breadcrumbs and linked questions without hiding content', async () => {
|
||||
it('renders sections and breadcrumbs, and practises rather than revealing questions', async () => {
|
||||
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
|
||||
expect(await screen.findByRole('heading', { name: 'Febrile seizures' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
|
||||
|
|
@ -50,8 +51,10 @@ describe('topic reading', () => {
|
|||
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
||||
await userEvent.click(within(overlay).getByRole('button', { name: 'Close section' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Linked question text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Why')).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()
|
||||
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Why')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows reading links for a question and nothing when there are none', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue