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 && ( + + )} +
+ )} +
+ ) +} 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() { })} - } {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 () => {