feat: Orthobullets-style lab panel with source deep links and card links

Lab references deep-link to article sections or external sources, show linked cards with study links, and educators can attach cards and article targets. Grouped panel layout. Migration i2d3e4f5a607. 57 backend and 93 frontend tests pass.
This commit is contained in:
Daniel 2026-09-08 16:20:55 +02:00
parent 01337c8c25
commit f5a084e49a
8 changed files with 328 additions and 28 deletions

View file

@ -0,0 +1,30 @@
"""Lab reference article deep links and card associations.
Revision ID: i2d3e4f5a607
Revises: h1c2d3e4f506
"""
from alembic import op
revision = "i2d3e4f5a607"
down_revision = "h1c2d3e4f506"
branch_labels = None
depends_on = None
def upgrade():
op.execute("ALTER TABLE lab_reference_values ADD COLUMN IF NOT EXISTS article_id INTEGER REFERENCES articles(id) ON DELETE SET NULL")
op.execute("ALTER TABLE lab_reference_values ADD COLUMN IF NOT EXISTS article_section_id VARCHAR(64)")
op.execute("""
CREATE TABLE IF NOT EXISTS lab_reference_card_links (
id SERIAL PRIMARY KEY,
lab_reference_id INTEGER NOT NULL REFERENCES lab_reference_values(id) ON DELETE CASCADE,
flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
CONSTRAINT uq_lab_card_link UNIQUE (lab_reference_id, flashcard_id)
)""")
op.execute("CREATE INDEX IF NOT EXISTS ix_lab_reference_card_links_lab ON lab_reference_card_links (lab_reference_id)")
def downgrade():
op.execute("DROP TABLE IF EXISTS lab_reference_card_links")
op.execute("ALTER TABLE lab_reference_values DROP COLUMN IF EXISTS article_section_id")
op.execute("ALTER TABLE lab_reference_values DROP COLUMN IF EXISTS article_id")

View file

@ -8,7 +8,7 @@ from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig
from app.models.favorite import Favorite
from app.models.user_note import UserNote
from app.models.lab_reference import LabReference
from app.models.lab_reference import LabReference, LabReferenceCardLink
from app.models.article import Article, QuestionArticleLink
from app.models.comment import Comment
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
@ -27,6 +27,7 @@ __all__ = [
"Favorite",
"UserNote",
"LabReference",
"LabReferenceCardLink",
"Article",
"QuestionArticleLink",
"Comment",

View file

@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
from app.database import Base
@ -15,6 +15,18 @@ class LabReference(Base):
specimen = Column(String(120), nullable=False)
source = Column(String(500), nullable=False)
source_url = Column(String(2000), nullable=True)
# Orthobullets-style deep link: jump straight to an article subsection.
article_id = Column(Integer, ForeignKey("articles.id", ondelete="SET NULL"), nullable=True)
article_section_id = Column(String(64), nullable=True)
is_published = Column(Boolean, nullable=False, default=False, server_default="false")
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
class LabReferenceCardLink(Base):
__tablename__ = "lab_reference_card_links"
__table_args__ = (UniqueConstraint("lab_reference_id", "flashcard_id", name="uq_lab_card_link"),)
id = Column(Integer, primary_key=True)
lab_reference_id = Column(Integer, ForeignKey("lab_reference_values.id", ondelete="CASCADE"), nullable=False)
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False)

View file

