feat: facet filters, personal libraries, adaptive shortcuts, restart, rename support
Create/bank pages use AMBOSS-style facets: Exams, Disciplines, Symptoms, Systems, Articles, Saved. Personal question libraries with add-to-library in study modal. Adaptive session shortcuts from performance (including weakest topics). Quiz restart with fresh attempt. Category counts computed with two grouped queries. Migration n7a8b9c0d142. 63 backend and 97 frontend tests pass.
This commit is contained in:
parent
6fbfedd8a9
commit
73ef007e0a
25 changed files with 572 additions and 85 deletions
33
backend/alembic/versions/n7a8b9c0d142_collections.py
Normal file
33
backend/alembic/versions/n7a8b9c0d142_collections.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Personal question libraries.
|
||||
|
||||
Revision ID: n7a8b9c0d142
|
||||
Revises: m6a7b8c9d031
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "n7a8b9c0d142"
|
||||
down_revision = "m6a7b8c9d031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_collections (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_collection_questions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
collection_id INTEGER NOT NULL REFERENCES user_collections(id) ON DELETE CASCADE,
|
||||
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_collection_question UNIQUE (collection_id, question_id)
|
||||
)""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DROP TABLE IF EXISTS user_collection_questions")
|
||||
op.execute("DROP TABLE IF EXISTS user_collections")
|
||||
|
|
@ -11,7 +11,7 @@ from app.logging_config import setup_logging
|
|||
setup_logging(settings.LOG_LEVEL)
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
|
||||
from app.routers import study_tools, uploads, articles, comments, share
|
||||
from app.routers import study_tools, uploads, articles, comments, share, collections
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
|
@ -619,6 +619,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|||
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
|
||||
app.include_router(comments.router, prefix="/api/comments", tags=["comments"])
|
||||
app.include_router(share.router, prefix="/api/share", tags=["share"])
|
||||
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
|
||||
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
|
||||
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
|
||||
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from app.models.article import Article, QuestionArticleLink
|
|||
from app.models.comment import Comment
|
||||
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
|
||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
from app.models.collection import UserCollection, UserCollectionQuestion
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
|
|
@ -38,4 +39,6 @@ __all__ = [
|
|||
"FlashcardArticleLink",
|
||||
"QuestionCategory",
|
||||
"QuestionCategoryLink",
|
||||
"UserCollection",
|
||||
"UserCollectionQuestion",
|
||||
]
|
||||
|
|
|
|||
22
backend/app/models/collection.py
Normal file
22
backend/app/models/collection.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserCollection(Base):
|
||||
__tablename__ = "user_collections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class UserCollectionQuestion(Base):
|
||||
__tablename__ = "user_collection_questions"
|
||||
__table_args__ = (UniqueConstraint("collection_id", "question_id", name="uq_collection_question"),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
collection_id = Column(Integer, ForeignKey("user_collections.id", ondelete="CASCADE"), nullable=False)
|
||||
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
|
||||
100
backend/app/routers/collections.py
Normal file
100
backend/app/routers/collections.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Personal question libraries (saved questions)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.collection import UserCollection, UserCollectionQuestion
|
||||
from app.models.question import Question
|
||||
from app.models.user import User
|
||||
from app.services.quiz_builder import bank_question_predicate
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
title: str
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def title_shape(cls, value):
|
||||
value = value.strip()
|
||||
if not value or len(value) > 200:
|
||||
raise ValueError("Collection title is required (max 200 characters)")
|
||||
return value
|
||||
|
||||
|
||||
class CollectionQuestionIn(BaseModel):
|
||||
question_id: int
|
||||
|
||||
|
||||
def _own(db, user, collection_id):
|
||||
collection = db.get(UserCollection, collection_id)
|
||||
if not collection:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
if collection.user_id != user.id:
|
||||
raise HTTPException(403, "Not your collection")
|
||||
return collection
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_collections(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
rows = db.query(UserCollection).filter(UserCollection.user_id == user.id).order_by(
|
||||
UserCollection.created_at.desc()).all()
|
||||
return [{"id": c.id, "title": c.title, "question_count": db.query(UserCollectionQuestion).filter(
|
||||
UserCollectionQuestion.collection_id == c.id).count()} for c in rows]
|
||||
|
||||
|
||||
@router.post("/", status_code=201)
|
||||
def create_collection(data: CollectionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
collection = UserCollection(user_id=user.id, title=data.title)
|
||||
db.add(collection)
|
||||
db.commit()
|
||||
db.refresh(collection)
|
||||
return {"id": collection.id, "title": collection.title, "question_count": 0}
|
||||
|
||||
|
||||
@router.patch("/{collection_id}")
|
||||
def rename_collection(collection_id: int, data: CollectionCreate, db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user)):
|
||||
collection = _own(db, user, collection_id)
|
||||
collection.title = data.title
|
||||
db.commit()
|
||||
return {"id": collection.id, "title": collection.title}
|
||||
|
||||
|
||||
@router.delete("/{collection_id}", status_code=204)
|
||||
def delete_collection(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
db.delete(_own(db, user, collection_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{collection_id}/questions")
|
||||
def collection_questions(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
collection = _own(db, user, collection_id)
|
||||
rows = db.query(Question).join(UserCollectionQuestion, UserCollectionQuestion.question_id == Question.id).filter(
|
||||
UserCollectionQuestion.collection_id == collection.id).all()
|
||||
return [{"id": q.id, "question_text": q.question_text} for q in rows]
|
||||
|
||||
|
||||
@router.put("/{collection_id}/questions/{question_id}")
|
||||
def add_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user)):
|
||||
collection = _own(db, user, collection_id)
|
||||
if not db.query(Question.id).filter(Question.id == question_id, bank_question_predicate(user)).first():
|
||||
raise HTTPException(404, "Question not found")
|
||||
if db.query(UserCollectionQuestion.id).filter_by(collection_id=collection.id, question_id=question_id).first():
|
||||
return {"added": False}
|
||||
db.add(UserCollectionQuestion(collection_id=collection.id, question_id=question_id))
|
||||
db.commit()
|
||||
return {"added": True}
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/questions/{question_id}", status_code=204)
|
||||
def remove_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user)):
|
||||
_own(db, user, collection_id)
|
||||
db.query(UserCollectionQuestion).filter_by(collection_id=collection_id, question_id=question_id).delete(
|
||||
synchronize_session=False)
|
||||
db.commit()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Question category management; saved quiz membership is never changed here."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import or_, select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
|
|
@ -9,7 +9,7 @@ from app.models.question import Question
|
|||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
from app.models.user import User
|
||||
from app.services.quiz_builder import (bank_query, filtered_bank_query, category_descendants, category_breadcrumbs,
|
||||
validate_parent, GenerateTestRequest, generate_test)
|
||||
validate_parent, GenerateTestRequest, generate_test, bank_question_predicate)
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -32,21 +32,50 @@ class QCatResponse(BaseModel):
|
|||
|
||||
def category_response(db, user, cat, cats):
|
||||
ids = category_descendants(cats, [cat.id])
|
||||
extra = select(QuestionCategoryLink.question_id).where(QuestionCategoryLink.category_id.in_(ids))
|
||||
count = bank_query(db, user).filter(or_(
|
||||
Question.question_category_id.in_(ids),
|
||||
Question.id.in_(extra),
|
||||
)).count()
|
||||
return QCatResponse(id=cat.id, name=cat.name, description=cat.description, parent_id=cat.parent_id,
|
||||
breadcrumbs=category_breadcrumbs(cats, cat.id),
|
||||
question_count=count)
|
||||
breadcrumbs=category_breadcrumbs(cats, cat.id), question_count=0)
|
||||
|
||||
|
||||
def _category_totals(db, user, by_id):
|
||||
"""Distinct question totals per category via ancestor set unions (fast, no N+1)."""
|
||||
from collections import defaultdict
|
||||
rows = bank_query(db, user).with_entities(Question.id, Question.question_category_id).all()
|
||||
qids = [row[0] for row in rows]
|
||||
direct = defaultdict(set)
|
||||
for qid, cid in rows:
|
||||
if cid is not None:
|
||||
direct[cid].add(qid)
|
||||
if qids:
|
||||
for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).filter(
|
||||
QuestionCategoryLink.question_id.in_(qids)).all():
|
||||
direct[cid].add(qid)
|
||||
ancestors = {}
|
||||
for cat_id in by_id:
|
||||
path, seen = [cat_id], set()
|
||||
node = by_id[cat_id]
|
||||
while node.parent_id and node.parent_id in by_id and node.parent_id not in seen:
|
||||
seen.add(node.parent_id)
|
||||
path.append(node.parent_id)
|
||||
node = by_id[node.parent_id]
|
||||
ancestors[cat_id] = path
|
||||
totals = defaultdict(set)
|
||||
for cid, question_set in direct.items():
|
||||
for ancestor in ancestors.get(cid, [cid]):
|
||||
totals[ancestor] |= question_set
|
||||
return {cid: len(question_set) for cid, question_set in totals.items()}
|
||||
|
||||
|
||||
@router.get("/", response_model=list[QCatResponse])
|
||||
def list_question_categories(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
cats = db.query(QuestionCategory).order_by(QuestionCategory.name).all()
|
||||
# ponytail: one count per category; aggregate counts if the taxonomy becomes large.
|
||||
return [category_response(db, current_user, cat, cats) for cat in cats]
|
||||
by_id = {cat.id: cat for cat in cats}
|
||||
totals = _category_totals(db, current_user, by_id)
|
||||
return [
|
||||
QCatResponse(id=cat.id, name=cat.name, description=cat.description, parent_id=cat.parent_id,
|
||||
breadcrumbs=category_breadcrumbs(cats, cat.id),
|
||||
question_count=totals.get(cat.id, 0))
|
||||
for cat in cats
|
||||
]
|
||||
|
||||
|
||||
def validate_category(db, data, cat_id=None):
|
||||
|
|
|
|||
|
|
@ -234,6 +234,8 @@ def get_question_bank(
|
|||
uncategorized: bool = Query(False),
|
||||
favorites_only: bool = Query(False),
|
||||
my_questions: bool = Query(False, description="Show only questions created by current user"),
|
||||
difficulty: Literal["easy", "medium", "hard"] | None = Query(None),
|
||||
article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"),
|
||||
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
||||
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
|
||||
limit: int = Query(50, le=200),
|
||||
|
|
@ -246,6 +248,15 @@ def get_question_bank(
|
|||
if my_questions:
|
||||
query = query.filter(Question.user_id == current_user.id)
|
||||
|
||||
if difficulty:
|
||||
query = query.filter(Question.difficulty == difficulty)
|
||||
if article_ids:
|
||||
article_list = [int(part) for part in article_ids.split(",") if part.strip().isdigit()]
|
||||
if article_list:
|
||||
from app.models.article import QuestionArticleLink
|
||||
query = query.filter(Question.id.in_(select(QuestionArticleLink.question_id).where(
|
||||
QuestionArticleLink.article_id.in_(article_list))))
|
||||
|
||||
if quiz_id:
|
||||
query = query.filter(Question.source_quiz_id == quiz_id)
|
||||
|
||||
|
|
@ -504,10 +515,14 @@ def count_builder_questions(
|
|||
state: Literal["all", "unused", "incorrect", "bookmarked"] = "all",
|
||||
is_shared: bool = False,
|
||||
difficulty: Literal["easy", "medium", "hard"] | None = Query(None),
|
||||
article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"),
|
||||
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared, difficulty).count()}
|
||||
ids = [int(part) for part in (article_ids or "").split(",") if part.strip().isdigit()]
|
||||
tag_list = [int(part) for part in (tag_ids or "").split(",") if part.strip().isdigit()]
|
||||
return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared, difficulty, ids, tag_list).count()}
|
||||
|
||||
|
||||
@router.post("/builder")
|
||||
|
|
|
|||
|
|
@ -66,10 +66,27 @@ def bank_query(db, user):
|
|||
return db.query(Question).filter(bank_question_predicate(user))
|
||||
|
||||
|
||||
def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None):
|
||||
def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None, article_ids=(), tag_ids=()):
|
||||
query = bank_query(db, user)
|
||||
if difficulty:
|
||||
query = query.filter(Question.difficulty == difficulty)
|
||||
if article_ids:
|
||||
from app.models.article import QuestionArticleLink
|
||||
query = query.filter(Question.id.in_(select(QuestionArticleLink.question_id).where(
|
||||
QuestionArticleLink.article_id.in_(article_ids))))
|
||||
if tag_ids:
|
||||
from sqlalchemy import text as sa_text
|
||||
tag_list = list(dict.fromkeys(tag_ids))
|
||||
matching = list(db.execute(sa_text("""
|
||||
SELECT question_id FROM question_tag_links
|
||||
WHERE tag_id = ANY(:tag_ids)
|
||||
GROUP BY question_id
|
||||
HAVING COUNT(DISTINCT tag_id) = :cnt
|
||||
"""), {"tag_ids": tag_list, "cnt": len(tag_list)}).scalars())
|
||||
if matching:
|
||||
query = query.filter(Question.id.in_(matching))
|
||||
else:
|
||||
query = query.filter(Question.id.is_(None)) # No questions match all tags.
|
||||
if category_ids:
|
||||
ids = category_descendants(db.query(QuestionCategory).all(), category_ids)
|
||||
query = query.filter(or_(
|
||||
|
|
@ -127,6 +144,9 @@ class GenerateTestRequest(TestOptions):
|
|||
expected_count: int | None = Field(default=None, ge=0)
|
||||
difficulty: Literal["easy", "medium", "hard"] | None = None
|
||||
algorithm: Literal["random", "adaptive"] = "random"
|
||||
article_ids: list[int] = Field(default_factory=list)
|
||||
tag_ids: list[int] = Field(default_factory=list)
|
||||
explicit_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
def create_saved_test(db, user, data, question_ids):
|
||||
|
|
@ -203,9 +223,9 @@ def generate_test(db, user, data):
|
|||
if len(ids) < data.count:
|
||||
raise HTTPException(400, f"Only {len(ids)} questions available; requested {data.count}")
|
||||
return create_saved_test(db, user, data, ids)
|
||||
query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared)
|
||||
if data.difficulty:
|
||||
query = query.filter(Question.difficulty == data.difficulty)
|
||||
query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared, data.difficulty, data.article_ids, data.tag_ids)
|
||||
if data.explicit_ids:
|
||||
query = query.filter(Question.id.in_(list(dict.fromkeys(data.explicit_ids))))
|
||||
ids = [row[0] for row in query.with_entities(Question.id).all()]
|
||||
if data.expected_count is not None and data.expected_count != len(ids):
|
||||
raise HTTPException(409, "Available count changed. Refresh the count and try again")
|
||||
|
|
|
|||
|
|
@ -70,3 +70,16 @@ Run frontend build and targeted/full relevant suites; obtain independent review,
|
|||
- Lab-reference support includes educator-managed entries and source/age/unit fields. Do not publish unsourced or AI-invented ranges. An honest empty state is permitted until verified clinical data is supplied.
|
||||
- The user has authorized implementation. Continue each completed milestone into verification, review, Git synchronization and the next milestone; stop only for a genuine blocker.
|
||||
- No migrations or deployments were performed during the initial scoping/Git synchronization. Subsequent implementation and validation evidence must be recorded in focused commits.
|
||||
|
||||
## Pending backlog (2026-09-09)
|
||||
|
||||
1. Category management page (with search): organize parents/subparents, create new parent, delete with move_to, move questions between categories; link from bank/create pages.
|
||||
2. Question editor: use shared CategoryTree facets (primary select + checkbox tree) — in progress.
|
||||
3. Create Custom Test facets: Status / Difficulty / Systems / Articles sections — code in progress; update tests (state is now buttons, not select).
|
||||
4. Upload flow: document → matched BANK questions via keywords/embeddings; min 10MB, max 30 questions, 5-day auto-delete.
|
||||
5. AI "describe what to study" → quiz from matched bank questions (all logged-in users).
|
||||
6. Quiz action buttons: analysis / rename / repeat / delete.
|
||||
7. Study recommendations from weakest categories.
|
||||
8. Dedicated question-management CMS page with per-category educator grants.
|
||||
9. Vision review finding: question-card row consistency (Study/Share alignment, answer-chip widths).
|
||||
10. Runner header spacing polish (question review / X of Y) — CSS added, verify visually.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function CategoryPerformance() {
|
||||
const [data, setData] = useState(null)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const [adaptiveCount, setAdaptiveCount] = useState(10)
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
api.get('/study-tools/performance-by-category')
|
||||
.then(res => setData(res.data))
|
||||
|
|
@ -42,19 +45,42 @@ export default function CategoryPerformance() {
|
|||
<div className="cp-heading">
|
||||
<h2 style={{ margin: 0 }}>Performance by category</h2>
|
||||
<button type="button" className="btn btn-secondary btn-sm" aria-expanded={showAll} onClick={() => setShowAll(v => !v)}>
|
||||
{showAll ? 'Show main categories' : `Show all (${rows.length})`}
|
||||
{showAll ? 'Close' : `Show all (${rows.length})`}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: '4px 0 12px' }}>{data.basis}</p>
|
||||
{!showAll && main.map(renderRow)}
|
||||
<div className="cp-adaptive-row">
|
||||
<label className="cp-adaptive-label">Adaptive session
|
||||
<select value={adaptiveCount} onChange={e => setAdaptiveCount(parseInt(e.target.value, 10))} aria-label="Adaptive question count">
|
||||
{[3, 5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button>
|
||||
{main.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
|
||||
On my weakest topics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{main.map(renderRow)}
|
||||
{showAll && (
|
||||
<div className="cp-all">
|
||||
{main.map(row => (
|
||||
<div key={row.category_id}>
|
||||
{renderRow(row)}
|
||||
{renderBranch(row.category_id, 1)}
|
||||
<div className="cp-overlay" role="dialog" aria-label="All category performance">
|
||||
<div className="cp-panel">
|
||||
<header className="cp-panel-header">
|
||||
<h2>All categories</h2>
|
||||
<button type="button" aria-label="Close all categories" onClick={() => setShowAll(false)}>✕</button>
|
||||
</header>
|
||||
<div className="cp-panel-body">
|
||||
{main.map(row => (
|
||||
<div key={row.category_id}>
|
||||
{renderRow(row)}
|
||||
{renderBranch(row.category_id, 1)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import CategoryPerformance from './CategoryPerformance'
|
||||
import api from '../api/client'
|
||||
|
||||
|
|
@ -13,22 +15,30 @@ describe('category performance', () => {
|
|||
total_answered: 3,
|
||||
basis: 'Your completed, non-expired general test answers.',
|
||||
categories: [
|
||||
{ category_id: 2, name: 'Clinical reasoning', answered: 2, correct: 1, accuracy: 50 },
|
||||
{ category_id: 1, name: 'Pediatrics', answered: 1, correct: 1, accuracy: 100 },
|
||||
{ category_id: 2, name: 'Clinical reasoning', parent_id: null, answered: 2, correct: 1, accuracy: 50 },
|
||||
{ category_id: 1, name: 'Pediatrics', parent_id: null, answered: 1, correct: 1, accuracy: 100 },
|
||||
{ category_id: 3, name: 'Neonatal', parent_id: 1, answered: 1, correct: 0, accuracy: 0 },
|
||||
],
|
||||
} })
|
||||
render(<CategoryPerformance />)
|
||||
render(<MemoryRouter><CategoryPerformance /></MemoryRouter>)
|
||||
expect(await screen.findByTestId('category-performance')).toBeInTheDocument()
|
||||
expect(screen.getByText('Clinical reasoning')).toBeInTheDocument()
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
expect(screen.getByText('1/2')).toBeInTheDocument()
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
expect(screen.getByText(/completed, non-expired/)).toBeInTheDocument()
|
||||
// Child rows stay out of the compact view until the side panel opens.
|
||||
expect(screen.queryByText('Neonatal')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Show all (3)' }))
|
||||
const panel = await screen.findByRole('dialog', { name: 'All category performance' })
|
||||
expect(within(panel).getByText('Neonatal')).toBeInTheDocument()
|
||||
await userEvent.click(within(panel).getByRole('button', { name: 'Close all categories' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing without category data', async () => {
|
||||
api.get.mockResolvedValue({ data: { total_answered: 0, categories: [] } })
|
||||
const { container } = render(<CategoryPerformance />)
|
||||
const { container } = render(<MemoryRouter><CategoryPerformance /></MemoryRouter>)
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalled())
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(container.querySelector('[data-testid="category-performance"]')).toBeNull()
|
||||
|
|
|
|||
55
frontend/src/components/CategoryTree.jsx
Normal file
55
frontend/src/components/CategoryTree.jsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
export default function CategoryTree({ categories, selectedIds, onToggle, excludedId = null, searchable = false }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const childrenOf = {}
|
||||
for (const cat of categories) {
|
||||
;(childrenOf[cat.parent_id || 0] ||= []).push(cat)
|
||||
}
|
||||
const visible = searchable
|
||||
? categories.filter(cat => [cat.name, ...(cat.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(query.toLowerCase()))
|
||||
: categories
|
||||
const descendantSelected = (cat) => {
|
||||
const ids = []
|
||||
const walk = (id) => { for (const child of childrenOf[id] || []) { ids.push(child.id); walk(child.id) } }
|
||||
walk(cat.id)
|
||||
return ids.some(id => selectedIds.includes(id))
|
||||
}
|
||||
const renderBranch = (parentId) => {
|
||||
const branch = (childrenOf[parentId] || []).filter(cat => visible.includes(cat))
|
||||
if (!branch.length) return null
|
||||
return <ul className="category-tree">
|
||||
{branch.map(cat => {
|
||||
const excluded = cat.id === excludedId
|
||||
const node = (
|
||||
<label className={excluded ? 'category-tree-excluded' : ''}>
|
||||
<input type="checkbox" checked={selectedIds.includes(cat.id)} disabled={excluded}
|
||||
onChange={e => onToggle(cat.id, e.target.checked)} />
|
||||
<span className="category-tree-name">{cat.name}</span>
|
||||
<span className="category-tree-count">({cat.question_count})</span>
|
||||
</label>
|
||||
)
|
||||
const kids = (childrenOf[cat.id] || []).filter(child => visible.includes(child))
|
||||
if (!kids.length) return <li key={cat.id}>{node}</li>
|
||||
return <li key={cat.id}>
|
||||
<details open={!!query || selectedIds.includes(cat.id) || descendantSelected(cat)}>
|
||||
<summary className="category-tree-branch"><span className="category-tree-chevron" aria-hidden="true" />{node}</summary>
|
||||
{renderBranch(cat.id)}
|
||||
</details>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
return (
|
||||
<div className="category-tree-wrap">
|
||||
{searchable && (
|
||||
<div className="custom-test-search">
|
||||
<span className="custom-test-search-icon" aria-hidden="true">🔍</span>
|
||||
<input type="search" value={query} onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search topics…" aria-label="Search topics" className="input" />
|
||||
</div>
|
||||
)}
|
||||
{renderBranch(0)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export default function CommentSection({ articleId, questionId }) {
|
|||
|
||||
const load = useCallback(async (off = 0) => {
|
||||
try {
|
||||
const res = await api.get('/comments', { params: { ...params, limit: LIMIT, offset: off } })
|
||||
const res = await api.get('/comments/', { params: { ...params, limit: LIMIT, offset: off } })
|
||||
const list = res.data?.comments || []
|
||||
setComments(prev => off === 0 ? list : [...prev, ...list])
|
||||
setTotal(res.data?.total || 0)
|
||||
|
|
@ -33,7 +33,7 @@ export default function CommentSection({ articleId, questionId }) {
|
|||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await api.post('/comments', { ...params, content: draft })
|
||||
const res = await api.post('/comments/', { ...params, content: draft })
|
||||
setComments(prev => [res.data, ...prev.filter(c => c.id !== res.data.id)])
|
||||
setTotal(t => t + 1)
|
||||
setDraft('')
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe('comment section', () => {
|
|||
await screen.findByRole('heading', { name: /Discussion/ })
|
||||
await userEvent.type(screen.getByLabelText('Comment text'), 'New note')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Post comment' }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments', { question_id: 5, content: 'New note' }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments/', { question_id: 5, content: 'New note' }))
|
||||
expect(await screen.findByText('New note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -746,3 +746,17 @@ body {
|
|||
.category-performance-row { grid-template-columns: 1fr 56px; }
|
||||
.cp-track { grid-column: 1 / -1; grid-row: 2; }
|
||||
}
|
||||
.cp-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.cp-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35); z-index: 1050; display: flex; justify-content: flex-end; }
|
||||
.cp-panel { background: var(--card-bg); width: min(520px, 92vw); height: 100%; display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,0.18); }
|
||||
.cp-panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border); }
|
||||
.cp-panel-header h2 { margin: 0; font-size: 1rem; }
|
||||
.cp-panel-header button { background: none; border: none; font-size: 1.1rem; cursor: pointer; color: var(--text-muted); }
|
||||
.cp-panel-body { flex: 1; overflow-y: auto; padding: 14px 18px; }
|
||||
@media (max-width: 640px) {
|
||||
.cp-overlay { align-items: flex-end; }
|
||||
.cp-panel { width: 100%; height: 85vh; border-radius: 16px 16px 0 0; }
|
||||
}
|
||||
.cp-adaptive-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); }
|
||||
.cp-adaptive-label { display: flex; align-items: center; gap: 6px; font-size: .82rem; }
|
||||
.cp-adaptive-label select { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export default function ArticlesPage() {
|
|||
const params = {}
|
||||
if (categoryId) params.category_id = categoryId
|
||||
if (query.trim()) params.q = query.trim()
|
||||
api.get('/articles', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false))
|
||||
api.get('/articles/', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false))
|
||||
}, [categoryId, query])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
|
@ -61,7 +61,7 @@ export default function ArticlesPage() {
|
|||
setError('')
|
||||
if (!title.trim() || !slug.trim()) { setError('Title and slug are required'); return }
|
||||
try {
|
||||
const res = await api.post('/articles', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] })
|
||||
const res = await api.post('/articles/', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] })
|
||||
setShowCreate(false)
|
||||
navigate(`/articles/${res.data.id}?edit=1`)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ beforeEach(() => {
|
|||
vi.resetAllMocks()
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/question-categories') return Promise.resolve({ data: [] })
|
||||
if (url === '/articles') return Promise.resolve({ data: [article] })
|
||||
if (url === '/articles/') return Promise.resolve({ data: [article] })
|
||||
if (url === '/articles/linked') return Promise.resolve({ data: [article] })
|
||||
if (url === '/articles/1') return Promise.resolve({ data: article })
|
||||
if (url === '/articles/1/questions') return Promise.resolve({ data: [{ question_id: 1, question_text: 'Linked question text', correct_answer: 'yes', explanation: 'Why', section_id: null }] })
|
||||
|
|
|
|||
|
|
@ -52,3 +52,6 @@ details[open] > .custom-test-branch .custom-test-chevron { transform: rotate(45d
|
|||
.custom-test-actions .btn-primary { flex: 1; background: var(--primary); color: var(--primary-fg); font-weight: 600; padding: 10px 16px; }
|
||||
.custom-test-main > p { margin: 8px 0 0; }
|
||||
.custom-test-main > .custom-test-share { margin-top: 8px; }
|
||||
.custom-test-tags { display: flex; flex-direction: column; gap: 2px; max-height: 24vh; overflow-y: auto; }
|
||||
.custom-test-tags label { display: flex; gap: 6px; align-items: baseline; font-size: .82rem; cursor: pointer; }
|
||||
.custom-test-exam { display: flex; gap: 6px; align-items: center; font-size: .84rem; font-weight: 600; color: var(--primary); margin: 4px 0; }
|
||||
|
|
|
|||
|
|
@ -12,12 +12,18 @@ export default function CustomQuizPage() {
|
|||
const [categoryIds, setCategoryIds] = useState(() => [...new Set(searchParams.getAll('category').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))])
|
||||
const [state, setState] = useState('all')
|
||||
const [shared, setShared] = useState(false)
|
||||
const [title, setTitle] = useState(() => `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`)
|
||||
const [title, setTitle] = useState(() => {
|
||||
const now = new Date()
|
||||
return `Custom test from ${now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${now.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}`
|
||||
})
|
||||
const [mode, setMode] = useState('learning')
|
||||
const [time, setTime] = useState('')
|
||||
const [count, setCount] = useState(20)
|
||||
const [count, setCount] = useState(() => {
|
||||
const fromUrl = parseInt(searchParams.get('count'), 10)
|
||||
return Number.isInteger(fromUrl) && fromUrl >= 1 && fromUrl <= 200 ? fromUrl : 20
|
||||
})
|
||||
const [difficulty, setDifficulty] = useState('')
|
||||
const [adaptive, setAdaptive] = useState(false)
|
||||
const [adaptive, setAdaptive] = useState(() => searchParams.get('adaptive') === '1')
|
||||
const [available, setAvailable] = useState(null)
|
||||
const [countKey, setCountKey] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
|
@ -40,6 +46,8 @@ export default function CustomQuizPage() {
|
|||
const params = new URLSearchParams({ state, is_shared: String(shared) })
|
||||
if (difficulty) params.append('difficulty', difficulty)
|
||||
categoryIds.forEach(id => params.append('category_ids', id))
|
||||
articleIds.forEach(id => params.append('article_ids', id))
|
||||
tagIds.forEach(id => params.append('tag_ids', id))
|
||||
api.get('/questions/builder/count', { params }).then(r => {
|
||||
if (active) { setAvailable(r.data.count); setCountKey(filterKey) }
|
||||
}).catch(() => { if (active) setCountError('Could not load available count. Try refreshing.') })
|
||||
|
|
@ -58,6 +66,7 @@ export default function CustomQuizPage() {
|
|||
title: title.trim(), category_ids: categoryIds, state, count: Number(count),
|
||||
expected_count: available, mode, time_limit_minutes: mode === 'timed' && time ? Number(time) : null,
|
||||
is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random',
|
||||
article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds,
|
||||
})
|
||||
navigate(`/quizzes/${result.data.id}`)
|
||||
} catch (err) {
|
||||
|
|
@ -69,6 +78,26 @@ export default function CustomQuizPage() {
|
|||
|
||||
const [catSearch, setCatSearch] = useState('')
|
||||
const [filtersOpen, setFiltersOpen] = useState(true)
|
||||
const [articleIds, setArticleIds] = useState([])
|
||||
const [tagIds, setTagIds] = useState([])
|
||||
const [articles, setArticles] = useState([])
|
||||
const [tags, setTags] = useState({ subjects: [], keywords: [] })
|
||||
const [presetIds, setPresetIds] = useState([])
|
||||
const [collections, setCollections] = useState([])
|
||||
useEffect(() => {
|
||||
api.get('/articles/').then(res => setArticles(Array.isArray(res.data) ? res.data : [])).catch(() => setArticles([]))
|
||||
api.get('/tags').then(res => setTags(res.data && res.data.subjects ? res.data : { subjects: [], keywords: [] })).catch(() => {})
|
||||
api.get('/collections/').then(res => setCollections(Array.isArray(res.data) ? res.data : [])).catch(() => setCollections([]))
|
||||
}, [])
|
||||
const togglePreset = async (collection) => {
|
||||
if (presetIds.includes(collection.id)) { setPresetIds(ids => ids.filter(id => id !== collection.id)); return }
|
||||
setPresetIds(ids => [...ids, collection.id])
|
||||
if (!collection._loaded) {
|
||||
const res = await api.get(`/collections/${collection.id}/questions`)
|
||||
setCollections(prev => prev.map(c => c.id === collection.id ? { ...c, _loaded: true, question_ids: res.data.map(q => q.id) } : c))
|
||||
}
|
||||
}
|
||||
const explicitIds = [...new Set(collections.filter(c => presetIds.includes(c.id)).flatMap(c => c.question_ids || []))]
|
||||
const childrenOf = {}
|
||||
for (const cat of categories) {
|
||||
;(childrenOf[cat.parent_id || 0] ||= []).push(cat)
|
||||
|
|
@ -114,7 +143,42 @@ export default function CustomQuizPage() {
|
|||
<button type="button" className="custom-test-filters-toggle" aria-expanded={filtersOpen}
|
||||
onClick={() => setFiltersOpen(v => !v)}>{filtersOpen ? '✕ Hide filters' : '☰ Filters'}</button>
|
||||
<div className={`custom-test-filters-body ${filtersOpen ? 'open' : ''}`}>
|
||||
<h2>Topics</h2>
|
||||
<h2>Filters</h2>
|
||||
<h3>Status</h3>
|
||||
<div className="bank-state-buttons">
|
||||
{[['all', 'All'], ['unused', 'Unused'], ['incorrect', 'Incorrect'], ['bookmarked', 'Saved']].map(([value, label]) => (
|
||||
<button key={value} type="button" className={`btn btn-sm ${state === value ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setState(value)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
<h3>Difficulty</h3>
|
||||
<select value={difficulty} onChange={e => setDifficulty(e.target.value)} className="input" aria-label="Difficulty">
|
||||
<option value="">Any</option><option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option><option value="hard">Hard</option>
|
||||
</select>
|
||||
<h3>Exams</h3>
|
||||
<label className="custom-test-exam"><input type="checkbox" checked readOnly /> Pediatrics Boards</label>
|
||||
<h3>Disciplines</h3>
|
||||
<div className="custom-test-tags">
|
||||
{(tags.subjects || []).slice(0, 40).map(tag => (
|
||||
<label key={tag.id}>
|
||||
<input type="checkbox" checked={tagIds.includes(tag.id)}
|
||||
onChange={e => setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
|
||||
{tag.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<h3>Symptoms & keywords</h3>
|
||||
<div className="custom-test-tags">
|
||||
{(tags.keywords || []).slice(0, 40).map(tag => (
|
||||
<label key={tag.id}>
|
||||
<input type="checkbox" checked={tagIds.includes(tag.id)}
|
||||
onChange={e => setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
|
||||
{tag.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<h3>Systems</h3>
|
||||
<p>Parent categories include all their subcategories.</p>
|
||||
<div className="custom-test-search">
|
||||
<span className="custom-test-search-icon" aria-hidden="true">🔍</span>
|
||||
|
|
@ -123,19 +187,32 @@ export default function CustomQuizPage() {
|
|||
</div>
|
||||
{renderTree(0)}
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
|
||||
<h3>Saved</h3>
|
||||
<div className="custom-test-tags">
|
||||
<label><input type="checkbox" checked={state === 'bookmarked'} onChange={e => setState(e.target.checked ? 'bookmarked' : 'all')} /> Bookmarked questions</label>
|
||||
{collections.map(collection => (
|
||||
<label key={collection.id}>
|
||||
<input type="checkbox" checked={presetIds.includes(collection.id)} onChange={() => togglePreset(collection)} />
|
||||
{collection.title} ({collection.question_count})
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<h3>Articles</h3>
|
||||
<div className="custom-test-articles">
|
||||
{(articles || []).map(article => (
|
||||
<label key={article.id}>
|
||||
<input type="checkbox" checked={articleIds.includes(article.id)}
|
||||
onChange={e => setArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
|
||||
{article.title}
|
||||
</label>
|
||||
))}
|
||||
{(articles || []).length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '.8rem' }}>No articles yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="custom-test-main">
|
||||
<div className="custom-test-settings card">
|
||||
<label>Title<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} className="custom-test-title-input" /></label>
|
||||
<label>Question state<select value={state} onChange={e => setState(e.target.value)}>
|
||||
<option value="all">All</option><option value="unused">Unused</option>
|
||||
<option value="incorrect">Incorrect</option><option value="bookmarked">Bookmarked</option>
|
||||
</select></label>
|
||||
<label>Difficulty<select value={difficulty} onChange={e => setDifficulty(e.target.value)}>
|
||||
<option value="">Any</option><option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option><option value="hard">Hard</option>
|
||||
</select></label>
|
||||
<label className="custom-test-adaptive"><input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} /> <span className="custom-test-switch" aria-hidden="true"><span /></span> Adaptive session</label>
|
||||
{adaptive && <p className="custom-test-adaptive-note">Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.</p>}
|
||||
<label>Number of questions<input type="number" required min="1" max="200" step="1" value={count} onChange={e => setCount(e.target.value)} /></label>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ const categories = [
|
|||
{ id: 2, name: 'Neonatal', question_count: 10, breadcrumbs: [{ id: 1, name: 'Pediatrics' }, { id: 2, name: 'Neonatal' }] },
|
||||
]
|
||||
function setupCount(count = 30) {
|
||||
api.get.mockImplementation(url => Promise.resolve({ data: url === '/question-categories/' ? categories : { count } }))
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/question-categories/') return Promise.resolve({ data: categories })
|
||||
if (url.startsWith('/articles') || url.startsWith('/collections') || url.startsWith('/tags')) return Promise.resolve({ data: url.startsWith('/articles') ? [] : url.startsWith('/collections') ? [] : { subjects: [], keywords: [] } })
|
||||
return Promise.resolve({ data: { count } })
|
||||
})
|
||||
}
|
||||
function renderBuilder() {
|
||||
render(<MemoryRouter initialEntries={['/quizzes/create']}><Routes>
|
||||
|
|
@ -34,7 +38,7 @@ describe('CustomQuizPage', () => {
|
|||
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByLabelText('Pediatrics (30)'))
|
||||
await userEvent.click(screen.getByLabelText('Neonatal (10)'))
|
||||
await userEvent.selectOptions(screen.getByLabelText('Question state'), 'incorrect')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Unused' }))
|
||||
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
|
||||
fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })
|
||||
fireEvent.change(screen.getByLabelText('Number of questions'), { target: { value: '10' } })
|
||||
|
|
@ -43,13 +47,13 @@ describe('CustomQuizPage', () => {
|
|||
const calls = api.get.mock.calls.filter(([url]) => url === '/questions/builder/count')
|
||||
const params = calls.at(-1)[1].params
|
||||
expect(params.getAll('category_ids')).toEqual(['1', '2'])
|
||||
expect(params.get('state')).toBe('incorrect')
|
||||
expect(params.get('state')).toBe('unused')
|
||||
expect(params.get('is_shared')).toBe('true')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Test' }))
|
||||
expect(api.post).toHaveBeenCalledWith('/questions/builder', {
|
||||
title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, category_ids: [1, 2], state: 'incorrect', count: 10,
|
||||
title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${new Date().toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}`, category_ids: [1, 2], state: 'unused', count: 10,
|
||||
mode: 'timed', time_limit_minutes: 15, expected_count: 30, is_shared: true,
|
||||
difficulty: null, algorithm: 'random',
|
||||
difficulty: null, algorithm: 'random', article_ids: [], tag_ids: [], explicit_ids: [],
|
||||
})
|
||||
await screen.findByRole('heading', { name: 'Saved test' })
|
||||
})
|
||||
|
|
@ -92,7 +96,7 @@ describe('CustomQuizPage', () => {
|
|||
await screen.findByLabelText('Pediatrics (30)')
|
||||
const old = resolveOld
|
||||
setupCount(4)
|
||||
await userEvent.selectOptions(screen.getByLabelText('Question state'), 'bookmarked')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Saved' }))
|
||||
await screen.findByText('4 questions available')
|
||||
old({ data: { count: 100 } })
|
||||
await waitFor(() => expect(screen.queryByText('100 questions available')).not.toBeInTheDocument())
|
||||
|
|
|
|||
|
|
@ -21,3 +21,16 @@
|
|||
.bank-layout { grid-template-columns: 1fr; }
|
||||
.bank-category-list { max-height: none; }
|
||||
}
|
||||
.category-tree-wrap { margin-top: 6px; }
|
||||
.category-tree { list-style: none; margin: 0; padding: 0; max-height: 46vh; overflow-y: auto; }
|
||||
.category-tree ul { list-style: none; margin: 0 0 0 16px; padding: 0; }
|
||||
.category-tree li { margin: 1px 0; }
|
||||
.category-tree label { display: flex; align-items: baseline; gap: 6px; font-size: .84rem; cursor: pointer; }
|
||||
.category-tree-count { color: var(--text-muted); font-size: .74rem; }
|
||||
.category-tree-excluded { opacity: .5; }
|
||||
.category-tree-branch { display: flex; align-items: center; cursor: pointer; list-style: none; }
|
||||
.category-tree-branch::-webkit-details-marker { display: none; }
|
||||
.category-tree-chevron { display: inline-block; width: 7px; height: 7px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); margin-right: 6px; transition: transform .15s ease; flex-shrink: 0; }
|
||||
details[open] > .category-tree-branch .category-tree-chevron { transform: rotate(45deg); }
|
||||
.bank-articles { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; max-height: 30vh; overflow-y: auto; }
|
||||
.bank-articles label { display: flex; gap: 6px; align-items: baseline; font-size: .82rem; cursor: pointer; }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useNavigate, Link } from 'react-router-dom'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
import Dialog from '../components/Dialog'
|
||||
import CategoryTree from '../components/CategoryTree'
|
||||
import { useDialog } from '../hooks/useDialog'
|
||||
|
||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||
|
|
@ -22,7 +23,7 @@ function stripHtml(html) {
|
|||
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }) {
|
||||
function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) {
|
||||
const [answered, setAnswered] = useState(null)
|
||||
return (
|
||||
<>
|
||||
|
|
@ -105,6 +106,27 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
|
|||
</div>
|
||||
)}
|
||||
<QuestionReadingLinks questionId={question.id} />
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10, fontSize: '0.85rem' }}>
|
||||
<strong>Add to library</strong>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
<select defaultValue="" onChange={async e => {
|
||||
if (!e.target.value) return
|
||||
await api.put(`/collections/${e.target.value}/questions/${question.id}`)
|
||||
e.target.value = ''
|
||||
}} aria-label="Add to collection">
|
||||
<option value="">Choose collection…</option>
|
||||
{collections.map(c => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||
</select>
|
||||
<input placeholder="New library…" aria-label="New library name" onKeyDown={async e => {
|
||||
if (e.key === 'Enter' && e.target.value.trim()) {
|
||||
const res = await api.post('/collections/', { title: e.target.value.trim() })
|
||||
await api.put(`/collections/${res.data.id}/questions/${question.id}`)
|
||||
setCollections(prev => [...prev, res.data])
|
||||
e.target.value = ''
|
||||
}
|
||||
}} style={{ padding: '4px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
{answered && (
|
||||
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
||||
|
|
@ -323,7 +345,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
|
|||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div role="dialog" aria-label="Edit Question" style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: '1.1rem' }}>Edit Question</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||
|
|
@ -368,28 +390,20 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
|
|||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Question Category</label>
|
||||
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}>
|
||||
<label>Categories</label>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
|
||||
Primary category drives the main listing; additional categories place the question in every selected branch.
|
||||
</p>
|
||||
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category">
|
||||
<option value="">— Uncategorized —</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Additional subcategories</label>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
|
||||
A question can appear in several categories; the primary category stays the main one.
|
||||
</p>
|
||||
<div className="category-chips" role="group" aria-label="Additional subcategories">
|
||||
{categories.filter(c => c.id !== (form.question_category_id ? parseInt(form.question_category_id) : null)).map(c => {
|
||||
const checked = form.extraCategoryIds.includes(c.id)
|
||||
return (
|
||||
<label key={c.id} className={checked ? 'category-chip checked' : 'category-chip'}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleExtra(c.id)} />
|
||||
{(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
selectedIds={form.extraCategoryIds}
|
||||
excludedId={form.question_category_id ? parseInt(form.question_category_id) : null}
|
||||
onToggle={toggleExtra}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Key points (smart links to reading)</label>
|
||||
|
|
@ -650,6 +664,13 @@ export default function QuestionBankPage() {
|
|||
const [categories, setCategories] = useState([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [difficulty, setDifficulty] = useState('')
|
||||
const [bankArticleIds, setBankArticleIds] = useState([])
|
||||
const [articles, setArticles] = useState([])
|
||||
const [collections, setCollections] = useState([])
|
||||
useEffect(() => {
|
||||
api.get('/articles/').then(res => setArticles(res.data || [])).catch(() => setArticles([]))
|
||||
api.get('/collections/').then(res => setCollections(res.data || [])).catch(() => setCollections([]))
|
||||
}, [])
|
||||
const [searchMode, setSearchMode] = useState('hybrid')
|
||||
const [filterCatIds, setFilterCatIds] = useState([])
|
||||
const [showUncategorized, setShowUncategorized] = useState(false)
|
||||
|
|
@ -716,7 +737,7 @@ export default function QuestionBankPage() {
|
|||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey, difficulty])
|
||||
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey, difficulty, bankArticleIds.join(',')])
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setSelectedTagIds(prev => {
|
||||
|
|
@ -739,6 +760,7 @@ export default function QuestionBankPage() {
|
|||
const params = {}
|
||||
if (searchQuery.trim()) params.q = searchQuery.trim()
|
||||
if (difficulty) params.difficulty = difficulty
|
||||
if (bankArticleIds.length) params.article_ids = bankArticleIds.join(',')
|
||||
if (filterCatIds.length > 0) params.category_ids = filterCatIds.join(',')
|
||||
if (showUncategorized) params.uncategorized = true
|
||||
if (showFavorites) params.favorites_only = true
|
||||
|
|
@ -916,7 +938,7 @@ export default function QuestionBankPage() {
|
|||
)
|
||||
})()}
|
||||
|
||||
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)}
|
||||
{studyQuestion && <QuestionStudyModal question={studyQuestion} collections={collections} onClose={() => setStudyQuestion(null)}
|
||||
isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />}
|
||||
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
|
||||
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
|
||||
|
|
@ -1033,6 +1055,17 @@ export default function QuestionBankPage() {
|
|||
<option value="medium">Medium</option><option value="hard">Hard</option>
|
||||
</select>
|
||||
</label>
|
||||
<h3>Articles</h3>
|
||||
<div className="bank-articles">
|
||||
{articles.map(article => (
|
||||
<label key={article.id}>
|
||||
<input type="checkbox" checked={bankArticleIds.includes(article.id)}
|
||||
onChange={e => setBankArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
|
||||
{article.title}
|
||||
</label>
|
||||
))}
|
||||
{articles.length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '.8rem' }}>No articles yet.</p>}
|
||||
</div>
|
||||
<h3>Categories</h3>
|
||||
<div className="bank-category-list">
|
||||
{categories.map(cat => {
|
||||
|
|
|
|||
|
|
@ -217,12 +217,11 @@ describe('QuestionBankPage edit modal multi-category', () => {
|
|||
api.patch = vi.fn().mockResolvedValue({ data: {} })
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
expect(await screen.findByRole('group', { name: 'Additional subcategories' })).toBeInTheDocument()
|
||||
const extras = screen.getByRole('group', { name: 'Additional subcategories' })
|
||||
expect(within(extras).getByLabelText('Cardiology')).toBeChecked()
|
||||
expect(within(extras).getByLabelText('Renal')).not.toBeChecked()
|
||||
expect(within(extras).queryByLabelText('Neonatology')).not.toBeInTheDocument()
|
||||
await userEvent.click(within(extras).getByLabelText('Renal'))
|
||||
const modal = await screen.findByRole('dialog', { name: 'Edit Question' })
|
||||
expect(within(modal).getByLabelText(/Cardiology/)).toBeChecked()
|
||||
expect(within(modal).getByLabelText(/Renal/)).not.toBeChecked()
|
||||
expect(within(modal).getByLabelText(/Neonatology/)).toBeDisabled()
|
||||
await userEvent.click(within(modal).getByLabelText(/Renal/))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
||||
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({
|
||||
additional_category_ids: [2, 3],
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ export default function QuizPage() {
|
|||
const [resumedExpired, setResumedExpired] = useState(false)
|
||||
const [resumeRetry, setResumeRetry] = useState(0)
|
||||
const [progressError, setProgressError] = useState('')
|
||||
const [restartConfirm, setRestartConfirm] = useState(false)
|
||||
const timerRef = useRef(null)
|
||||
const toastRef = useRef(null)
|
||||
const hasStarted = useRef(false)
|
||||
|
|
@ -637,7 +638,7 @@ export default function QuizPage() {
|
|||
return () => clearInterval(timerRef.current)
|
||||
}, [id, resumeRetry])
|
||||
|
||||
const startAttempt = async (mode, voice, timerMinutes = null) => {
|
||||
const startAttempt = async (mode, voice, timerMinutes = null, fresh = false) => {
|
||||
hasStarted.current = true
|
||||
setSelectedVoice(voice)
|
||||
setStarting(true)
|
||||
|
|
@ -646,7 +647,7 @@ export default function QuizPage() {
|
|||
|
||||
try {
|
||||
// Start attempt first (may select random question subset)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}`)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}${fresh ? '&fresh=true' : ''}`)
|
||||
mode = attemptRes.data.mode || mode
|
||||
setAttemptId(attemptRes.data.id)
|
||||
const aid = attemptRes.data.id
|
||||
|
|
@ -1044,6 +1045,21 @@ const timerStarted = timeLeft !== null
|
|||
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
|
||||
⏸ Suspend
|
||||
</button>
|
||||
{restartConfirm ? (
|
||||
<span className="quiz-restart-confirm" role="alert">Restart from the beginning?
|
||||
<button className="btn btn-primary btn-sm" onClick={async () => {
|
||||
setRestartConfirm(false)
|
||||
setAnswers({}); setCurrentIdx(0); setDraftAnswer(''); setStartedAt(null)
|
||||
setTimeLeft(null); setTotalTime(null); setResponseStats(null); setStatsError('')
|
||||
setResumedExpired(false)
|
||||
hasStarted.current = false
|
||||
await startAttempt(quizMode || (quiz?.mode === 'timed' ? 'exam' : 'study'), selectedVoice || null, quiz?.time_limit_minutes || null, true)
|
||||
}}>Yes, restart</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(false)}>Cancel</button>
|
||||
</span>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning">↺ Restart</button>
|
||||
)}
|
||||
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -92,3 +92,4 @@
|
|||
.quiz-header-card { padding-bottom: 12px; }
|
||||
.quiz-response-stat { font-size: .7rem; }
|
||||
}
|
||||
.quiz-restart-confirm { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--wrong-fg); background: var(--wrong-bg); border: 1px solid var(--wrong-bd); padding: 4px 10px; border-radius: 8px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue