feat: an educator can actually write a tip
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
68d65ac782
commit
bfc5ec93d7
7 changed files with 185 additions and 4 deletions
|
|
@ -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]))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<Harness initial="A child has stridor at rest." />)
|
||||
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(<Harness initial="" />)
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -122,9 +122,18 @@ export default function HandbookPage() {
|
|||
|
||||
<section id="tips">
|
||||
<h2>Tips in the prose</h2>
|
||||
<h3>1. A tip on one phrase</h3>
|
||||
<p>
|
||||
In the question editor, <strong>select the words</strong> in the stem
|
||||
(or an option, or the explanation) and press <strong>⚕ Tip</strong> 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 <code>phrase</code>
|
||||
highlighted to type over.
|
||||
</p>
|
||||
<p>
|
||||
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{' '}
|
||||
<code>{'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}</code>{' '}
|
||||
— the phrase first, then a bar, then the tip.
|
||||
</p>
|
||||
|
|
@ -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 <code>{'{'}</code> in a formula is safe.
|
||||
</p>
|
||||
|
||||
<h3>2. A tip for the whole question</h3>
|
||||
<p>
|
||||
The <strong>Attending tip</strong> 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. <strong>Leave it empty and no button
|
||||
appears</strong> — a question with nothing to say says nothing.
|
||||
</p>
|
||||
|
||||
<h3>What it costs the learner</h3>
|
||||
<p>
|
||||
Either kind, opened <em>before</em> 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 —{' '}
|
||||
<em>correct after a tip</em> — 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="tutor">
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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' }) {
|
|||
<textarea ref={explanationRef} className="qe-explanation" value={form.explanation}
|
||||
aria-label="Explanation" onChange={e => setField('explanation', e.target.value)} />
|
||||
</label>
|
||||
|
||||
{/* The one for the whole question, offered behind a button in the
|
||||
player. Left empty, the player shows no button at all — an
|
||||
empty panel is worse than no panel. */}
|
||||
<label className="qe-field">
|
||||
<span>Attending tip</span>
|
||||
<p className="qe-primary-note">
|
||||
One nudge for the whole question, read before answering. Leave
|
||||
it empty and learners are offered nothing. For a point that
|
||||
belongs to one word instead, select the word in the stem and
|
||||
press <strong>⚕ Tip</strong>.
|
||||
</p>
|
||||
<MarkdownToolbar textareaRef={tipRef} value={form.attending_tip}
|
||||
label="Attending tip formatting" onChange={v => setField('attending_tip', v)} />
|
||||
<textarea ref={tipRef} className="qe-tip" value={form.attending_tip} rows={3}
|
||||
aria-label="Attending tip" onChange={e => setField('attending_tip', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
68
frontend/src/pages/QuestionEditPage.test.jsx
Normal file
68
frontend/src/pages/QuestionEditPage.test.jsx
Normal file
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={['/questions/7']}>
|
||||
<Routes><Route path="/questions/:id" element={<QuestionEditPage />} /></Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
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.')
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue