feat: figures a question can actually have — managed, labelled, previewed

question_media replaced the two filename columns months ago: any number
of figures per question, each with a role, a label the prose can refer
to, a caption and an order. Only the editor's own endpoint ever read
them. The editor showed the two legacy text fields, and the player and
the answer review rendered the legacy paths — so the model existed and
nothing used it.

- FigureManager in the question editor: add from the image bank, name,
  caption, reorder, remove, per role. A figure with no caption is called
  out, because a caption is how anyone finds it again. The image id is
  shown, since that is what the link survives a rename by.
- FigureStrip on the player and the review. Explanation figures are
  labelled thumbnails that open full size and page between them — a
  stack of full-width radiographs between two paragraphs pushes the
  explanation off the screen, and "as in Figure 2" needs Figure 2 to be
  named where it sits. A stem figure stays full size: it is the question.
- question_figures.py is the single place rows become what a page
  renders, so the three views cannot disagree.
- Explanation figures are withheld until answers are revealed, the same
  rule the explanation itself follows.

The legacy paths still render where a question was never backfilled, so
nothing that worked before stops working.

Backend 242/242, frontend 290/290.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 12:59:43 +02:00
parent 994733581e
commit 765a477d4b
14 changed files with 540 additions and 61 deletions

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import case, func
from app.services.attempt_expiry import active_key, progress_key, settle_if_expired
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete
from app.database import get_db
from app.models.quiz import Quiz
@ -147,6 +148,7 @@ def submit_attempt(
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
answer_details = []
if review_allowed:
figures = figures_for_questions(db, [q.id for q, _, _ in grades])
for q, user_answer, is_correct in grades:
answer_details.append(AnswerDetail(
question_id=q.id,
@ -161,6 +163,7 @@ def submit_attempt(
image_path=q.image_path,
page_reference=q.page_reference,
category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id),
figures=figures.get(q.id, []),
))
attempt.score = score
@ -667,9 +670,10 @@ def get_attempt(
submitted_map = {ans.question_id: ans for ans in attempt.answers}
answer_details = []
if review_allowed:
for q in get_quiz_questions(db, attempt.quiz_id):
if attempt.selected_question_ids is not None and q.id not in attempt.selected_question_ids:
continue
shown = [q for q in get_quiz_questions(db, attempt.quiz_id)
if attempt.selected_question_ids is None or q.id in attempt.selected_question_ids]
figures = figures_for_questions(db, [q.id for q in shown])
for q in shown:
ans = submitted_map.get(q.id)
answer_details.append(AnswerDetail(
question_id=q.id,
@ -684,6 +688,7 @@ def get_attempt(
image_path=q.image_path,
page_reference=q.page_reference,
category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id),
figures=figures.get(q.id, []),
))
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0

View file

@ -24,6 +24,7 @@ from app.models.quiz import Quiz
from app.models.user import User
from app.models.favorite import Favorite
from app.services.search_service import hybrid_ids, hybrid_question_ids
from app.services.question_figures import figures_for as _figures_for
from app.services.quiz_builder import (bank_query, category_descendants, filtered_bank_query,
CreateFromBankRequest, GenerateTestRequest, create_saved_test, exam_scope_predicate, generate_test)
from app.utils.auth import get_current_user, require_moderator
@ -1238,30 +1239,6 @@ class FigureUpdate(BaseModel):
position: int | None = None
def _figure_json(link, asset) -> dict:
return {
"id": link.id, "media_id": link.media_id, "role": link.role,
# The label the prose refers to. Falls back to a number so a figure is
# never nameless, which is what makes "see the figure" ambiguous.
"label": link.label or f"Figure {link.position + 1}",
"caption": link.caption or getattr(asset, "caption", None),
"title": getattr(asset, "title", None),
"path": getattr(asset, "path", None),
"position": link.position,
}
def _figures_for(db: Session, question_id: int) -> list[dict]:
from app.models.media import MediaAsset
from app.models.question_media import QuestionMedia
rows = db.query(QuestionMedia, MediaAsset).join(
MediaAsset, MediaAsset.id == QuestionMedia.media_id).filter(
QuestionMedia.question_id == question_id).order_by(
QuestionMedia.role, QuestionMedia.position, QuestionMedia.id).all()
return [_figure_json(link, asset) for link, asset in rows]
@router.get("/detail/{question_id}/figures")
def list_figures(question_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):

View file

@ -6,6 +6,7 @@ from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
from app.services.attempt_expiry import settle_if_expired
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import plan_context_for_quizzes
from app.utils.upload_access import validate_image_attachments
from app.utils.quiz_questions import validate_option_explanations
@ -437,8 +438,13 @@ def get_quiz(
result.questions = [q for q in result.questions if q.id in selected]
result.questions_count = len(result.questions)
categories = db.query(QuestionCategory).all()
figures = figures_for_questions(db, [q.id for q in result.questions])
for question in result.questions:
question.category_breadcrumbs = category_breadcrumbs(categories, question.question_category_id)
# Stem figures always; explanation figures only where answers are
# already revealed, so a figure cannot give away what the stem hides.
question.figures = [figure for figure in figures.get(question.id, [])
if reveal or figure["role"] == "stem"]
return result

View file

@ -28,6 +28,7 @@ class AnswerDetail(BaseModel):
image_path: str | None = None
page_reference: int | None = None
category_breadcrumbs: list[dict] = Field(default_factory=list)
figures: list[dict] = Field(default_factory=list)
class Config:
from_attributes = True

View file

@ -33,6 +33,9 @@ class QuestionResponse(BaseModel):
question_category_id: int | None = None
difficulty: str | None = None
category_breadcrumbs: list[dict] = Field(default_factory=list)
# Filled by the endpoint, not read off the model: a figure is a row in
# question_media, not a column on the question.
figures: list[dict] = Field(default_factory=list)
class Config:
from_attributes = True

View file

@ -0,0 +1,56 @@
"""The figures attached to a question.
A question used to carry one stem image and one explanation image as two
filename columns. `question_media` replaced that with rows any number of
figures, each with a role, a label the prose can refer to, a caption and an
order but only the editor's own endpoint ever read them, so the quiz player
and the answer review still showed the two legacy paths.
This is the one place that turns those rows into what a page renders, so the
editor, the player and the review cannot disagree about what is on a question.
"""
from collections import defaultdict
from sqlalchemy.orm import Session
from app.models.media import MediaAsset
from app.models.question_media import QuestionMedia
def figure_json(link: QuestionMedia, asset: MediaAsset) -> dict:
return {
"id": link.id,
"media_id": link.media_id,
"role": link.role,
# The label the prose refers to. Falls back to a number so a figure is
# never nameless, which is what makes "see the figure" ambiguous.
"label": link.label or f"Figure {link.position + 1}",
"caption": link.caption or getattr(asset, "caption", None),
"title": getattr(asset, "title", None),
"path": getattr(asset, "path", None),
"position": link.position,
}
def figures_for_questions(db: Session, question_ids) -> dict[int, list[dict]]:
"""Figures for many questions at once, keyed by question id.
One query for a whole quiz rather than one per question a 240-question
exam would otherwise fan out into 240 round trips to build one page.
"""
ids = [qid for qid in dict.fromkeys(question_ids) if qid]
if not ids:
return {}
rows = (db.query(QuestionMedia, MediaAsset)
.join(MediaAsset, MediaAsset.id == QuestionMedia.media_id)
.filter(QuestionMedia.question_id.in_(ids))
.order_by(QuestionMedia.role, QuestionMedia.position, QuestionMedia.id)
.all())
out: dict[int, list[dict]] = defaultdict(list)
for link, asset in rows:
out[link.question_id].append(figure_json(link, asset))
return out
def figures_for(db: Session, question_id: int) -> list[dict]:
return figures_for_questions(db, [question_id]).get(question_id, [])

View file

@ -0,0 +1,57 @@
/* The figures on a question, in the editor. */
.fm-error {
margin: 0 0 12px; padding: 9px 12px; font-size: 0.84rem;
color: var(--wrong-fg); background: var(--wrong-bg);
border: 1px solid var(--wrong-bd); border-radius: 8px;
}
.fm-role + .fm-role { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); }
.fm-role h3 {
margin: 0 0 4px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.fm-help { margin: 0 0 12px; font-size: 0.8rem; line-height: 1.55; color: var(--text-muted); }
.fm-empty { margin: 0 0 12px; font-size: 0.83rem; color: var(--text-muted); }
.fm-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.fm-item {
display: flex; gap: 12px; align-items: flex-start; padding: 10px;
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
}
.fm-item img {
flex-shrink: 0; width: 96px; height: 72px; object-fit: cover;
border-radius: 7px; border: 1px solid var(--border); background: var(--card-bg);
}
.fm-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.fm-label { font-size: 0.88rem; font-weight: 650; }
.fm-caption { font-size: 0.8rem; line-height: 1.5; color: var(--text-muted); overflow-wrap: anywhere; }
/* A figure with no caption is one nobody will find when searching the bank. */
.fm-caption.is-missing { color: var(--wrong-fg); font-style: italic; }
.fm-id { font-size: 0.72rem; color: var(--text-subtle); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.fm-actions { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
.fm-remove { color: var(--wrong-fg); border-color: var(--wrong-bd); }
.fm-edit { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.fm-edit label { display: flex; flex-direction: column; gap: 4px; }
.fm-edit span {
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.fm-edit input, .fm-edit textarea {
width: 100%; padding: 8px 10px;
/* 16px on touch so iOS does not zoom the page in and refuse to zoom back. */
font-size: 16px; font-family: inherit;
border: 1px solid var(--border); border-radius: 7px;
background: var(--input-bg); color: var(--text); resize: vertical;
}
@media (min-width: 700px) { .fm-edit input, .fm-edit textarea { font-size: 0.88rem; } }
@media (max-width: 620px) {
.fm-item { flex-wrap: wrap; }
.fm-item img { width: 64px; height: 48px; }
.fm-actions { width: 100%; }
.fm-actions .btn { flex: 1; }
}

View file

@ -0,0 +1,185 @@
import { useCallback, useEffect, useState } from 'react'
import api from '../api/client'
import ImagePicker from './ImagePicker'
import { uploadUrl } from '../utils/uploads'
import './FigureManager.css'
const ROLES = [
{ key: 'stem', title: 'Question figures',
help: 'Shown beside the stem, before the answer. Nothing here may give the answer away.' },
{ key: 'explanation', title: 'Explanation figures',
help: 'Shown with the explanation, after the answer is in. Referred to by label — "as in Figure 2".' },
]
const apiError = (err, fallback) => {
const detail = err?.response?.data?.detail
return typeof detail === 'string' ? detail : fallback
}
/**
* The figures on one question: add, name, caption, order, remove.
*
* A question used to carry one stem image and one explanation image, as two
* text fields holding a filename. `question_media` replaced that with rows
* any number of figures, each with a label the prose can refer to and until
* now there was no way to see or change them.
*
* The label is the point. "See the figure" is ambiguous the moment there are
* two, so every figure has one, falling back to its position.
*/
export default function FigureManager({ questionId }) {
const [figures, setFigures] = useState([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [picking, setPicking] = useState(null) // the role being added to
const [editing, setEditing] = useState(null) // figure id being renamed
const [draft, setDraft] = useState({ label: '', caption: '' })
const load = useCallback(() => {
if (!questionId) { setLoading(false); return }
api.get(`/questions/detail/${questionId}/figures`)
.then(res => setFigures(res.data || []))
.catch(() => setError('Could not load the figures'))
.finally(() => setLoading(false))
}, [questionId])
useEffect(() => { load() }, [load])
const run = async (fn, failure) => {
setBusy(true); setError('')
try {
const res = await fn()
// Every write answers with the full list, so nothing has to be guessed.
if (res?.data?.figures) setFigures(res.data.figures)
else load()
} catch (err) { setError(apiError(err, failure)) }
finally { setBusy(false) }
}
const add = (role, mediaId) => run(
() => api.post(`/questions/detail/${questionId}/figures`, { media_id: mediaId, role }),
'Could not add that figure')
const save = (figure) => run(
() => api.patch(`/questions/figures/${figure.id}`,
{ label: draft.label.trim() || null, caption: draft.caption.trim() || null })
.then(res => { setEditing(null); return res }),
'Could not save that')
const remove = (figure) => run(
() => api.delete(`/questions/figures/${figure.id}`), 'Could not remove that figure')
// Swapping positions rather than renumbering the lot: the server stores a
// position per figure, and two writes are enough to move one place.
const move = (role, index, delta) => {
const inRole = figures.filter(f => f.role === role)
const target = index + delta
if (target < 0 || target >= inRole.length) return
const a = inRole[index]
const b = inRole[target]
return run(async () => {
await api.patch(`/questions/figures/${a.id}`, { position: b.position })
return api.patch(`/questions/figures/${b.id}`, { position: a.position })
}, 'Could not reorder the figures')
}
if (!questionId) {
return <p className="fm-empty">Save the question first, then figures can be added to it.</p>
}
if (loading) return <div className="loading"><div className="spinner" /></div>
return (
<div className="fm">
{error && <p className="fm-error" role="alert">{error}</p>}
{ROLES.map(role => {
const inRole = figures.filter(f => f.role === role.key)
return (
<section className="fm-role" key={role.key}>
<h3>{role.title}</h3>
<p className="fm-help">{role.help}</p>
{inRole.length === 0 ? (
<p className="fm-empty">None yet.</p>
) : (
<ul className="fm-list">
{inRole.map((figure, index) => (
<li key={figure.id} className="fm-item">
<img src={uploadUrl(figure.path)} alt={figure.caption || figure.label}
onError={e => { e.target.style.visibility = 'hidden' }} />
<div className="fm-meta">
{editing === figure.id ? (
<div className="fm-edit">
<label>
<span>Label</span>
<input value={draft.label} autoFocus maxLength={80}
aria-label={`Label for ${figure.label}`}
onChange={e => setDraft(d => ({ ...d, label: e.target.value }))} />
</label>
<label>
<span>Caption</span>
<textarea value={draft.caption} rows={2}
aria-label={`Caption for ${figure.label}`}
onChange={e => setDraft(d => ({ ...d, caption: e.target.value }))} />
</label>
<div className="fm-actions">
<button type="button" className="btn btn-primary btn-sm" disabled={busy}
onClick={() => save(figure)}>Save</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
) : (
<>
<strong className="fm-label">{figure.label}</strong>
{figure.caption
? <span className="fm-caption">{figure.caption}</span>
: <span className="fm-caption is-missing">
No caption a figure nobody can search for
</span>}
{/* The id is what the question refers to, so it
survives a rename or a move. */}
<span className="fm-id">image #{figure.media_id}</span>
</>
)}
</div>
{editing !== figure.id && (
<div className="fm-actions">
<button type="button" className="btn btn-secondary btn-sm"
aria-label={`Move ${figure.label} up`} disabled={busy || index === 0}
onClick={() => move(role.key, index, -1)}></button>
<button type="button" className="btn btn-secondary btn-sm"
aria-label={`Move ${figure.label} down`}
disabled={busy || index === inRole.length - 1}
onClick={() => move(role.key, index, 1)}></button>
<button type="button" className="btn btn-secondary btn-sm"
aria-label={`Edit ${figure.label}`}
onClick={() => {
setEditing(figure.id)
setDraft({ label: figure.label || '', caption: figure.caption || '' })
}}>Edit</button>
<button type="button" className="btn btn-secondary btn-sm fm-remove"
aria-label={`Remove ${figure.label}`} disabled={busy}
onClick={() => remove(figure)}>Remove</button>
</div>
)}
</li>
))}
</ul>
)}
<button type="button" className="btn btn-secondary btn-sm" disabled={busy}
onClick={() => setPicking(role.key)}>+ Add {role.key === 'stem' ? 'question' : 'explanation'} figure</button>
</section>
)
})}
<ImagePicker open={picking !== null} onClose={() => setPicking(null)}
title={picking === 'explanation' ? 'Choose an explanation figure' : 'Choose a question figure'}
onPick={(_path, image) => { const role = picking; setPicking(null); add(role, image.id) }} />
</div>
)
}

View file

@ -0,0 +1,61 @@
/* Figures on a question: thumbnails that open full size. */
.fs-heading {
margin: 14px 0 6px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.fs { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; }
.fs-item {
display: flex; flex-direction: column; gap: 6px; padding: 0;
background: none; border: 0; cursor: zoom-in; font: inherit; text-align: left;
color: inherit;
}
.fs-item img {
display: block; border-radius: 8px; border: 1px solid var(--border);
background: var(--card-bg);
}
.fs-item:hover img { border-color: var(--primary); }
/* Several small ones, for an explanation: the prose is what is being read. */
.fs-compact li { width: 150px; }
.fs-compact img { width: 150px; height: 110px; object-fit: cover; }
.fs-compact .fs-cap { font-size: 0.75rem; line-height: 1.4; }
/* One big one, for a stem: it is the question. */
.fs-full li { width: 100%; max-width: 520px; }
.fs-full img { width: 100%; max-height: 340px; object-fit: contain; }
.fs-full .fs-cap { font-size: 0.82rem; line-height: 1.5; }
.fs-cap { display: flex; flex-direction: column; gap: 2px; color: var(--text-muted); }
.fs-cap strong { color: var(--text); font-weight: 650; }
/* ── Full size ────────────────────────────────────────────────────── */
.fs-overlay {
position: fixed; inset: 0; z-index: 1200; display: flex;
align-items: center; justify-content: center; padding: 16px;
background: rgba(0, 0, 0, 0.72);
}
.fs-overlay-inner {
max-width: min(1000px, 100%); max-height: 100%; overflow-y: auto;
background: var(--card-bg); border-radius: 12px; padding: 14px;
}
.fs-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.fs-overlay-head strong { font-size: 0.95rem; }
.fs-overlay-head button {
width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 50%;
background: none; cursor: pointer; color: var(--text-muted); font-size: 1rem;
}
.fs-overlay img { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: 8px; }
.fs-overlay-cap { margin: 10px 0 0; font-size: 0.85rem; line-height: 1.6; color: var(--text-muted); }
.fs-overlay-nav { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 12px; font-size: 0.82rem; color: var(--text-muted); }
.fs-overlay-nav button {
min-height: 38px; padding: 8px 14px; font: inherit; font-size: 0.82rem;
background: var(--bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; color: var(--text);
}
.fs-overlay-nav button:disabled { opacity: 0.4; cursor: default; }
@media (max-width: 560px) {
.fs-compact li, .fs-compact img { width: 120px; }
.fs-compact img { height: 88px; }
}

View file

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { uploadUrl } from '../utils/uploads'
import './FigureStrip.css'
/**
* The figures on a question, as labelled thumbnails that open full size.
*
* Explanation figures are thumbnails rather than full-width images because
* there can be several and the explanation is the thing being read: a stack of
* radiographs between two paragraphs pushes the prose off the screen. The
* label is what makes them referable "as in Figure 2" only means something
* if Figure 2 is named where it sits.
*
* Stem figures default to full size: there is usually one, and it is the
* question.
*/
export default function FigureStrip({ figures, attemptId, size = 'full', label }) {
const [open, setOpen] = useState(null)
useEffect(() => {
if (open === null) return undefined
const onKey = e => { if (e.key === 'Escape') setOpen(null) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
if (!figures?.length) return null
const shown = figures[open]
return (
<>
{label && <p className="fs-heading">{label}</p>}
<ul className={`fs fs-${size}`}>
{figures.map((figure, index) => (
<li key={figure.id}>
<button type="button" className="fs-item"
aria-label={`Open ${figure.label}${figure.caption ? `: ${figure.caption}` : ''}`}
onClick={() => setOpen(index)}>
<img src={uploadUrl(figure.path, attemptId)} alt={figure.caption || figure.label}
loading="lazy" onError={e => { e.currentTarget.style.visibility = 'hidden' }} />
<span className="fs-cap">
<strong>{figure.label}</strong>
{figure.caption && <span>{figure.caption}</span>}
</span>
</button>
</li>
))}
</ul>
{shown && (
<div className="fs-overlay" role="dialog" aria-modal="true" aria-label={shown.label}
onClick={e => e.target === e.currentTarget && setOpen(null)}>
<div className="fs-overlay-inner">
<div className="fs-overlay-head">
<strong>{shown.label}</strong>
<button type="button" onClick={() => setOpen(null)} aria-label="Close figure"></button>
</div>
<img src={uploadUrl(shown.path, attemptId)} alt={shown.caption || shown.label} />
{shown.caption && <p className="fs-overlay-cap">{shown.caption}</p>}
{figures.length > 1 && (
<div className="fs-overlay-nav">
<button type="button" disabled={open === 0}
onClick={() => setOpen(open - 1)}> Previous</button>
<span>{open + 1} of {figures.length}</span>
<button type="button" disabled={open === figures.length - 1}
onClick={() => setOpen(open + 1)}>Next </button>
</div>
)}
</div>
</div>
)}
</>
)
}

View file

@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FigureStrip from './FigureStrip'
vi.mock('../utils/uploads', () => ({ uploadUrl: (path) => `/uploads/${path}` }))
const FIGURES = [
{ id: 1, media_id: 11, role: 'explanation', label: 'Figure 1', caption: 'Lateral neck radiograph', path: 'a.png', position: 0 },
{ id: 2, media_id: 12, role: 'explanation', label: 'Figure 2', caption: null, path: 'b.png', position: 1 },
]
describe('figures on a question', () => {
it('shows nothing at all when there are none', () => {
const { container } = render(<FigureStrip figures={[]} />)
expect(container).toBeEmptyDOMElement()
})
it('names every figure, so prose can refer to one', () => {
render(<FigureStrip figures={FIGURES} size="compact" label="Figures" />)
expect(screen.getByText('Figure 1')).toBeInTheDocument()
expect(screen.getByText('Figure 2')).toBeInTheDocument()
expect(screen.getByText('Lateral neck radiograph')).toBeInTheDocument()
})
it('carries the caption into the accessible name, not just the label', () => {
render(<FigureStrip figures={FIGURES} />)
expect(screen.getByRole('button', { name: 'Open Figure 1: Lateral neck radiograph' })).toBeInTheDocument()
// A figure with no caption still has a name.
expect(screen.getByRole('button', { name: 'Open Figure 2' })).toBeInTheDocument()
})
it('opens one full size and walks between them', async () => {
render(<FigureStrip figures={FIGURES} />)
await userEvent.click(screen.getByRole('button', { name: /Open Figure 1/ }))
const dialog = screen.getByRole('dialog', { name: 'Figure 1' })
expect(within(dialog).getByText('1 of 2')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: ' Previous' })).toBeDisabled()
await userEvent.click(within(dialog).getByRole('button', { name: 'Next ' }))
expect(screen.getByRole('dialog', { name: 'Figure 2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next ' })).toBeDisabled()
})
it('closes on Escape, the way anything laid over the page should', async () => {
render(<FigureStrip figures={FIGURES} />)
await userEvent.click(screen.getByRole('button', { name: /Open Figure 1/ }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('offers no paging for a single figure', async () => {
render(<FigureStrip figures={[FIGURES[0]]} />)
await userEvent.click(screen.getByRole('button', { name: /Open Figure 1/ }))
expect(screen.queryByRole('button', { name: 'Next ' })).not.toBeInTheDocument()
})
})

View file

@ -4,6 +4,7 @@ import api from '../api/client'
import CategoryDrilldown from '../components/CategoryDrilldown'
import RichText from '../components/RichText'
import ImagePicker from '../components/ImagePicker'
import FigureManager from '../components/FigureManager'
import { uploadUrl } from '../utils/uploads'
import './QuestionEditPage.css'
@ -375,40 +376,16 @@ export default function QuestionEditPage({ mode = 'edit' }) {
)}
<section className="qe-card">
<h2>Images</h2>
<h2>Figures</h2>
<div className="qe-card-body">
{/* Typing a filename from memory meant keeping a second tab open;
the picker searches the bank by what an image shows. */}
{[['image_path', 'Question image'], ['explanation_image_path', 'Explanation image']].map(([field, label]) => (
<div className="qe-image-field" key={field}>
<label className="qe-field">
<span>{label}</span>
<input value={form[field]} placeholder="Image ID or filename"
aria-label={label} onChange={e => setField(field, e.target.value)} />
</label>
{form[field] && (
<div className="qe-image">
<img src={uploadUrl(form[field])} alt={label}
title={form[field]}
onError={e => { e.currentTarget.style.display = 'none' }} />
<span className="qe-image-meta">{form[field]}</span>
</div>
)}
<div className="qe-image-actions">
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setPicking(field)}>
{form[field] ? 'Change image' : 'Choose image'}
</button>
{form[field] && (
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setField(field, '')}>Remove</button>
)}
</div>
</div>
))}
{/* A question used to carry one stem image and one explanation
image as two filename fields. It can carry any number now,
each with a label the explanation can refer to. */}
<FigureManager questionId={isCreate ? null : Number(id)} />
<Link className="btn btn-secondary btn-sm" to="/media">Manage the image bank</Link>
</div>
</section>
</aside>
</div>

View file

@ -8,6 +8,7 @@ import { mergeTextRanges } from '../utils/highlightOffsets'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import useMediaQuery from '../hooks/useMediaQuery'
import FigureStrip from '../components/FigureStrip'
import MyNote from '../components/MyNote'
import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css'
@ -1415,7 +1416,12 @@ const timerStarted = timeLeft !== null
</div>
)}
{current.image_path && (
{/* Figures are rows now. The legacy single path is still shown
for anything that was never backfilled. */}
{current.figures?.some(f => f.role === 'stem') ? (
<FigureStrip figures={current.figures.filter(f => f.role === 'stem')}
attemptId={attemptId} size="full" />
) : current.image_path && (
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
<img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration"
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
@ -1529,7 +1535,13 @@ const timerStarted = timeLeft !== null
{(current.explanation || current.explanation_image_path) && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
{current.explanation && <><strong>Explanation:</strong> <RichText value={current.explanation} /></>}
{current.explanation_image_path && (
{/* Labelled thumbnails, so the prose can say "as in
Figure 2" and mean something. */}
{current.figures?.some(f => f.role === 'explanation') && (
<FigureStrip figures={current.figures.filter(f => f.role === 'explanation')}
attemptId={attemptId} size="compact" label="Figures" />
)}
{!current.figures?.some(f => f.role === 'explanation') && current.explanation_image_path && (
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.explanation_image_path) }} title="Expand explanation image" type="button" style={{ marginTop: 12 }}>
<img src={uploadUrl(current.explanation_image_path, attemptId)} alt="Explanation illustration"
onError={e => e.currentTarget.closest('button').style.display = 'none'} />

View file

@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
import api from '../api/client'
import QuizTools from '../components/QuizTools'
import FigureStrip from '../components/FigureStrip'
import './QuizPlayer.css'
export default function ResultsPage() {
@ -126,7 +127,9 @@ export default function ResultsPage() {
</span>
</div>
{ans.image_path && <img src={uploadUrl(ans.image_path, id)} alt="Question illustration" style={{ maxWidth: '100%', maxHeight: 360, marginBottom: 20 }} />}
{ans.figures?.some(f => f.role === 'stem')
? <FigureStrip figures={ans.figures.filter(f => f.role === 'stem')} attemptId={id} size="full" />
: ans.image_path && <img src={uploadUrl(ans.image_path, id)} alt="Question illustration" style={{ maxWidth: '100%', maxHeight: 360, marginBottom: 20 }} />}
{/* Numbered answers */}
{ans.options && ans.options.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
@ -180,7 +183,11 @@ export default function ResultsPage() {
<div className="explanation">
<strong>Explanation</strong>
{ans.explanation && <div style={{ marginTop: 8 }}>{ans.explanation}</div>}
{ans.explanation_image_path && (
{ans.figures?.some(f => f.role === 'explanation') && (
<FigureStrip figures={ans.figures.filter(f => f.role === 'explanation')}
attemptId={id} size="compact" label="Figures" />
)}
{!ans.figures?.some(f => f.role === 'explanation') && ans.explanation_image_path && (
<div style={{ marginTop: 12 }}>
<img src={uploadUrl(ans.explanation_image_path, id)} alt="Explanation illustration"
style={{ maxWidth: '100%', maxHeight: 320, borderRadius: 8, border: '1px solid var(--border)' }}