From bfc5ec93d7e78e3c2f5bb6444607aa4c6c91cf9f Mon Sep 17 00:00:00 2001
From: Daniel
Date: Sat, 12 Sep 2026 02:02:16 +0200
Subject: [PATCH] feat: an educator can actually write a tip
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two gaps closed. `attending_tip` has been on the question model and in
the API since before this session, rendered by the player — and there was
no field anywhere in the UI to put anything in it. The question editor
now has one, at the foot of the Explanation card.
And the inline kind is written by selecting the words: select "stridor"
in the stem, press ⚕ Tip on that field's toolbar, and the caret waits
where the tip goes. With nothing selected it drops in a marker with
"phrase" highlighted to type over.
A question with no tip offers no button — that was already true in the
player, and is now pinned by a test rather than left to hold by
accident. An emptied tip saves as null for the same reason.
The handbook says how to do both, and what a tip costs the learner:
opened before answering it is recorded with the answer and shown as
"correct after a tip"; opened while reading the explanation it is
revision and costs nothing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
---
frontend/src/components/MarkdownToolbar.jsx | 22 +++++-
.../src/components/MarkdownToolbar.test.jsx | 23 ++++++-
frontend/src/pages/HandbookPage.jsx | 29 +++++++-
frontend/src/pages/QuestionEditPage.css | 2 +
frontend/src/pages/QuestionEditPage.jsx | 22 +++++-
frontend/src/pages/QuestionEditPage.test.jsx | 68 +++++++++++++++++++
frontend/src/pages/QuizPage.test.jsx | 23 +++++++
7 files changed, 185 insertions(+), 4 deletions(-)
create mode 100644 frontend/src/pages/QuestionEditPage.test.jsx
diff --git a/frontend/src/components/MarkdownToolbar.jsx b/frontend/src/components/MarkdownToolbar.jsx
index 25ddacd..5d7c9dc 100644
--- a/frontend/src/components/MarkdownToolbar.jsx
+++ b/frontend/src/components/MarkdownToolbar.jsx
@@ -18,6 +18,11 @@ import './MarkdownToolbar.css'
const WRAP = 'wrap'
const LINE = 'line'
const BLOCK = 'block'
+const TIP = 'tip'
+
+//: What a tip reads as before anything has been typed into it.
+const TIP_PHRASE = 'phrase'
+const TIP_TEXT = 'the teaching point'
const ACTIONS = [
{ key: 'bold', label: 'B', title: 'Bold', kind: WRAP, before: '**', after: '**', style: { fontWeight: 800 } },
@@ -31,6 +36,8 @@ const ACTIONS = [
text: '\n| Test | Value | Reference |\n| --- | --- | --- |\n| | | |\n',
},
{ key: 'math', label: '∑', title: 'Inline maths', kind: WRAP, before: '$', after: '$' },
+ // Select the words first; the tip is what you type next.
+ { key: 'tip', label: '⚕ Tip', title: 'Turn the selected words into a tip', kind: TIP },
]
export default function MarkdownToolbar({ textareaRef, value, onChange, label = 'Formatting' }) {
@@ -42,6 +49,8 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label =
const text = value || ''
let next = text
let caret = end
+ // Where to leave the selection, when landing the caret is not enough.
+ let select = null
if (action.kind === WRAP) {
const selected = text.slice(start, end)
@@ -63,6 +72,17 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label =
const replaced = lines.join('\n')
next = text.slice(0, lineStart) + replaced + text.slice(stop)
caret = lineStart + replaced.length
+ } else if (action.kind === TIP) {
+ // With words selected, they become the underlined phrase and the caret
+ // waits where the tip goes. With nothing selected there is a placeholder
+ // to type over, selected so the first keystroke replaces it.
+ const selected = text.slice(start, end)
+ const phrase = selected || TIP_PHRASE
+ const tip = selected ? '' : TIP_TEXT
+ next = `${text.slice(0, start)}{{${phrase}|${tip}}}${text.slice(end)}`
+ const tipAt = start + 2 + phrase.length + 1
+ select = selected ? [tipAt, tipAt] : [start + 2, start + 2 + phrase.length]
+ caret = tipAt
} else {
next = text.slice(0, start) + action.text + text.slice(end)
caret = start + action.text.length
@@ -72,7 +92,7 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label =
// The caret has to be restored after React has written the new value.
requestAnimationFrame(() => {
field.focus()
- field.setSelectionRange(caret, caret)
+ field.setSelectionRange(...(select || [caret, caret]))
})
}
diff --git a/frontend/src/components/MarkdownToolbar.test.jsx b/frontend/src/components/MarkdownToolbar.test.jsx
index 6524348..6f4711f 100644
--- a/frontend/src/components/MarkdownToolbar.test.jsx
+++ b/frontend/src/components/MarkdownToolbar.test.jsx
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useRef, useState } from 'react'
import MarkdownToolbar from './MarkdownToolbar'
@@ -65,6 +65,27 @@ describe('markdown formatting buttons', () => {
expect(box().value.startsWith('Labs:')).toBe(true)
})
+ it('turns the selected words into a tip, waiting where the tip goes', async () => {
+ render()
+ select(12, 19)
+ await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' }))
+ expect(box()).toHaveValue('A child has {{stridor|}} at rest.')
+ // The caret sits after the bar, so the next thing typed is the tip itself.
+ await waitFor(() => expect(box().selectionStart).toBe('A child has {{stridor|'.length))
+ })
+
+ it('with nothing selected, offers a placeholder to type over', async () => {
+ render()
+ select(0, 0)
+ await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' }))
+ expect(box()).toHaveValue('{{phrase|the teaching point}}')
+ // "phrase" is selected, so the first keystroke replaces it.
+ await waitFor(() => {
+ expect(box().selectionStart).toBe(2)
+ expect(box().selectionEnd).toBe(8)
+ })
+ })
+
it('leaves everything else exactly as typed — no reflow', async () => {
// The whole reason this is not a WYSIWYG: a stem carries learners'
// highlight offsets, so untouched text must come back byte for byte.
diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx
index f3c35f4..126ea46 100644
--- a/frontend/src/pages/HandbookPage.jsx
+++ b/frontend/src/pages/HandbookPage.jsx
@@ -122,9 +122,18 @@ export default function HandbookPage() {
Tips in the prose
+ 1. A tip on one phrase
+
+ In the question editor, select the words in the stem
+ (or an option, or the explanation) and press ⚕ Tip on
+ that field's toolbar. The words become the phrase and the caret waits
+ where the tip goes — type the sentence and you are done. With nothing
+ selected the button drops in a marker with phrase
+ highlighted to type over.
+
A phrase in a stem, an option, an explanation or an article can carry
- one sentence of teaching behind it. Written as{' '}
+ one sentence of teaching behind it. What the button writes is{' '}
{'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}{' '}
— the phrase first, then a bar, then the tip.
@@ -145,6 +154,24 @@ export default function HandbookPage() {
braces. Anything that does not match the shape exactly is left in the
text as written — a stray {'{'} in a formula is safe.
+
+ 2. A tip for the whole question
+
+ The Attending tip field at the foot of the editor.
+ One nudge for the question as a whole, offered in the player behind a
+ ⚕ button above the options. Leave it empty and no button
+ appears — a question with nothing to say says nothing.
+
+
+ What it costs the learner
+
+ Either kind, opened before the answer goes in, is recorded
+ with that answer. It still counts as correct and the percentage is not
+ docked; it is shown on Analysis as its own slice —{' '}
+ correct after a tip — so a learner can see how much of a
+ score leaned on one. A tip opened while reading the explanation is
+ revision and costs nothing.
+
diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css
index f9387da..9cbae9f 100644
--- a/frontend/src/pages/QuestionEditPage.css
+++ b/frontend/src/pages/QuestionEditPage.css
@@ -46,6 +46,8 @@
.qe-field textarea { min-height: 200px; resize: vertical; line-height: 1.65; }
.qe-field textarea.qe-stem { min-height: 340px; }
.qe-field textarea.qe-explanation { min-height: 280px; }
+/* Shorter than an explanation: the tip is meant to be one sentence. */
+.qe-field textarea.qe-tip { min-height: 84px; }
/* ── Options ──────────────────────────────────────────────────────── */
.qe-option { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; }
diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx
index 0f6f5b1..d2dedd8 100644
--- a/frontend/src/pages/QuestionEditPage.jsx
+++ b/frontend/src/pages/QuestionEditPage.jsx
@@ -22,7 +22,7 @@ const apiError = (err, fallback) => {
const blank = () => ({
question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '',
- explanation: '', question_category_id: '', extraCategoryIds: [], option_explanations: {},
+ explanation: '', attending_tip: '', question_category_id: '', extraCategoryIds: [], option_explanations: {},
difficulty: '', image_path: '', explanation_image_path: '',
})
@@ -46,6 +46,7 @@ export default function QuestionEditPage({ mode = 'edit' }) {
const [picking, setPicking] = useState(null)
const stemRef = useRef(null)
const explanationRef = useRef(null)
+ const tipRef = useRef(null)
// One ref per option, kept across renders so the toolbar can find the
// field it belongs to when options are added or reordered.
const optionRefs = useRef([])
@@ -74,6 +75,7 @@ export default function QuestionEditPage({ mode = 'edit' }) {
options: found.options ? [...found.options] : [],
correct_answer: found.correct_answer || '',
explanation: found.explanation || '',
+ attending_tip: found.attending_tip || '',
question_category_id: found.question_category_id || '',
extraCategoryIds: (found.category_ids || []).filter(c => c !== found.question_category_id),
option_explanations: { ...(found.option_explanations || {}) },
@@ -134,6 +136,7 @@ export default function QuestionEditPage({ mode = 'edit' }) {
options: form.question_type === 'mcq' ? form.options.filter(o => o.trim()) : null,
correct_answer: form.correct_answer.trim(),
explanation: form.explanation || null,
+ attending_tip: form.attending_tip.trim() || null,
question_category_id: form.question_category_id === '' ? null : Number(form.question_category_id),
additional_category_ids: form.extraCategoryIds.filter(c => c !== Number(form.question_category_id)),
option_explanations: form.option_explanations,
@@ -302,6 +305,23 @@ export default function QuestionEditPage({ mode = 'edit' }) {
diff --git a/frontend/src/pages/QuestionEditPage.test.jsx b/frontend/src/pages/QuestionEditPage.test.jsx
new file mode 100644
index 0000000..01e6663
--- /dev/null
+++ b/frontend/src/pages/QuestionEditPage.test.jsx
@@ -0,0 +1,68 @@
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { beforeEach, expect, it, vi } from 'vitest'
+import QuestionEditPage from './QuestionEditPage'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({
+ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn() },
+}))
+
+const question = (over = {}) => ({
+ id: 7, question_text: 'A child has stridor at rest.', question_type: 'mcq',
+ options: ['Croup', 'Epiglottitis'], correct_answer: 'Croup',
+ explanation: 'Barking cough.', attending_tip: 'Look at the growth chart first.',
+ option_explanations: {}, category_ids: [], question_category_id: null, figures: [],
+ difficulty: '', image_path: null, explanation_image_path: null, ...over,
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ api.get.mockImplementation(url => Promise.resolve({
+ // Only the question itself is an object; figures and categories are lists.
+ data: url === '/questions/detail/7' ? question() : [],
+ }))
+ api.patch.mockResolvedValue({ data: {} })
+})
+
+const mount = () => render(
+
+ } />
+ )
+
+it('shows the attending tip the question already carries', async () => {
+ mount()
+ expect(await screen.findByLabelText('Attending tip'))
+ .toHaveValue('Look at the growth chart first.')
+})
+
+it('saves an edited tip', async () => {
+ mount()
+ const field = await screen.findByLabelText('Attending tip')
+ await userEvent.clear(field)
+ await userEvent.type(field, 'Plot the weight.')
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7',
+ expect.objectContaining({ attending_tip: 'Plot the weight.' })))
+})
+
+it('an emptied tip is cleared, not saved as an empty panel', async () => {
+ mount()
+ await userEvent.clear(await screen.findByLabelText('Attending tip'))
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ // Null, so the player offers no button at all on this question.
+ await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7',
+ expect.objectContaining({ attending_tip: null })))
+})
+
+it('turns a selection in the stem into a tip marker', async () => {
+ mount()
+ const stem = await screen.findByLabelText('Question text')
+ stem.setSelectionRange(12, 19)
+ stem.focus()
+ // Every field has its own toolbar, so the stem's is the one asked.
+ const bar = within(screen.getByRole('toolbar', { name: 'Stem formatting' }))
+ await userEvent.click(bar.getByRole('button', { name: 'Turn the selected words into a tip' }))
+ expect(stem).toHaveValue('A child has {{stridor|}} at rest.')
+})
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx
index 4a8c71e..827f374 100644
--- a/frontend/src/pages/QuizPage.test.jsx
+++ b/frontend/src/pages/QuizPage.test.jsx
@@ -509,6 +509,29 @@ describe('quiz player', () => {
}
})
+ it('offers no tip button on a question that has none', async () => {
+ await begin(false)
+ // Nothing to open, so nothing is offered: an empty panel behind a button
+ // is worse than no button.
+ expect(screen.queryByRole('button', { name: /Attending tip/ })).not.toBeInTheDocument()
+ })
+
+ it('offers the attending tip only where the question carries one', async () => {
+ questions[0].attending_tip = 'Look at the growth chart before the vitals.'
+ try {
+ await begin(false)
+ const open = screen.getByRole('button', { name: /Attending tip/ })
+ await userEvent.click(open)
+ expect(inCard().getByText('Look at the growth chart before the vitals.')).toBeInTheDocument()
+ // The next question has none, so the button goes with it.
+ await userEvent.click(screen.getAllByRole('button', { name: /Skip|Next/ })[0])
+ await findStem('Full second clinical question.')
+ expect(screen.queryByRole('button', { name: /Attending tip/ })).not.toBeInTheDocument()
+ } finally {
+ delete questions[0].attending_tip
+ }
+ })
+
it('starts timed quizzes in exam mode without a mode prompt', async () => {
quizModeVar = 'timed'
await begin(false)