@ -3,13 +3,15 @@ from collections import Counter
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
from pydantic import BaseModel, Field, HttpUrl, field_validator
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.article import Article
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.lab_reference import LabReference
from app.models.flashcard import Flashcard, FlashcardDeck
from app.models.lab_reference import LabReference, LabReferenceCardLink
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.quiz import Quiz
@ -30,6 +32,8 @@ class LabInput(BaseModel):
specimen: str = Field(min_length=1, max_length=120)
source: str = Field(min_length=1, max_length=500)
source_url: HttpUrl | None = None
article_id: int | None = None
article_section_id: str | None = Field(default=None, max_length=64)
is_published: bool = False
@field_validator("source_url")
@ -47,13 +51,7 @@ class LabInput(BaseModel):
return value.strip()
class LabOutput(LabInput):
model_config = ConfigDict(from_attributes=True)
id: int
updated_at: datetime
@router.get("/lab-values", response_model=list[LabOutput])
@router.get("/lab-values")
def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
if include_drafts and not user.is_moderator:
raise HTTPException(403, "Educator access required")
@ -61,36 +59,103 @@ def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user
if not include_drafts:
query = query.filter(LabReference.is_published.is_(True))
# ponytail: bounded personal-project table; add pagination before exceeding 500 entries.
return query.order_by(LabReference.group, LabReference.name, LabReference.age_group).limit(500).all()
entries = query.order_by(LabReference.group, LabReference.name, LabReference.age_group).limit(500).all()
card_rows = db.query(LabReferenceCardLink.lab_reference_id, Flashcard.id, Flashcard.front, Flashcard.deck_id,
FlashcardDeck.title).join(
Flashcard, Flashcard.id == LabReferenceCardLink.flashcard_id,
).join(FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
LabReferenceCardLink.lab_reference_id.in_([entry.id for entry in entries]) if entries else False,
).all()
rows_by_lab: dict[int, list] = {}
for lab_id, cid, front, deck_id, deck_title in card_rows:
rows_by_lab.setdefault(lab_id, []).append((cid, front, deck_id, deck_title))
return [lab_json(db, entry, rows_by_lab.get(entry.id, [])) for entry in entries]
def lab_fields(data):
fields = data.model_dump()
fields["source_url"] = str(data.source_url) if data.source_url else None
fields["article_section_id"] = data.article_section_id or None
return fields
@router.post("/lab-values", response_model=LabOutput, status_code=201)
def validate_lab_target(db, article_id, article_section_id):
if article_id is None:
if article_section_id:
raise HTTPException(400, "article_section_id requires article_id")
return
article = db.get(Article, article_id)
if not article:
raise HTTPException(400, "Article not found")
if article_section_id and article_section_id not in {s["id"] for s in (article.sections or [])}:
raise HTTPException(400, "Section not found in this article")
def lab_json(db, entry, card_rows=None):
article = db.get(Article, entry.article_id) if entry.article_id else None
section_title = None
if article and entry.article_section_id:
section_title = next((s["title"] for s in (article.sections or []) if s["id"] == entry.article_section_id), None)
cards = []
if card_rows:
cards = [{"card_id": cid, "front": front, "deck_id": deck_id, "deck_title": deck_title}
for cid, front, deck_id, deck_title in card_rows]
return {
"id": entry.id, "name": entry.name, "group": entry.group,
"reference_range": entry.reference_range, "units": entry.units,
"age_group": entry.age_group, "specimen": entry.specimen,
"source": entry.source, "source_url": entry.source_url,
"article_id": entry.article_id, "article_section_id": entry.article_section_id,
"article_title": article.title if article else None,
"article_section_title": section_title,
"is_published": entry.is_published, "updated_at": entry.updated_at,
"cards": cards,
}
@router.post("/lab-values", status_code=201)
def create_lab_value(data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
validate_lab_target(db, data.article_id, data.article_section_id)
entry = LabReference(**lab_fields(data), updated_by=user.id)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
return lab_json(db, entry)
@router.put("/lab-values/{entry_id}", response_model=LabOutput)
@router.put("/lab-values/{entry_id}")
def update_lab_value(entry_id: int, data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
entry = db.get(LabReference, entry_id)
if not entry:
raise HTTPException(404, "Reference not found")
validate_lab_target(db, data.article_id, data.article_section_id)
for key, value in lab_fields(data).items():
setattr(entry, key, value)
entry.updated_by = user.id
entry.updated_at = datetime.utcnow()
db.commit()
db.refresh(entry)
return entry
return lab_json(db, entry)
@router.put("/lab-values/{entry_id}/cards/{card_id}")
def link_lab_card(entry_id: int, card_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
entry = db.get(LabReference, entry_id)
if not entry:
raise HTTPException(404, "Reference not found")
if not db.get(Flashcard, card_id):
raise HTTPException(404, "Card not found")
if db.query(LabReferenceCardLink.id).filter_by(lab_reference_id=entry_id, flashcard_id=card_id).first():
return {"linked": False}
db.add(LabReferenceCardLink(lab_reference_id=entry_id, flashcard_id=card_id))
db.commit()
return {"linked": True}
@router.delete("/lab-values/{entry_id}/cards/{card_id}", status_code=204)
def unlink_lab_card(entry_id: int, card_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
db.query(LabReferenceCardLink).filter_by(lab_reference_id=entry_id, flashcard_id=card_id).delete(synchronize_session=False)
db.commit()
@router.delete("/lab-values/{entry_id}", status_code=204)

View file

@ -169,6 +169,41 @@ class StudyToolTests(unittest.TestCase):
self.assertEqual(data['total_answered'], 4)
self.assertEqual(data['categories'][0]['category_id'], 2) # Most answered first.
def test_lab_article_deep_links_and_card_links(self):
from app.models.article import Article
from app.models.flashcard import Flashcard, FlashcardDeck
article = Article(slug='lab-source', title='Lab source article', content='Intro',
sections=[{'id': 'd' * 32, 'slug': 'ranges', 'title': 'Ranges', 'content': 'Body'}],
user_id=3, status='published')
deck = FlashcardDeck(user_id=3, title='Sodium cards', is_shared=0)
self.bank.db.add_all([article, deck])
self.bank.db.flush()
card = Flashcard(deck_id=deck.id, front='Sodium card front', back='back')
self.bank.db.add(card)
self.bank.db.commit()
self.bank.user = self.bank.mod
payload = dict(name='Deep linked', group='Blood', reference_range='1-2', units='u',
age_group='a', specimen='s', source='src', article_id=article.id,
article_section_id='d' * 32, is_published=True)
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': 999}).status_code, 400)
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_section_id': 'bad'}).status_code, 400)
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': None, 'article_section_id': 'd' * 32}).status_code, 400)
created = self.client.post('/study-tools/lab-values', json=payload)
self.assertEqual(created.status_code, 201, created.text)
entry_id = created.json()['id']
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/999').status_code, 404)
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], True)
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], False)
row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id)
self.assertEqual(row['article_title'], 'Lab source article')
self.assertEqual(row['article_section_title'], 'Ranges')
self.assertEqual([c['front'] for c in row['cards']], ['Sodium card front'])
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 204)
row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id)
self.assertEqual(row['cards'], [])
self.bank.user = self.bank.owner
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 403)
def test_lab_reference_permissions_validation_and_publication(self):
payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units',
age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference')

