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:
parent
01337c8c25
commit
f5a084e49a
8 changed files with 328 additions and 28 deletions
30
backend/alembic/versions/i2d3e4f5a607_lab_links.py
Normal file
30
backend/alembic/versions/i2d3e4f5a607_lab_links.py
Normal 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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
70
frontend/src/components/QuizTools.test.jsx
Normal file
70
frontend/src/components/QuizTools.test.jsx
Normal 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: '135–145', 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('135–145')).toBeInTheDocument()
|
||||
const deep = screen.getByRole('link', { name: /Cerebrospinal fluid › Cell counts/ })
|
||||
expect(deep).toHaveAttribute('href', `/articles/7?section=${'a'.repeat(32)}`)
|
||||
expect(screen.getByRole('link', { name: 'PediRounds' })).toHaveAttribute('href', 'https://www.pedirounds.com/lab-values/')
|
||||
expect(screen.getByRole('link', { name: 'CSF card front' })).toHaveAttribute('href', '/flashcards/4/study')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue