From d893b77cc859d5248a1061d21e027bf9bc39e786 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 19:34:49 +0200 Subject: [PATCH] feat: practise a topic from its article; tidy the lab panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA --- frontend/src/components/PractiseTopic.jsx | 113 ++++++++++++++++++ .../src/components/PractiseTopic.test.jsx | 84 +++++++++++++ frontend/src/components/QuizTools.css | 25 ++-- frontend/src/components/QuizTools.jsx | 18 +-- frontend/src/components/QuizTools.test.jsx | 10 +- frontend/src/pages/ArticlesPage.css | 39 +++++- frontend/src/pages/ArticlesPage.jsx | 16 +-- frontend/src/pages/ArticlesPage.test.jsx | 9 +- 8 files changed, 259 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/PractiseTopic.jsx create mode 100644 frontend/src/components/PractiseTopic.test.jsx diff --git a/frontend/src/components/PractiseTopic.jsx b/frontend/src/components/PractiseTopic.jsx new file mode 100644 index 0000000..5b1770e --- /dev/null +++ b/frontend/src/components/PractiseTopic.jsx @@ -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 ( +
+
+

Practise this topic

+ + {available === null ? 'Counting…' : `${available} question${available === 1 ? '' : 's'} linked`} + +
+ + {available === 0 ? ( +

+ No questions are linked to this article yet{canEdit ? ' — link some below.' : '.'} +

+ ) : ( + <> +
+ + + +
+ {error &&

{error}

} + + )} + + {canEdit && questions.length > 0 && ( +
+ + {listOpen && ( +
    + {questions.map(q => ( +
  • + {q.question_text.slice(0, 110)}{q.question_text.length > 110 ? '…' : ''} + {q.section_id && ( + {(article.sections || []).find(s => s.id === q.section_id)?.title || 'removed section'} + )} + +
  • + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/PractiseTopic.test.jsx b/frontend/src/components/PractiseTopic.test.jsx new file mode 100644 index 0000000..b918497 --- /dev/null +++ b/frontend/src/components/PractiseTopic.test.jsx @@ -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( + + + } /> + Test ready} /> + + +) + +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) +}) diff --git a/frontend/src/components/QuizTools.css b/frontend/src/components/QuizTools.css index 859ed1e..7c9c407 100644 --- a/frontend/src/components/QuizTools.css +++ b/frontend/src/components/QuizTools.css @@ -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); } diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx index 7d7b109..b66eb4a 100644 --- a/frontend/src/components/QuizTools.jsx +++ b/frontend/src/components/QuizTools.jsx @@ -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 <> -

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.

+

Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.

{isEducator && } {error &&

{error}

} {linkError &&

{linkError}

} @@ -142,14 +142,15 @@ function LabValues() { })} -
- Sources - {[...new Map(rows.map(row => [row.source, row])).values()].map(row => ( - {row.source} - ))} - {[...new Map(rows.filter(row => row.article_id).map(row => [row.article_id + (row.article_section_id || ''), row])).values()].map(row => ( - - 📖 {row.article_title}{row.article_section_title ? ` › ${row.article_section_title}` : ''} - - ))} -
} {form && manage &&

{form.id ? 'Edit reference' : 'New reference'}

diff --git a/frontend/src/components/QuizTools.test.jsx b/frontend/src/components/QuizTools.test.jsx index 0ca5101..43f024d 100644 --- a/frontend/src/components/QuizTools.test.jsx +++ b/frontend/src/components/QuizTools.test.jsx @@ -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 () => { diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index 7c8be6d..c6b0107 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -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%; } +} diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 1575e63..dc8d119 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -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) && (
Content is being prepared by educators.
)} - {questions.length > 0 && ( -
-

Related questions

- {questions.map(q => ( -
-
{q.question_text}
- {q.correct_answer &&
Answer: {q.correct_answer}
} - {q.explanation &&
{q.explanation}
} - {q.section_id &&
Section: {(article.sections || []).find(s => s.id === q.section_id)?.title || 'removed'}
} - {canEdit && } -
- ))} -
- )} + {cards.length > 0 && (

Related cards

diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx index be6a42f..f88e114 100644 --- a/frontend/src/pages/ArticlesPage.test.jsx +++ b/frontend/src/pages/ArticlesPage.test.jsx @@ -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(} />) 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 () => {