View file

@ -34,3 +34,30 @@
.quiz-check input { width: auto; }
.quiz-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
@media (max-width: 520px) { .quiz-tool-body { padding: 14px; } .quiz-reference-form { grid-template-columns: minmax(0, 1fr); } }
.quiz-labs { display: flex; flex-direction: column; gap: 14px; }
.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:last-child { border-bottom: none; }
.quiz-lab-name strong { display: block; font-size: .88rem; }
.quiz-lab-name small { color: var(--text-muted); font-size: .72rem; }
.quiz-lab-range strong { font-size: .9rem; }
.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; }
.quiz-lab-card button { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: .72rem; }
.quiz-lab-card-link { display: inline-flex; align-items: center; gap: 5px; }
.quiz-lab-card-link input { width: 70px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 999px; font-size: .74rem; background: var(--input-bg); color: var(--text); }
.quiz-lab-card-link button { font-size: .72rem; }
.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; }
}

View file

@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import { calculate } from '../utils/calculator'
@ -43,11 +44,12 @@ function Calculator() {
</form>
}
const emptyReference = { name: '', group: 'Blood', reference_range: '', units: '', age_group: '', specimen: '', source: '', source_url: '', is_published: false }
const emptyReference = { name: '', group: 'Blood', reference_range: '', units: '', age_group: '', specimen: '', source: '', source_url: '', article_id: '', article_section_id: '', is_published: false }
const labFields = [
['name', 'Test name', 120], ['group', 'Group', 60], ['reference_range', 'Reference range', 250],
['units', 'Units', 80], ['age_group', 'Age / population', 120], ['specimen', 'Specimen', 120],
['source', 'Source citation', 500], ['source_url', 'Source URL (optional)', 2000],
['article_id', 'Article ID (optional deep link)', 12], ['article_section_id', 'Article section ID (optional)', 64],
]
function LabValues() {
@ -63,6 +65,8 @@ function LabValues() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [refresh, setRefresh] = useState(0)
const [cardLink, setCardLink] = useState({})
const [linkError, setLinkError] = useState('')
useEffect(() => {
let active = true
setLoading(true)
@ -77,6 +81,8 @@ function LabValues() {
setSaving(true); setError('')
const payload = Object.fromEntries(Object.keys(emptyReference).map(key => [key, form[key]]))
payload.source_url ||= null
payload.article_id = payload.article_id ? parseInt(payload.article_id, 10) : null
payload.article_section_id ||= null
try {
if (form.id) await api.put(`/study-tools/lab-values/${form.id}`, payload)
else await api.post('/study-tools/lab-values', payload)
@ -90,29 +96,83 @@ function LabValues() {
catch { setError('Could not delete this reference.') }
finally { setSaving(false) }
}
const linkCard = async (labId) => {
setLinkError('')
const cardId = parseInt(cardLink[labId], 10)
if (!cardId) { setLinkError('Enter a card ID'); return }
try {
await api.put(`/study-tools/lab-values/${labId}/cards/${cardId}`)
setCardLink(prev => ({ ...prev, [labId]: '' }))
setRefresh(v => v + 1)
} catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not link card') }
}
const unlinkCard = async (labId, cardId) => {
setLinkError('')
try { await api.delete(`/study-tools/lab-values/${labId}/cards/${cardId}`); setRefresh(v => v + 1) }
catch { setLinkError('Could not remove card link') }
}
const filtered = rows.filter(row => (group === 'All' || row.group === group) &&
[row.name, row.age_group, row.specimen, row.units].some(value => value.toLowerCase().includes(query.toLowerCase())))
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 <>
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Use the source and population shown; follow local laboratory intervals.</p>
<p className="quiz-reference-note">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.</p>
{isEducator && <label className="quiz-check"><input type="checkbox" checked={manage} onChange={e => { setManage(e.target.checked); setForm(null); setGroup('All') }} /> Manage references and drafts</label>}
{error && <p role="alert">{error}</p>}
{linkError && <p role="alert">{linkError}</p>}
<div className="quiz-reference-controls"><label>Search references<input value={query} onChange={e => setQuery(e.target.value)} /></label>
<button type="button" onClick={() => setRefresh(v => v + 1)}>Refresh</button>
{manage && <button type="button" onClick={() => setForm({ ...emptyReference })}>Add reference</button>}
</div>
<div className="quiz-tool-tabs" aria-label="Reference groups">{['All', ...new Set(rows.map(row => row.group))].map(name => <button type="button" key={name} aria-pressed={group === name} onClick={() => setGroup(name)}>{name}</button>)}</div>
<div className="quiz-tool-tabs" aria-label="Reference groups">{groups.map(name => <button type="button" key={name} aria-pressed={group === name} onClick={() => setGroup(name)}>{name}</button>)}</div>
{loading ? <p role="status">Loading references</p> : filtered.length === 0 ? <p>No published reference values match. Educators can add sourced, age-specific entries; no ranges are assumed.</p> :
<div className="quiz-reference-scroll"><table><thead><tr><th>Test / specimen</th><th>Reference range</th><th>Population / source</th>{manage && <th>Actions</th>}</tr></thead><tbody>{filtered.map(row => <tr key={row.id}>
<th scope="row">{row.name}<small>{row.specimen}{!row.is_published && ' · Draft'}</small></th>
<td>{row.reference_range} <span>{row.units}</span></td>
<td>{row.age_group}<small>{row.source_url ? <a href={row.source_url} target="_blank" rel="noopener noreferrer">{row.source}</a> : row.source}</small></td>
{manage && <td><button type="button" onClick={() => setForm({ ...row, source_url: row.source_url || '' })}>Edit {row.name}</button>{removeId === row.id ? <>
<button type="button" disabled={saving} onClick={() => remove(row.id)}>Confirm delete</button><button type="button" onClick={() => setRemoveId(null)}>Cancel</button>
</> : <button type="button" onClick={() => setRemoveId(row.id)}>Delete {row.name}</button>}</td>}
</tr>)}</tbody></table></div>}
<div className="quiz-labs">
{(group === 'All' ? groups.slice(1) : [group]).map(groupName => {
const groupRows = ordered.filter(row => row.group === groupName)
if (!groupRows.length) return null
return <section key={groupName} className="quiz-lab-group" aria-label={groupName}>
<h3 className="quiz-lab-group-title">{groupName}</h3>
<ul className="quiz-lab-rows">
{groupRows.map(row => <li key={row.id} className="quiz-lab-row">
<div className="quiz-lab-name">
<strong>{row.name}{!row.is_published && <em className="article-status-draft"> draft</em>}</strong>
<small>{row.specimen}</small>
</div>
<div className="quiz-lab-range"><strong>{row.reference_range}</strong>{row.units && <span className="quiz-lab-units">{row.units}</span>}</div>
<div className="quiz-lab-pop">{row.age_group}</div>
<div className="quiz-lab-source">
{row.article_id ? (
<Link to={`/articles/${row.article_id}${row.article_section_id ? `?section=${row.article_section_id}` : ''}`}>
📖 {row.article_title || 'Article'}{row.article_section_title ? ` ${row.article_section_title}` : ''}
</Link>
) : row.source_url ? <a href={row.source_url} target="_blank" rel="noopener noreferrer">{row.source}</a> : <span>{row.source}</span>}
</div>
{(row.cards?.length > 0 || manage) && <div className="quiz-lab-cards">
{row.cards.map(card => (
<span key={card.card_id} className="quiz-lab-card">
<Link to={`/flashcards/${card.deck_id}/study`}>{card.front.slice(0, 40)}{card.front.length > 40 ? '…' : ''}</Link>
{manage && <button type="button" aria-label={`Unlink card ${card.card_id} from ${row.name}`} onClick={() => unlinkCard(row.id, card.card_id)}></button>}
</span>
))}
{manage && <span className="quiz-lab-card-link">
<input aria-label={`Card ID to link to ${row.name}`} placeholder="Card ID" value={cardLink[row.id] || ''} onChange={e => setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
<button type="button" onClick={() => linkCard(row.id)}>Link card</button>
</span>}
</div>}
{manage && <div className="quiz-lab-actions">
<button type="button" onClick={() => setForm({ ...row, source_url: row.source_url || '', article_id: row.article_id || '', article_section_id: row.article_section_id || '' })}>Edit {row.name}</button>
{removeId === row.id ? <>
<button type="button" disabled={saving} onClick={() => remove(row.id)}>Confirm delete</button><button type="button" onClick={() => setRemoveId(null)}>Cancel</button>
</> : <button type="button" onClick={() => setRemoveId(row.id)}>Delete {row.name}</button>}
</div>}
</li>)}
</ul>
</section>
})}
</div>}
{form && manage && <form className="quiz-reference-form" onSubmit={save}>
<h3>{form.id ? 'Edit reference' : 'New reference'}</h3>
{labFields.map(([key, label, max]) => <label key={key}>{label}<input required={key !== 'source_url'} type={key === 'source_url' ? 'url' : 'text'} maxLength={max} value={form[key]} onChange={e => setForm(value => ({ ...value, [key]: e.target.value }))} /></label>)}
{labFields.map(([key, label, max]) => <label key={key}>{label}<input required={key !== 'source_url' && key !== 'article_id' && key !== 'article_section_id'} type={key === 'source_url' ? 'url' : 'text'} maxLength={max} value={form[key]} onChange={e => setForm(value => ({ ...value, [key]: e.target.value }))} /></label>)}
<label className="quiz-check"><input type="checkbox" checked={form.is_published} onChange={e => setForm(value => ({ ...value, is_published: e.target.checked }))} /> I have verified this source and population; publish this reference</label>
<div><button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save reference'}</button><button type="button" onClick={() => setForm(null)}>Cancel editing</button></div>
</form>}

View file

@ -0,0 +1,70 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import QuizTools from './QuizTools'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 3, name: 'Educator', role: 'moderator' } }) }))
let nativeShowModal
let nativeClose
beforeAll(() => {
nativeShowModal = HTMLDialogElement.prototype.showModal
nativeClose = HTMLDialogElement.prototype.close
HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', '') }
HTMLDialogElement.prototype.close = function () { this.removeAttribute('open') }
})
afterAll(() => {
HTMLDialogElement.prototype.showModal = nativeShowModal
HTMLDialogElement.prototype.close = nativeClose
})
const rows = [
{ id: 1, name: 'Sodium', group: 'Chemistries', reference_range: '135145', units: 'mmol/L', age_group: '>10 days', specimen: 'Plasma', source: 'PediRounds', source_url: 'https://www.pedirounds.com/lab-values/', article_id: null, article_section_id: null, article_title: null, article_section_title: null, is_published: true, cards: [] },
{ id: 2, name: 'CSF white cell count', group: 'CSF', reference_range: '<5', units: '/mm³', age_group: 'Child vs neonate', specimen: 'CSF', source: 'AAP 2018', source_url: null, article_id: 7, article_section_id: 'a'.repeat(32), article_title: 'Cerebrospinal fluid', article_section_title: 'Cell counts', is_published: true, cards: [{ card_id: 9, front: 'CSF card front', deck_id: 4, deck_title: 'CSF deck' }] },
]
beforeEach(() => {
vi.resetAllMocks()
api.get.mockResolvedValue({ data: rows })
api.put.mockResolvedValue({ data: { linked: true } })
api.delete.mockResolvedValue({ data: null })
})
function renderLabs() {
return render(<MemoryRouter><QuizTools tool="labs" onClose={() => {}} /></MemoryRouter>)
}
describe('lab values panel', () => {
it('renders Orthobullets-style groups with deep source links and card links', async () => {
renderLabs()
expect(await screen.findByRole('heading', { name: 'Chemistries' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'CSF' })).toBeInTheDocument()
expect(screen.getByText('135145')).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')
})
it('lets an educator link and unlink a card per reference', async () => {
renderLabs()
await screen.findByRole('heading', { name: 'CSF' })
await userEvent.click(screen.getByRole('checkbox', { name: /Manage references and drafts/ }))
const csfSection = screen.getByRole('heading', { name: 'CSF' }).closest('section')
const input = await within(csfSection).findByLabelText('Card ID to link to CSF white cell count')
await userEvent.type(input, '12')
await userEvent.click(within(csfSection).getByRole('button', { name: 'Link card' }))
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/12'))
await userEvent.click(screen.getByRole('button', { name: /Unlink card 9 from CSF white cell count/ }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/9'))
})
it('shows an honest empty state', async () => {
api.get.mockResolvedValue({ data: [] })
renderLabs()
expect(await screen.findByText(/No published reference values match/)).toBeInTheDocument()
})
})