feat: feedback replaces comments; Qbank is a landing page; one question page
Comments are gone. A thread under every question was a discussion nobody
moderated, and what it was used for was telling an educator something was
wrong. That is now feedback: a private report, carrying the question id,
that someone is expected to act on.
* Give feedback sits in the question bar's new "more" menu, beside Save
and Share — occasional actions, folded away rather than each taking a
slot in a bar read on every question.
* An educator gets a badge of what is outstanding. Each row names the
question and opens its editor, where the report sits beside the field
it is about; reply, resolve, reopen or delete from there.
* Resolving keeps the report. A question with a history of the same
complaint should visibly have one; deleting is for the ones that were
never about the question.
* A granted educator sees only their own branch. The badge answers
quietly with zero for someone with no access, so the header can ask
without first working out who is asking.
The question bank is now the Qbank: create a session, and the last three
with Resume. Its facets, tag tree and create-a-quiz were a second copy of
the custom-session page; marking and folders belong in the player while
you are sitting a question. Import and export moved to the question
manager, which is the one place questions are managed, and which now has
a Preview that opens over the list instead of a page you have to come
back from.
Fixed while there: a session in progress analysed as 0/0 with an empty
table, because the analysis read attempt_answers — written on submit —
while the session list counted the saved progress. They read the same
thing now. The category trail is gone from the player: it named the
answer's own topic and led out of a session part-way through. An option's
reasoning opens on click and closes on the next one.
Backend 253/253, frontend 323/323.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
522af7181c
commit
392a2cc483
32 changed files with 1496 additions and 1296 deletions
44
backend/alembic/versions/c3d4e5f6a7b8_question_feedback.py
Normal file
44
backend/alembic/versions/c3d4e5f6a7b8_question_feedback.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Feedback on a question, replacing the comment thread
|
||||
|
||||
A comment thread under every question is a discussion that needs moderating.
|
||||
What people used it for was telling an educator something was wrong, which is a
|
||||
private report someone is meant to act on. This is that.
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2c3d4e5f6a7
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c3d4e5f6a7b8"
|
||||
down_revision = "b2c3d4e5f6a7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Guarded: create_all() may have built it already on a fresh deploy.
|
||||
if "question_feedback" in sa.inspect(op.get_bind()).get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"question_feedback",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("question_id", sa.Integer(),
|
||||
sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="open"),
|
||||
sa.Column("reply", sa.Text(), nullable=True),
|
||||
sa.Column("replied_by", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("replied_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_question_feedback_question_id", "question_feedback", ["question_id"])
|
||||
op.create_index("ix_question_feedback_status", "question_feedback", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if "question_feedback" in sa.inspect(op.get_bind()).get_table_names():
|
||||
op.drop_table("question_feedback")
|
||||
|
|
@ -12,6 +12,7 @@ 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, exams
|
||||
from app.routers import access
|
||||
from app.routers import feedback
|
||||
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
|
||||
from app.utils.auth import get_password_hash
|
||||
|
||||
|
|
@ -614,6 +615,7 @@ app.include_router(uploads.router)
|
|||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
|
||||
app.include_router(access.router, prefix="/api/access", tags=["access"])
|
||||
app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"])
|
||||
app.include_router(exams.router, prefix="/api/exams", tags=["exams"])
|
||||
app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"])
|
||||
app.include_router(media.router, prefix="/api/media", tags=["media"])
|
||||
|
|
|
|||
|
|
@ -40,3 +40,5 @@ __all__ = [
|
|||
"UserCollection",
|
||||
"UserCollectionQuestion",
|
||||
]
|
||||
|
||||
from app.models.feedback import QuestionFeedback # noqa: F401
|
||||
|
|
|
|||
30
backend/app/models/feedback.py
Normal file
30
backend/app/models/feedback.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class QuestionFeedback(Base):
|
||||
"""A learner telling an educator something is wrong with a question.
|
||||
|
||||
It replaces the comment thread that used to sit under every question. A
|
||||
discussion is public and needs moderating; this is a private report that
|
||||
someone is expected to act on, which is what people were using comments for
|
||||
anyway. It carries the question id because that is what an educator needs
|
||||
to find the thing being reported.
|
||||
"""
|
||||
|
||||
__tablename__ = "question_feedback"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
message = Column(Text, nullable=False)
|
||||
#: open | resolved. Kept rather than deleted on resolve, so a question with
|
||||
#: a history of the same complaint is visibly that.
|
||||
status = Column(String(20), nullable=False, default="open", index=True)
|
||||
reply = Column(Text, nullable=True)
|
||||
replied_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
replied_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
|
@ -9,7 +9,7 @@ from pydantic import BaseModel
|
|||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import case, func
|
||||
|
||||
from app.services.attempt_expiry import active_key, progress_key, settle_if_expired
|
||||
from app.services.attempt_expiry import active_key, load_saved, progress_key, settle_if_expired
|
||||
from app.services.question_figures import figures_for_questions
|
||||
from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete
|
||||
from app.database import get_db
|
||||
|
|
@ -768,6 +768,50 @@ def quiz_analysis(
|
|||
}
|
||||
|
||||
|
||||
class _LiveAnswer:
|
||||
"""An answer that has been given but not yet submitted.
|
||||
|
||||
Shaped like an AttemptAnswer so the analysis does not have to care which of
|
||||
the two it is reading. Not persisted: submitting is what writes answers,
|
||||
and reading a page must not.
|
||||
"""
|
||||
|
||||
__slots__ = ("question_id", "user_answer", "is_correct", "seconds_spent")
|
||||
|
||||
def __init__(self, question_id, user_answer, is_correct):
|
||||
self.question_id = question_id
|
||||
self.user_answer = user_answer
|
||||
self.is_correct = is_correct
|
||||
# Per-question timing is submitted with the attempt, so a live session
|
||||
# has none yet. None, not zero — those are different claims.
|
||||
self.seconds_spent = None
|
||||
|
||||
|
||||
def _rows_from_progress(db: Session, user_id: int, attempt: QuizAttempt) -> list:
|
||||
"""Grade what is saved for a live attempt, so it can be analysed mid-session."""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
|
||||
from app.config import settings
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
saved = load_saved(r, user_id, attempt.id)
|
||||
except Exception:
|
||||
logger.warning("Redis unavailable for live analysis of attempt %s", attempt.id, exc_info=True)
|
||||
return []
|
||||
answers = (saved or {}).get("answers") or {}
|
||||
if not answers:
|
||||
return []
|
||||
graded = grade_quiz_answers(
|
||||
get_quiz_questions(db, attempt.quiz_id),
|
||||
[(int(qid), value) for qid, value in answers.items()],
|
||||
attempt.selected_question_ids,
|
||||
)
|
||||
# Every question, not only the answered ones: the unanswered are what make
|
||||
# the figure read "1 of 34" and the table show the rest as skipped, exactly
|
||||
# as a finished session does.
|
||||
return [_LiveAnswer(question.id, answer, correct) for question, answer, correct in graded]
|
||||
|
||||
|
||||
@router.get("/{attempt_id}/analysis")
|
||||
def attempt_analysis(
|
||||
attempt_id: int,
|
||||
|
|
@ -788,6 +832,12 @@ def attempt_analysis(
|
|||
|
||||
quiz = db.get(Quiz, attempt.quiz_id)
|
||||
rows = db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == attempt_id).all()
|
||||
# A session still in progress has no AttemptAnswer rows — they are written
|
||||
# on submit — so the analysis of one read as entirely empty while the
|
||||
# session list, which counts the saved progress, said a question had been
|
||||
# answered. The two are now looking at the same thing.
|
||||
if not rows and attempt.completed_at is None:
|
||||
rows = _rows_from_progress(db, current_user.id, attempt)
|
||||
question_ids = [row.question_id for row in rows]
|
||||
questions = {q.id: q for q in db.query(Question).filter(Question.id.in_(question_ids)).all()} \
|
||||
if question_ids else {}
|
||||
|
|
|
|||
155
backend/app/routers/feedback.py
Normal file
155
backend/app/routers/feedback.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Feedback on a question: reported by a learner, worked through by an educator.
|
||||
|
||||
The comment thread that used to sit under every question was a discussion
|
||||
nobody moderated. What it was actually used for was telling an educator
|
||||
something was wrong — a private report someone is meant to act on — so that is
|
||||
what this is.
|
||||
|
||||
An educator sees what is outstanding, which question each report is about, and
|
||||
can reply, resolve or delete it. Resolving keeps the report: a question with a
|
||||
history of the same complaint should visibly have one.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.feedback import QuestionFeedback
|
||||
from app.models.question import Question
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user
|
||||
from app.utils.category_grants import is_question_manager, manageable_categories
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_MESSAGE = 2000
|
||||
|
||||
|
||||
class FeedbackIn(BaseModel):
|
||||
message: str = Field(min_length=3, max_length=MAX_MESSAGE)
|
||||
|
||||
|
||||
class ReplyIn(BaseModel):
|
||||
reply: str | None = Field(default=None, max_length=MAX_MESSAGE)
|
||||
status: str | None = None
|
||||
|
||||
|
||||
def _json(row: QuestionFeedback, senders: dict[int, User], questions: dict[int, Question]) -> dict:
|
||||
sender = senders.get(row.user_id)
|
||||
question = questions.get(row.question_id)
|
||||
return {
|
||||
"id": row.id,
|
||||
"question_id": row.question_id,
|
||||
# The stem, trimmed: an educator scanning a list needs to recognise the
|
||||
# question, not read it.
|
||||
"question_excerpt": (getattr(question, "question_text", "") or "")[:120],
|
||||
"message": row.message,
|
||||
"status": row.status,
|
||||
"reply": row.reply,
|
||||
"replied_at": row.replied_at,
|
||||
"created_at": row.created_at,
|
||||
"from_name": getattr(sender, "name", None) or "A learner",
|
||||
"from_email": getattr(sender, "email", None),
|
||||
}
|
||||
|
||||
|
||||
def _decorate(db: Session, rows: list[QuestionFeedback]) -> list[dict]:
|
||||
if not rows:
|
||||
return []
|
||||
senders = {u.id: u for u in db.query(User).filter(
|
||||
User.id.in_({r.user_id for r in rows if r.user_id}))} if any(r.user_id for r in rows) else {}
|
||||
questions = {q.id: q for q in db.query(Question).filter(
|
||||
Question.id.in_({r.question_id for r in rows}))}
|
||||
return [_json(row, senders, questions) for row in rows]
|
||||
|
||||
|
||||
def _require_manager(db: Session, user: User) -> None:
|
||||
if not is_question_manager(db, user):
|
||||
raise HTTPException(403, "Reviewing feedback requires moderator access or a grant")
|
||||
|
||||
|
||||
def _visible(db: Session, user: User, query):
|
||||
"""Narrow to the questions this educator may manage. None means all."""
|
||||
scope = manageable_categories(db, user)
|
||||
if scope is None:
|
||||
return query
|
||||
if not scope:
|
||||
return query.filter(QuestionFeedback.id.is_(None))
|
||||
return query.join(Question, Question.id == QuestionFeedback.question_id).filter(
|
||||
Question.question_category_id.in_(scope))
|
||||
|
||||
|
||||
@router.post("/questions/{question_id}", status_code=201)
|
||||
def leave_feedback(question_id: int, data: FeedbackIn, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""Report something about a question. Any signed-in learner may."""
|
||||
if not db.query(Question.id).filter(Question.id == question_id).first():
|
||||
raise HTTPException(404, "Question not found")
|
||||
row = QuestionFeedback(question_id=question_id, user_id=current_user.id,
|
||||
message=data.message.strip())
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"sent": True, "id": row.id}
|
||||
|
||||
|
||||
@router.get("/questions/{question_id}")
|
||||
def feedback_for_question(question_id: int, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""Every report about one question, for the educator editing it."""
|
||||
_require_manager(db, current_user)
|
||||
rows = (db.query(QuestionFeedback)
|
||||
.filter(QuestionFeedback.question_id == question_id)
|
||||
.order_by(QuestionFeedback.status.desc(), QuestionFeedback.created_at.desc())
|
||||
.all())
|
||||
return _decorate(db, rows)
|
||||
|
||||
|
||||
@router.get("/open")
|
||||
def open_feedback(limit: int = Query(8, ge=1, le=50), db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""What is outstanding — the count for a badge, and the newest few.
|
||||
|
||||
Answers quietly for someone with no editorial access rather than refusing,
|
||||
so the header can ask for it without first asking who is asking.
|
||||
"""
|
||||
if not is_question_manager(db, current_user):
|
||||
return {"open": 0, "items": []}
|
||||
base = _visible(db, current_user,
|
||||
db.query(QuestionFeedback).filter(QuestionFeedback.status == "open"))
|
||||
total = base.with_entities(func.count(QuestionFeedback.id)).scalar() or 0
|
||||
rows = base.order_by(QuestionFeedback.created_at.desc()).limit(limit).all()
|
||||
return {"open": int(total), "items": _decorate(db, rows)}
|
||||
|
||||
|
||||
@router.patch("/{feedback_id}")
|
||||
def answer_feedback(feedback_id: int, data: ReplyIn, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""Reply to a report, resolve it, or reopen it."""
|
||||
_require_manager(db, current_user)
|
||||
row = db.get(QuestionFeedback, feedback_id)
|
||||
if not row:
|
||||
raise HTTPException(404, "Feedback not found")
|
||||
if data.status is not None:
|
||||
if data.status not in ("open", "resolved"):
|
||||
raise HTTPException(400, "status must be open or resolved")
|
||||
row.status = data.status
|
||||
if data.reply is not None:
|
||||
row.reply = data.reply.strip() or None
|
||||
row.replied_by = current_user.id
|
||||
row.replied_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return _decorate(db, [row])[0]
|
||||
|
||||
|
||||
@router.delete("/{feedback_id}", status_code=204)
|
||||
def delete_feedback(feedback_id: int, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
_require_manager(db, current_user)
|
||||
row = db.get(QuestionFeedback, feedback_id)
|
||||
if not row:
|
||||
raise HTTPException(404, "Feedback not found")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
114
backend/tests/test_feedback.py
Normal file
114
backend/tests/test_feedback.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Feedback on a question: reported by a learner, worked through by an educator.
|
||||
|
||||
Disposable SQLite. The rules worth pinning: any learner may report, only
|
||||
someone with editorial access may read or answer, resolving keeps the report,
|
||||
and the badge answers quietly for someone with no access rather than refusing.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.category_grant import CategoryGrant
|
||||
from app.models.feedback import QuestionFeedback
|
||||
from app.routers import feedback
|
||||
|
||||
|
||||
class FeedbackTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bank = fixtures.BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.client = self.bank.client
|
||||
self.db = self.bank.db
|
||||
self.client.app.include_router(feedback.router, prefix="/feedback")
|
||||
self.bank.user = self.bank.owner
|
||||
|
||||
def tearDown(self):
|
||||
self.bank.tearDown()
|
||||
|
||||
def send(self, question_id=1, message="The answer key looks wrong."):
|
||||
return self.client.post(f"/feedback/questions/{question_id}", json={"message": message})
|
||||
|
||||
def test_any_learner_may_report_and_it_names_the_question(self):
|
||||
self.bank.user = self.bank.peer
|
||||
response = self.send()
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
row = self.db.query(QuestionFeedback).one()
|
||||
self.assertEqual((row.question_id, row.user_id, row.status), (1, self.bank.peer.id, "open"))
|
||||
|
||||
def test_a_report_about_nothing_is_refused(self):
|
||||
self.assertEqual(self.send(question_id=9999).status_code, 404)
|
||||
self.assertEqual(self.client.post("/feedback/questions/1", json={"message": "no"}).status_code, 422)
|
||||
|
||||
def test_only_an_educator_may_read_or_answer(self):
|
||||
self.send()
|
||||
row = self.db.query(QuestionFeedback).one()
|
||||
self.bank.user = self.bank.peer
|
||||
self.assertEqual(self.client.get("/feedback/questions/1").status_code, 403)
|
||||
self.assertEqual(self.client.patch(f"/feedback/{row.id}", json={"status": "resolved"}).status_code, 403)
|
||||
self.assertEqual(self.client.delete(f"/feedback/{row.id}").status_code, 403)
|
||||
|
||||
def test_the_badge_answers_quietly_for_someone_with_no_access(self):
|
||||
self.send()
|
||||
self.bank.user = self.bank.peer
|
||||
body = self.client.get("/feedback/open").json()
|
||||
# Not a 403: the header asks for this without first asking who is asking.
|
||||
self.assertEqual(body, {"open": 0, "items": []})
|
||||
|
||||
def test_an_educator_sees_what_is_outstanding_with_the_question_it_is_about(self):
|
||||
self.send()
|
||||
self.bank.user = self.bank.mod
|
||||
body = self.client.get("/feedback/open").json()
|
||||
self.assertEqual(body["open"], 1)
|
||||
self.assertEqual(body["items"][0]["question_id"], 1)
|
||||
self.assertTrue(body["items"][0]["question_excerpt"])
|
||||
self.assertEqual(body["items"][0]["from_name"], self.bank.owner.name)
|
||||
|
||||
def test_replying_resolves_and_keeps_the_report(self):
|
||||
self.send()
|
||||
row_id = self.db.query(QuestionFeedback).one().id
|
||||
self.bank.user = self.bank.mod
|
||||
answered = self.client.patch(f"/feedback/{row_id}",
|
||||
json={"reply": "Fixed, thank you.", "status": "resolved"})
|
||||
self.assertEqual(answered.status_code, 200, answered.text)
|
||||
self.assertEqual(answered.json()["reply"], "Fixed, thank you.")
|
||||
# Kept, not deleted: a question with a history of the same complaint
|
||||
# should visibly have one.
|
||||
self.db.expire_all()
|
||||
self.assertEqual(self.db.query(QuestionFeedback).count(), 1)
|
||||
self.assertEqual(self.client.get("/feedback/open").json()["open"], 0)
|
||||
|
||||
def test_a_resolved_report_can_be_reopened(self):
|
||||
self.send()
|
||||
row_id = self.db.query(QuestionFeedback).one().id
|
||||
self.bank.user = self.bank.mod
|
||||
self.client.patch(f"/feedback/{row_id}", json={"status": "resolved"})
|
||||
self.client.patch(f"/feedback/{row_id}", json={"status": "open"})
|
||||
self.assertEqual(self.client.get("/feedback/open").json()["open"], 1)
|
||||
self.assertEqual(self.client.patch(f"/feedback/{row_id}", json={"status": "maybe"}).status_code, 400)
|
||||
|
||||
def test_deleting_removes_it(self):
|
||||
self.send()
|
||||
row_id = self.db.query(QuestionFeedback).one().id
|
||||
self.bank.user = self.bank.mod
|
||||
self.assertEqual(self.client.delete(f"/feedback/{row_id}").status_code, 204)
|
||||
self.assertEqual(self.db.query(QuestionFeedback).count(), 0)
|
||||
|
||||
def test_a_granted_educator_sees_only_their_own_branch(self):
|
||||
# Questions 1 and 2 sit under the Root branch; 6 is filed nowhere, so
|
||||
# no grant over the tree can reach it.
|
||||
self.send(question_id=1)
|
||||
self.send(question_id=6)
|
||||
self.db.add(CategoryGrant(category_id=1, user_id=self.bank.peer.id))
|
||||
self.db.commit()
|
||||
|
||||
self.bank.user = self.bank.peer
|
||||
body = self.client.get("/feedback/open").json()
|
||||
self.assertEqual(body["open"], 1)
|
||||
self.assertEqual(body["items"][0]["question_id"], 1)
|
||||
|
||||
# A moderator sees both, including the one filed nowhere.
|
||||
self.bank.user = self.bank.mod
|
||||
self.assertEqual(self.client.get("/feedback/open").json()["open"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -233,3 +233,48 @@ class QuizAnalysisTests(unittest.TestCase):
|
|||
quiz_id = self.bank.generate(is_shared=False, category_ids=[1], count=2).json()["id"]
|
||||
self.bank.user = self.bank.peer
|
||||
self.assertEqual(self.client.get(f"/attempts/quiz/{quiz_id}/analysis").status_code, 403)
|
||||
|
||||
|
||||
class LiveAnalysisTests(unittest.TestCase):
|
||||
"""A session can be analysed while it is still being sat."""
|
||||
|
||||
def setUp(self):
|
||||
self.bank = fixtures.BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.client = self.bank.client
|
||||
self.db = self.bank.db
|
||||
self.client.app.include_router(attempts.router, prefix="/attempts")
|
||||
self.bank.user = self.bank.owner
|
||||
self.store = {}
|
||||
self.redis = fake_redis(self.store)
|
||||
|
||||
def tearDown(self):
|
||||
self.bank.tearDown()
|
||||
|
||||
def test_answers_given_but_not_submitted_are_analysed(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}").json()["id"]
|
||||
# One answered, saved to progress; nothing submitted.
|
||||
self.store[f"quiz_progress:{self.bank.owner.id}:{aid}"] = json.dumps({"answers": {"1": "yes"}})
|
||||
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
body = self.client.get(f"/attempts/{aid}/analysis").json()
|
||||
|
||||
# It used to report 0/0 with an empty table while the session list,
|
||||
# which reads the same saved progress, said one had been answered.
|
||||
self.assertEqual(body["answered"], 1)
|
||||
self.assertGreater(body["total"], 1)
|
||||
self.assertEqual(body["score"], 1)
|
||||
statuses = {q["status"] for q in body["questions"]}
|
||||
self.assertIn("correct", statuses)
|
||||
self.assertIn("skipped", statuses)
|
||||
# Nothing was written: submitting is what records answers.
|
||||
self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).count(), 0)
|
||||
|
||||
def test_a_live_attempt_with_nothing_answered_reads_as_nothing_answered(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}").json()["id"]
|
||||
body = self.client.get(f"/attempts/{aid}/analysis").json()
|
||||
self.assertEqual((body["answered"], body["score"]), (0, 0))
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import api from '../api/client'
|
||||
|
||||
const LIMIT = 20
|
||||
|
||||
export default function CommentSection({ articleId, questionId }) {
|
||||
const [comments, setComments] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const params = articleId ? { article_id: articleId } : { question_id: questionId }
|
||||
|
||||
const load = useCallback(async (off = 0) => {
|
||||
try {
|
||||
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)
|
||||
setOffset(off)
|
||||
} catch { setComments([]); setTotal(0) }
|
||||
finally { setLoaded(true) }
|
||||
}, [articleId, questionId])
|
||||
|
||||
useEffect(() => { setComments([]); setLoaded(false); load(0) }, [load])
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
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('')
|
||||
} catch (err) {
|
||||
setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not post comment')
|
||||
} finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const moderate = async (id, status) => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.patch(`/comments/${id}`, { status })
|
||||
setComments(prev => prev.map(c => c.id === id ? res.data : c))
|
||||
} catch { setError('Could not moderate comment') }
|
||||
}
|
||||
|
||||
if (!loaded) return null
|
||||
|
||||
return (
|
||||
<section className="comment-section" aria-label="Comments" data-testid="comment-section">
|
||||
<div className="comment-heading">
|
||||
<h3>Discussion{total > 0 ? ` · ${total}` : ''}</h3>
|
||||
<span className="comment-subtitle">Visible to everyone after educator approval.</span>
|
||||
</div>
|
||||
<div className="comment-compose">
|
||||
<textarea className="input comment-input" rows={2} maxLength={2000} value={draft} onChange={e => setDraft(e.target.value)}
|
||||
placeholder="Ask a question or add a note…" aria-label="Comment text" />
|
||||
<div className="comment-compose-footer">
|
||||
<span className="comment-count">{draft.length}/2000</span>
|
||||
{error && <span className="form-error" role="alert">{error}</span>}
|
||||
<button className="btn btn-primary btn-sm" disabled={submitting || !draft.trim()} onClick={submit}>
|
||||
{submitting ? 'Posting…' : 'Post comment'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{comments.length === 0 ? (
|
||||
<p className="comment-empty">No comments yet — start the discussion.</p>
|
||||
) : (
|
||||
<ul className="comment-list">
|
||||
{comments.map(comment => (
|
||||
<li key={comment.id} className="comment">
|
||||
<div className="comment-avatar" aria-hidden="true">{comment.author_name?.charAt(0) || '?'}</div>
|
||||
<div className="comment-body">
|
||||
<div className="comment-meta">
|
||||
<strong>{comment.author_name}</strong>
|
||||
<span>{new Date(comment.created_at).toLocaleDateString()}</span>
|
||||
{comment.status === 'pending' && <em className="comment-badge">awaiting approval</em>}
|
||||
</div>
|
||||
<div className="comment-content"><ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.content}</ReactMarkdown></div>
|
||||
{comment.can_moderate && (
|
||||
<div className="comment-actions">
|
||||
{comment.status !== 'approved' && <button className="btn btn-primary btn-sm" onClick={() => moderate(comment.id, 'approved')}>Approve</button>}
|
||||
{comment.status !== 'rejected' && <button className="btn btn-secondary btn-sm" onClick={() => moderate(comment.id, 'rejected')}>Reject</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{comments.length < total && (
|
||||
<button className="btn btn-secondary btn-sm comment-load-more" onClick={() => load(offset + LIMIT)}>Load more</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CommentSection from './CommentSection'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
const comments = [
|
||||
{ id: 1, author_name: 'Educator', content: 'Approved **note**', status: 'approved', created_at: '2026-09-07T10:00:00', own: false, can_moderate: false },
|
||||
{ id: 2, author_name: 'Me', content: 'My pending note', status: 'pending', created_at: '2026-09-07T11:00:00', own: true, can_moderate: false },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
api.get.mockResolvedValue({ data: { total: comments.length, comments } })
|
||||
api.post.mockResolvedValue({ data: { id: 3, author_name: 'Me', content: 'New note', status: 'pending', created_at: '2026-09-07T12:00:00', own: true, can_moderate: false } })
|
||||
api.patch.mockResolvedValue({ data: { ...comments[1], status: 'approved', can_moderate: true } })
|
||||
})
|
||||
|
||||
describe('comment section', () => {
|
||||
it('lists comments with markdown and own pending state', async () => {
|
||||
render(<CommentSection articleId={1} />)
|
||||
expect(await screen.findByText('Educator')).toBeInTheDocument()
|
||||
expect(screen.getByText('note')).toBeInTheDocument() // **note** rendered as strong text
|
||||
expect(screen.getByText(/awaiting approval/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('posts a comment and prepends it', async () => {
|
||||
render(<CommentSection questionId={5} />)
|
||||
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' }))
|
||||
expect(await screen.findByText('New note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders comment markdown without executing raw HTML', async () => {
|
||||
api.get.mockResolvedValue({ data: { total: 1, comments: [{ id: 4, author_name: 'A', content: '<img src=x onerror=alert(1)>Text', status: 'approved', created_at: '2026-09-07T10:00:00', own: false, can_moderate: false }] } })
|
||||
const { container } = render(<CommentSection articleId={1} />)
|
||||
expect(await screen.findByText(/Text/)).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets a moderator approve and reject', async () => {
|
||||
api.get.mockResolvedValue({ data: { total: 1, comments: [{ ...comments[1], can_moderate: true }] } })
|
||||
render(<CommentSection articleId={1} />)
|
||||
expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/comments/2', { status: 'approved' }))
|
||||
})
|
||||
})
|
||||
38
frontend/src/components/Feedback.css
Normal file
38
frontend/src/components/Feedback.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* Reporting a question, and working through what was reported. */
|
||||
|
||||
.fb-form { display: flex; flex-direction: column; gap: 8px; min-width: 240px; }
|
||||
.fb-form label { font-size: 0.8rem; font-weight: 650; }
|
||||
.fb-form textarea {
|
||||
width: 100%; padding: 9px 11px; font-family: inherit;
|
||||
/* 16px on touch so iOS does not zoom the page in on focus. */
|
||||
font-size: 16px; resize: vertical;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 700px) { .fb-form textarea { font-size: 0.86rem; } }
|
||||
.fb-form-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.fb-qid { font-size: 0.74rem; color: var(--text-subtle); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.fb-sent { margin: 0; font-size: 0.84rem; color: var(--correct-fg); }
|
||||
.fb-error { margin: 6px 0 0; font-size: 0.82rem; color: var(--wrong-fg); }
|
||||
.fb-none { margin: 0; font-size: 0.84rem; color: var(--text-muted); }
|
||||
|
||||
.fb-count { margin: 0 0 10px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--wrong-fg); }
|
||||
.fb-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.fb-item { padding: 12px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.fb-item.is-open { border-left: 3px solid var(--wrong-fg); }
|
||||
.fb-item.is-resolved { opacity: 0.78; }
|
||||
.fb-item-head { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; margin-bottom: 6px; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.fb-item-head strong { font-size: 0.84rem; color: var(--text); }
|
||||
.fb-tag { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; padding: 1px 7px; border-radius: 10px; }
|
||||
.fb-tag.is-open { background: var(--wrong-bg); color: var(--wrong-fg); }
|
||||
.fb-tag.is-resolved { background: var(--option-sel-bg); color: var(--primary); }
|
||||
.fb-message { margin: 0 0 10px; font-size: 0.87rem; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.fb-reply { margin: 0 0 10px; padding: 9px 11px; font-size: 0.84rem; line-height: 1.6; background: var(--card-bg); border-radius: 8px; }
|
||||
.fb-reply-form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.fb-reply-form textarea {
|
||||
width: 100%; padding: 9px 11px; font-family: inherit; font-size: 16px; resize: vertical;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 700px) { .fb-reply-form textarea { font-size: 0.86rem; } }
|
||||
.fb-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.fb-delete { color: var(--wrong-fg); border-color: var(--wrong-bd); }
|
||||
59
frontend/src/components/FeedbackForm.jsx
Normal file
59
frontend/src/components/FeedbackForm.jsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState } from 'react'
|
||||
import api from '../api/client'
|
||||
|
||||
/**
|
||||
* Telling an educator something is wrong with a question.
|
||||
*
|
||||
* It replaces the comment thread that used to sit under every question. A
|
||||
* discussion is public and needs moderating; this is a private report someone
|
||||
* is expected to act on, which is what the comments were being used for.
|
||||
*
|
||||
* The question's id is shown, because that is what an educator will search for
|
||||
* and what the reply will come back about.
|
||||
*/
|
||||
export default function FeedbackForm({ questionId, onDone }) {
|
||||
const [message, setMessage] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const send = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.post(`/feedback/questions/${questionId}`, { message: message.trim() })
|
||||
setSent(true)
|
||||
setMessage('')
|
||||
onDone?.()
|
||||
} catch (err) {
|
||||
const detail = err?.response?.data?.detail
|
||||
setError(typeof detail === 'string' ? detail : 'Could not send that')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<p className="fb-sent" role="status">
|
||||
Sent. An educator will see it against question #{questionId}.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fb-form">
|
||||
<label htmlFor={`fb-${questionId}`}>
|
||||
What is wrong with this question?
|
||||
</label>
|
||||
<textarea id={`fb-${questionId}`} value={message} rows={3} maxLength={2000}
|
||||
placeholder="A wrong answer key, a typo, an outdated guideline…"
|
||||
onChange={e => setMessage(e.target.value)} />
|
||||
<div className="fb-form-foot">
|
||||
<span className="fb-qid">Question #{questionId}</span>
|
||||
<button type="button" className="btn btn-primary btn-sm"
|
||||
disabled={busy || message.trim().length < 3} onClick={send}>
|
||||
{busy ? 'Sending…' : 'Send feedback'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="fb-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
frontend/src/components/FeedbackPanel.jsx
Normal file
122
frontend/src/components/FeedbackPanel.jsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import './Feedback.css'
|
||||
|
||||
const when = (value) => (value ? new Date(value).toLocaleDateString(undefined,
|
||||
{ day: '2-digit', month: 'short', year: 'numeric' }) : '')
|
||||
|
||||
/**
|
||||
* What learners have reported about this question, for the educator editing it.
|
||||
*
|
||||
* It sits on the edit page rather than in a queue of its own, because the
|
||||
* answer to almost every report is a change to the question — and having the
|
||||
* report beside the field it is about is the whole point.
|
||||
*
|
||||
* Resolving keeps the report. A question with a history of the same complaint
|
||||
* should visibly have one; deleting is for the ones that were never about the
|
||||
* question.
|
||||
*/
|
||||
export default function FeedbackPanel({ questionId }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!questionId) { setLoading(false); return }
|
||||
api.get(`/feedback/questions/${questionId}`)
|
||||
.then(res => setItems(res.data || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [questionId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const run = async (fn, failure) => {
|
||||
setBusy(true); setError('')
|
||||
try { await fn(); load() }
|
||||
catch { setError(failure) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setStatus = (row, status) => run(
|
||||
() => api.patch(`/feedback/${row.id}`, { status }), 'Could not change that')
|
||||
|
||||
const sendReply = (row) => run(
|
||||
() => api.patch(`/feedback/${row.id}`, { reply: draft.trim(), status: 'resolved' })
|
||||
.then(res => { setReplyTo(null); setDraft(''); return res }),
|
||||
'Could not send that reply')
|
||||
|
||||
const remove = (row) => run(
|
||||
() => api.delete(`/feedback/${row.id}`), 'Could not delete that')
|
||||
|
||||
if (!questionId) return null
|
||||
if (loading) return null
|
||||
if (items.length === 0) {
|
||||
return <p className="fb-none">No feedback on this question.</p>
|
||||
}
|
||||
|
||||
const open = items.filter(row => row.status === 'open').length
|
||||
|
||||
return (
|
||||
<div className="fb-panel">
|
||||
{error && <p className="fb-error" role="alert">{error}</p>}
|
||||
{open > 0 && <p className="fb-count">{open} open</p>}
|
||||
|
||||
<ul className="fb-list">
|
||||
{items.map(row => (
|
||||
<li key={row.id} className={`fb-item is-${row.status}`}>
|
||||
<div className="fb-item-head">
|
||||
<strong>{row.from_name}</strong>
|
||||
<span>{when(row.created_at)}</span>
|
||||
<span className={`fb-tag is-${row.status}`}>{row.status}</span>
|
||||
</div>
|
||||
<p className="fb-message">{row.message}</p>
|
||||
|
||||
{row.reply && (
|
||||
<p className="fb-reply">
|
||||
<strong>Replied:</strong> {row.reply}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{replyTo === row.id ? (
|
||||
<div className="fb-reply-form">
|
||||
<textarea value={draft} rows={3} autoFocus maxLength={2000}
|
||||
aria-label={`Reply to ${row.from_name}`}
|
||||
placeholder="What you changed, or why it stays as it is…"
|
||||
onChange={e => setDraft(e.target.value)} />
|
||||
<div className="fb-actions">
|
||||
<button type="button" className="btn btn-primary btn-sm"
|
||||
disabled={busy || !draft.trim()} onClick={() => sendReply(row)}>
|
||||
Reply and resolve
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => { setReplyTo(null); setDraft('') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fb-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={busy}
|
||||
onClick={() => { setReplyTo(row.id); setDraft(row.reply || '') }}>
|
||||
Reply
|
||||
</button>
|
||||
{row.status === 'open' ? (
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={busy}
|
||||
onClick={() => setStatus(row, 'resolved')}>Resolve</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={busy}
|
||||
onClick={() => setStatus(row, 'open')}>Reopen</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary btn-sm fb-delete" disabled={busy}
|
||||
aria-label={`Delete feedback from ${row.from_name}`}
|
||||
onClick={() => remove(row)}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -55,3 +55,8 @@
|
|||
.fm-actions { width: 100%; }
|
||||
.fm-actions .btn { flex: 1; }
|
||||
}
|
||||
|
||||
/* The add button and whatever follows it are separate actions, not a stack of
|
||||
touching pills — they were adjacent inline elements with no margin between. */
|
||||
.fm-role > .btn { display: inline-flex; margin-top: 2px; }
|
||||
.fm + .btn { display: inline-flex; margin-top: 14px; }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,70 @@ import ExamSwitcher from './ExamSwitcher'
|
|||
import GlobalSearch from './GlobalSearch'
|
||||
import useHidingBar from '../hooks/useHidingBar'
|
||||
|
||||
/**
|
||||
* What learners have reported and nobody has dealt with yet.
|
||||
*
|
||||
* Each row names the question it is about, because that is what an educator
|
||||
* searches for, and opens straight into that question's editor — where the
|
||||
* report sits beside the field it is about.
|
||||
*
|
||||
* The endpoint answers quietly with zero for someone with no editorial access,
|
||||
* so this can ask without first working out who is asking.
|
||||
*/
|
||||
function FeedbackBadge() {
|
||||
const [data, setData] = useState({ open: 0, items: [] })
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrap = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
const load = () => api.get('/feedback/open')
|
||||
.then(res => { if (live) setData(res.data || { open: 0, items: [] }) })
|
||||
.catch(() => {})
|
||||
load()
|
||||
const timer = setInterval(load, 60000)
|
||||
return () => { live = false; clearInterval(timer) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', away)
|
||||
return () => document.removeEventListener('mousedown', away)
|
||||
}, [open])
|
||||
|
||||
if (!data.open) return null
|
||||
|
||||
return (
|
||||
<div className="fbadge" ref={wrap}>
|
||||
<button type="button" className="fbadge-button" aria-haspopup="menu" aria-expanded={open}
|
||||
aria-label={`${data.open} question${data.open === 1 ? '' : 's'} with open feedback`}
|
||||
onClick={() => setOpen(v => !v)}>
|
||||
⚑ {data.open}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="fbadge-menu" role="menu">
|
||||
<div className="fbadge-head">Open feedback</div>
|
||||
<ul>
|
||||
{data.items.map(item => (
|
||||
<li key={item.id}>
|
||||
<Link role="menuitem" to={`/questions/${item.question_id}`}
|
||||
state={{ from: '/questions/manage', label: 'Questions' }}
|
||||
onClick={() => setOpen(false)}>
|
||||
<span className="fbadge-qid">#{item.question_id}</span>
|
||||
<span className="fbadge-excerpt">{item.question_excerpt}…</span>
|
||||
<span className="fbadge-message">{item.message}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function JobsBadge({ jobs }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const allJobs = jobs
|
||||
|
|
@ -163,8 +227,8 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
// One entry. Sessions and analysis are the same subject — the list of what
|
||||
// you have sat and the reading of how it went — so they are one page.
|
||||
{ to: '/sessions', label: 'Sessions' },
|
||||
{ to: '/question-bank', label: 'Question Bank' },
|
||||
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
|
||||
{ to: '/question-bank', label: 'Qbank' },
|
||||
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Questions' },
|
||||
{ to: '/media', label: 'Images' },
|
||||
{ to: '/editorial', label: 'Editorial' }] : []),
|
||||
{ to: '/study-plans', label: 'Study plans' },
|
||||
|
|
@ -184,6 +248,7 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
|
||||
{user ? (
|
||||
<div className="navbar-account">
|
||||
<FeedbackBadge />
|
||||
<JobsBadge jobs={jobs} />
|
||||
<AccountMenu user={user} onLogout={logout} />
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe('two-bar header', () => {
|
|||
currentUser = { id: 2, name: 'Mod', role: 'moderator' }
|
||||
mount()
|
||||
expect(await screen.findByRole('link', { name: 'Images' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('link', { name: 'Manage Qs' })[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('link', { name: 'Questions' })[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps what is about you out of the section bar', async () => {
|
||||
|
|
|
|||
33
frontend/src/components/QuestionImport.css
Normal file
33
frontend/src/components/QuestionImport.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/* Bringing questions in from a spreadsheet or a QTI export. */
|
||||
|
||||
.qi-overlay {
|
||||
position: fixed; inset: 0; z-index: 1100; display: flex;
|
||||
align-items: center; justify-content: center; padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
.qi {
|
||||
display: flex; flex-direction: column;
|
||||
width: min(560px, 100%); max-height: 86vh;
|
||||
background: var(--card-bg); border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
.qi-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px 0; }
|
||||
.qi-head h2 { margin: 0; font-size: 1.1rem; font-weight: 700; }
|
||||
.qi-head button { width: 32px; height: 32px; border: 0; border-radius: 50%; background: none; cursor: pointer; color: var(--text-muted); }
|
||||
.qi-head button:hover { background: var(--bg); color: var(--text); }
|
||||
|
||||
.qi-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 22px; }
|
||||
.qi-body section + section { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.qi-body h3 { margin: 0 0 4px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); }
|
||||
.qi-body p { margin: 0 0 10px; font-size: 0.85rem; color: var(--text-muted); }
|
||||
.qi-body input[type="file"] { display: block; margin-bottom: 10px; font-size: 0.85rem; max-width: 100%; }
|
||||
|
||||
.qi-result { margin-top: 10px; padding: 9px 12px; font-size: 0.85rem; background: var(--bg); border-radius: 8px; }
|
||||
/* Every failed row, not a count: an import that quietly drops a third of a
|
||||
file is worse than one that refuses. */
|
||||
.qi-errors { margin: 8px 0 0; padding-left: 18px; font-size: 0.8rem; color: var(--wrong-fg); }
|
||||
.qi-error { margin-top: 10px; font-size: 0.85rem; color: var(--wrong-fg); }
|
||||
.qi-foot { display: flex; justify-content: flex-end; padding: 14px 22px calc(18px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); }
|
||||
|
||||
.qi-sample { font-size: 0.82rem; }
|
||||
.qi-sample a { color: var(--primary); }
|
||||
152
frontend/src/components/QuestionImport.jsx
Normal file
152
frontend/src/components/QuestionImport.jsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import './QuestionImport.css'
|
||||
|
||||
const apiError = (err, fallback) => {
|
||||
const detail = err?.response?.data?.detail
|
||||
return typeof detail === 'string' ? detail : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringing questions in from a spreadsheet or a QTI export.
|
||||
*
|
||||
* It lived on the question bank, which is now a page about starting a session
|
||||
* rather than about the question bank's contents. Importing is management, so
|
||||
* it belongs with the rest of it.
|
||||
*
|
||||
* The result is reported in full — how many of how many, and every row that
|
||||
* failed — because an import that silently drops a third of a file is worse
|
||||
* than one that refuses.
|
||||
*/
|
||||
export default function QuestionImport({ onImported, selectedIds }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [file, setFile] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [qtiBusy, setQtiBusy] = useState(false)
|
||||
const [qtiResult, setQtiResult] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const qtiInput = useRef(null)
|
||||
|
||||
const importSheet = async () => {
|
||||
if (!file) return
|
||||
setBusy(true); setResult(null)
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const res = await api.post('/questions/import/upload', body)
|
||||
setResult(res.data)
|
||||
if (res.data.imported > 0) onImported?.()
|
||||
} catch (err) {
|
||||
setResult({ error: apiError(err, 'Import failed') })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const importQti = async (event) => {
|
||||
const chosen = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!chosen) return
|
||||
setQtiBusy(true); setQtiResult('')
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('file', chosen)
|
||||
const res = await api.post('/questions/import/qti', body)
|
||||
const failed = res.data.errors?.length ? ` ${res.data.errors.length} row(s) failed.` : ''
|
||||
setQtiResult(`Imported ${res.data.imported} of ${res.data.total_items} questions.${failed}`)
|
||||
if (res.data.imported > 0) onImported?.()
|
||||
} catch (err) {
|
||||
setQtiResult(apiError(err, 'QTI import failed'))
|
||||
} finally { setQtiBusy(false) }
|
||||
}
|
||||
|
||||
const chosen = selectedIds ? [...selectedIds] : []
|
||||
|
||||
const exportQti = async () => {
|
||||
setExporting(true); setQtiResult('')
|
||||
try {
|
||||
const query = chosen.length ? `?question_ids=${chosen.join(',')}` : ''
|
||||
const res = await api.get(`/questions/export/qti${query}`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'questions_qti.xml'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
setQtiResult(apiError(err, 'QTI export failed'))
|
||||
} finally { setExporting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setOpen(true)}>Import / export</button>
|
||||
|
||||
{open && (
|
||||
<div className="qi-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
|
||||
<div className="qi" role="dialog" aria-modal="true" aria-labelledby="qi-heading">
|
||||
<div className="qi-head">
|
||||
<h2 id="qi-heading">Import and export</h2>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="qi-body">
|
||||
<section>
|
||||
<h3>Spreadsheet</h3>
|
||||
<p>A CSV or Excel file, one question per row.</p>
|
||||
<p className="qi-sample">
|
||||
<a href="/api/questions/import/sample" download>Download a sample file</a> to see the columns.
|
||||
</p>
|
||||
<input type="file" accept=".csv,.xlsx,.xls" aria-label="Spreadsheet to import"
|
||||
onChange={e => { setFile(e.target.files?.[0] || null); setResult(null) }} />
|
||||
<button type="button" className="btn btn-primary btn-sm"
|
||||
disabled={!file || busy} onClick={importSheet}>
|
||||
{busy ? 'Importing…' : 'Import spreadsheet'}
|
||||
</button>
|
||||
|
||||
{result && !result.error && (
|
||||
<div className="qi-result" role="status">
|
||||
Imported <strong>{result.imported}</strong> of {result.total_rows} rows.
|
||||
{result.errors?.length > 0 && (
|
||||
<ul className="qi-errors">
|
||||
{result.errors.map((message, index) => <li key={index}>{message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{result?.error && <p className="qi-error" role="alert">{result.error}</p>}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>QTI</h3>
|
||||
<p>An XML export from another question bank.</p>
|
||||
<input ref={qtiInput} type="file" accept=".xml" hidden onChange={importQti} />
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={qtiBusy}
|
||||
onClick={() => qtiInput.current?.click()}>
|
||||
{qtiBusy ? 'Importing…' : 'Choose a QTI file'}
|
||||
</button>
|
||||
{qtiResult && <p className="qi-result" role="status">{qtiResult}</p>}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Export</h3>
|
||||
<p>
|
||||
{chosen.length
|
||||
? `The ${chosen.length} question${chosen.length === 1 ? '' : 's'} you have selected, as QTI XML.`
|
||||
: 'Every question you can see, as QTI XML. Select some first to export only those.'}
|
||||
</p>
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={exporting}
|
||||
onClick={exportQti}>
|
||||
{exporting ? 'Exporting…' : `Export QTI${chosen.length ? ` (${chosen.length})` : ''}`}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="qi-foot">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setOpen(false)}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/QuestionImport.test.jsx
Normal file
63
frontend/src/components/QuestionImport.test.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import QuestionImport from './QuestionImport'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
const open = async (props = {}) => {
|
||||
render(<QuestionImport {...props} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Import / export' }))
|
||||
}
|
||||
|
||||
describe('importing and exporting questions', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('reports every failed row, not just a count', async () => {
|
||||
await open()
|
||||
api.post.mockResolvedValue({ data: { imported: 8, total_rows: 10, errors: ['Row 3: no answer', 'Row 7: no stem'] } })
|
||||
const file = new File(['a,b'], 'q.csv', { type: 'text/csv' })
|
||||
await userEvent.upload(screen.getByLabelText('Spreadsheet to import'), file)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Import spreadsheet' }))
|
||||
|
||||
expect(await screen.findByText(/Imported/)).toHaveTextContent('Imported 8 of 10 rows.')
|
||||
// An import that quietly drops two rows is worse than one that refuses.
|
||||
expect(screen.getByText('Row 3: no answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Row 7: no stem')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces a refusal rather than doing nothing', async () => {
|
||||
await open()
|
||||
api.post.mockRejectedValue({ response: { data: { detail: 'Unsupported file' } } })
|
||||
await userEvent.upload(screen.getByLabelText('Spreadsheet to import'),
|
||||
new File(['x'], 'q.csv', { type: 'text/csv' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Import spreadsheet' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Unsupported file')
|
||||
})
|
||||
|
||||
it('exports everything when nothing is selected', async () => {
|
||||
await open()
|
||||
api.get.mockResolvedValue({ data: new Blob(['<xml/>']) })
|
||||
global.URL.createObjectURL = vi.fn(() => 'blob:x')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Export QTI' }))
|
||||
expect(api.get).toHaveBeenCalledWith('/questions/export/qti', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('exports only the selection when there is one', async () => {
|
||||
await open({ selectedIds: new Set([4, 9]) })
|
||||
api.get.mockResolvedValue({ data: new Blob(['<xml/>']) })
|
||||
global.URL.createObjectURL = vi.fn(() => 'blob:x')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
expect(screen.getByText(/The 2 questions you have selected/)).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Export QTI (2)' }))
|
||||
expect(api.get).toHaveBeenCalledWith('/questions/export/qti?question_ids=4,9', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('keeps the sample file within reach, since the columns are not guessable', async () => {
|
||||
await open()
|
||||
expect(screen.getByRole('link', { name: 'Download a sample file' }))
|
||||
.toHaveAttribute('href', '/api/questions/import/sample')
|
||||
})
|
||||
})
|
||||
135
frontend/src/components/QuestionPreview.jsx
Normal file
135
frontend/src/components/QuestionPreview.jsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { Suspense, lazy } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import RichText from './RichText'
|
||||
import QuestionReadingLinks from './QuestionReadingLinks'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
|
||||
const TeachChat = lazy(() => import('./TeachChat'))
|
||||
|
||||
/**
|
||||
* One question, shown whole, without leaving the page you found it on.
|
||||
*
|
||||
* It is a preview, not practice: the correct option, the per-option reasoning,
|
||||
* the explanation and the key points are all shown at once. Making someone
|
||||
* answer first is the right shape for a quiz and the wrong one for a list of
|
||||
* questions being inspected.
|
||||
*
|
||||
* Favourites and personal folders are optional and off by default — those
|
||||
* belong to studying a question, not to looking through the bank.
|
||||
*/
|
||||
export default function QuestionPreview({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) {
|
||||
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: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>
|
||||
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onToggleFavorite && (
|
||||
<button onClick={() => onToggleFavorite(question.id)}
|
||||
title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.4rem', padding: 2, lineHeight: 1 }}>
|
||||
{isFavorited ? '⭐' : '☆'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Markdown, not injected HTML: a stem is educator prose, and the
|
||||
same renderer the quiz player uses keeps a lab table a table. */}
|
||||
<div style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: 16 }}>
|
||||
<RichText value={question.question_text} />
|
||||
</div>
|
||||
{question.image_path && (
|
||||
<div style={{ margin: '0 0 14px' }}>
|
||||
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
|
||||
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
</div>
|
||||
)}
|
||||
{question.options && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{question.options.map((opt, i) => {
|
||||
const isCorrectOpt = opt === question.correct_answer
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isCorrectOpt ? 'correct' : ''}`}>
|
||||
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
|
||||
<span style={{ flex: 1 }}>{opt}</span>
|
||||
{isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||
{question.option_explanations?.[opt] && (
|
||||
<span style={{ flexBasis: '100%', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
<RichText value={question.option_explanations[opt]} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(question.explanation || question.explanation_image_path) && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation:</strong>
|
||||
{question.explanation && <div style={{ marginTop: 8 }}><RichText value={question.explanation} /></div>}
|
||||
{question.explanation_image_path && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<img src={`/uploads/${question.explanation_image_path}`} alt="Explanation illustration"
|
||||
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||
onError={e => e.currentTarget.style.display = 'none'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(question.key_points || []).length > 0 && (
|
||||
<div className="explanation" style={{ marginTop: 12 }}>
|
||||
<strong>Key points</strong>
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18, fontSize: '0.85rem' }}>
|
||||
{question.key_points.map((point, i) => (
|
||||
<li key={i}>
|
||||
{point.text}
|
||||
{point.article_id && (
|
||||
<Link to={`/articles/${point.article_id}${point.article_section_id ? `?section=${point.article_section_id}` : ''}`}
|
||||
style={{ marginLeft: 8, color: 'var(--primary)', textDecoration: 'none', fontWeight: 600, fontSize: '0.8rem' }}>📖 Read more</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* AI Tutor — z-index above the modal */}
|
||||
<Suspense fallback={null}>
|
||||
<TeachChat question={question} elevated />
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
/* A footer you can navigate from. */
|
||||
|
||||
.site-footer {
|
||||
/* Enough to separate it from the page, not enough to look like the page
|
||||
ended early. */
|
||||
margin-top: 32px;
|
||||
padding: 30px 0 calc(28px + env(safe-area-inset-bottom));
|
||||
/* No top margin: a margin here is a band of page background between the
|
||||
content and the footer, which reads as the page having ended early and a
|
||||
stray strip left behind. The rule and the padding do the separating. */
|
||||
margin-top: 0;
|
||||
padding: 36px 0 calc(28px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
text-align: left;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const COLUMNS = [
|
|||
heading: 'Study',
|
||||
links: [
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/question-bank', label: 'Question bank' },
|
||||
{ to: '/question-bank', label: 'Qbank' },
|
||||
{ to: '/study-plans', label: 'Study plans' },
|
||||
{ to: '/sessions', label: 'Sessions' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -871,3 +871,30 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|||
}
|
||||
.navbar .acct-menu a:hover, .acct-menu button:hover { background: var(--bg) !important; }
|
||||
.acct-menu .acct-signout { color: var(--wrong-fg) !important; border-top: 1px solid var(--border) !important; border-radius: 0 0 7px 7px; margin-top: 4px; }
|
||||
|
||||
/* ── Open feedback ──────────────────────────────────────────────
|
||||
What learners have reported and nobody has dealt with yet. Each row names
|
||||
the question, because that is what an educator searches for. */
|
||||
.fbadge { position: relative; }
|
||||
.fbadge-button {
|
||||
background: var(--wrong-fg) !important; color: #fff !important;
|
||||
border: 0 !important; border-radius: 20px; padding: 3px 11px;
|
||||
font-size: 0.75rem; font-weight: 700; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.fbadge-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
|
||||
width: min(360px, 84vw); max-height: 60vh; overflow-y: auto; padding: 6px;
|
||||
background: var(--card-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.fbadge-head { padding: 8px 10px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
|
||||
.fbadge-menu ul { list-style: none; margin: 0; padding: 0; }
|
||||
.navbar .fbadge-menu a {
|
||||
display: flex; flex-direction: column; gap: 3px; padding: 9px 10px;
|
||||
color: var(--text) !important; text-decoration: none; border-radius: 7px;
|
||||
}
|
||||
.navbar .fbadge-menu a:hover { background: var(--bg) !important; }
|
||||
.fbadge-qid { font-size: 0.72rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--primary); }
|
||||
.fbadge-excerpt { font-size: 0.8rem; color: var(--text-muted); }
|
||||
.fbadge-message { font-size: 0.84rem; overflow-wrap: anywhere; }
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ export default function CategoriesPage() {
|
|||
<p>Every axis a question can be filed under. Anything added here shows up in the question bank and quiz builder straight away.</p>
|
||||
</div>
|
||||
<div className="cat-header-actions">
|
||||
<Link className="btn btn-secondary" to="/question-bank">Question bank</Link>
|
||||
<Link className="btn btn-secondary" to="/questions/manage">Questions</Link>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(v => !v)}>
|
||||
+ New {facet.singular}
|
||||
</button>
|
||||
|
|
|
|||
36
frontend/src/pages/QbankPage.css
Normal file
36
frontend/src/pages/QbankPage.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/* The Qbank landing: start a session, and see the last few. */
|
||||
|
||||
.qb-page { max-width: 880px; margin: 0 auto; padding-bottom: 32px; }
|
||||
.qb-page h1 { margin: 0 0 18px; font-size: 1.6rem; font-weight: 700; }
|
||||
|
||||
.qb-start {
|
||||
padding: 24px 26px; margin-bottom: 30px;
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
|
||||
}
|
||||
.qb-start h2 { margin: 0 0 6px; font-size: 1.12rem; font-weight: 650; }
|
||||
.qb-start p { margin: 0 0 18px; max-width: 60ch; font-size: 0.9rem; line-height: 1.65; color: var(--text-muted); }
|
||||
|
||||
.qb-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.qb-history-head h2 { margin: 0; font-size: 1.12rem; font-weight: 650; }
|
||||
.qb-history-head a { font-size: 0.86rem; font-weight: 600; color: var(--primary); text-decoration: none; }
|
||||
.qb-history-head a:hover { text-decoration: underline; }
|
||||
|
||||
.qb-list { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.qb-list-head { padding: 13px 18px; border-bottom: 1px solid var(--border); font-size: 0.88rem; font-weight: 650; }
|
||||
.qb-list ul { list-style: none; margin: 0; padding: 0; }
|
||||
.qb-list li + li { border-top: 1px solid var(--border); }
|
||||
|
||||
.qb-row { display: flex; align-items: center; gap: 16px; padding: 15px 18px; flex-wrap: wrap; }
|
||||
.qb-row-main { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.qb-row-title { font-size: 0.9rem; overflow-wrap: anywhere; }
|
||||
.qb-row-title strong { font-weight: 650; }
|
||||
.qb-row-title a { color: var(--text); text-decoration: none; }
|
||||
.qb-row-title a:hover { color: var(--primary); }
|
||||
.qb-row-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.qb-empty { padding: 26px 18px; text-align: center; font-size: 0.88rem; color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.qb-row-actions { width: 100%; }
|
||||
.qb-row-actions .btn { flex: 1; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,161 +1,75 @@
|
|||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import QuestionBankPage from './QuestionBankPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
vi.mock('../context/AuthContext', () => ({
|
||||
useAuth: () => ({ user: { role: 'admin' } }),
|
||||
}))
|
||||
const SESSIONS = [
|
||||
{ quiz_id: 1, title: 'My Custom Test', mode: 'learning', state: 'in_progress',
|
||||
answered: 3, total: 20, active_attempt_id: 91, last_attempt_id: null },
|
||||
{ quiz_id: 2, title: 'Board Review IX', mode: 'timed', state: 'completed',
|
||||
answered: 40, total: 40, last_score: 31, active_attempt_id: null, last_attempt_id: 92 },
|
||||
{ quiz_id: 3, title: 'Neonatology drill', mode: 'learning', state: 'completed',
|
||||
answered: 10, total: 10, last_score: 8, active_attempt_id: null, last_attempt_id: 93 },
|
||||
{ quiz_id: 4, title: 'Older still', mode: 'timed', state: 'completed',
|
||||
answered: 5, total: 5, last_score: 5, active_attempt_id: null, last_attempt_id: 94 },
|
||||
{ quiz_id: 5, title: 'Board Review XII', mode: 'timed', state: 'not_started',
|
||||
answered: 0, total: 202, active_attempt_id: null, last_attempt_id: null },
|
||||
]
|
||||
|
||||
function mockInitialRequests() {
|
||||
api.get.mockImplementation((url) => {
|
||||
if (url === '/question-categories/') return Promise.resolve({ data: [] })
|
||||
if (url === '/favorites') return Promise.resolve({ data: [] })
|
||||
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
|
||||
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
const mount = (rows = SESSIONS) => {
|
||||
api.get.mockResolvedValue({ data: rows })
|
||||
return render(<MemoryRouter><QuestionBankPage /></MemoryRouter>)
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QuestionBankPage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
async function openFilters() {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Filters/ }))
|
||||
await screen.findByRole('dialog', { name: 'Question filters' })
|
||||
}
|
||||
describe('the Qbank landing', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
describe('QuestionBankPage QTI actions', () => {
|
||||
let originalCreateElement
|
||||
let fileInput
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInitialRequests()
|
||||
originalCreateElement = document.createElement.bind(document)
|
||||
fileInput = null
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
|
||||
const element = originalCreateElement(tagName, options)
|
||||
if (tagName === 'input') {
|
||||
fileInput = element
|
||||
vi.spyOn(element, 'click').mockImplementation(() => {})
|
||||
}
|
||||
return element
|
||||
})
|
||||
it('leads with starting a session', async () => {
|
||||
mount()
|
||||
expect(await screen.findByRole('heading', { name: 'Create a session' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Create a session' })).toHaveAttribute('href', '/study/new')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement.mockRestore()
|
||||
it('shows the latest three and sends you elsewhere for the rest', async () => {
|
||||
mount()
|
||||
await screen.findByText('My Custom Test')
|
||||
const list = document.querySelector('.qb-list')
|
||||
// Three, not everything: this is a landing point and the history is a link away.
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(3)
|
||||
expect(within(list).queryByText('Older still')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /See all/ })).toHaveAttribute('href', '/sessions')
|
||||
})
|
||||
|
||||
it('imports QTI files with an in-app success dialog', async () => {
|
||||
api.post.mockResolvedValueOnce({ data: { imported: 3, total_items: 4, errors: ['Skipped duplicate'] } })
|
||||
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Import QTI' }))
|
||||
|
||||
expect(fileInput).toBeTruthy()
|
||||
expect(fileInput.accept).toBe('.xml')
|
||||
|
||||
const file = new File(['<questestinterop />'], 'questions.xml', { type: 'text/xml' })
|
||||
Object.defineProperty(fileInput, 'files', { value: [file], configurable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.post).toHaveBeenCalledWith('/questions/import/qti', expect.any(FormData))
|
||||
})
|
||||
expect(await screen.findByText('QTI Import Complete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Imported 3 of 4 questions. 1 error(s).')).toBeInTheDocument()
|
||||
it('leaves out material nobody has started — that is not history', async () => {
|
||||
mount()
|
||||
await screen.findByText('My Custom Test')
|
||||
expect(screen.queryByText('Board Review XII')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows QTI export failures instead of swallowing them', async () => {
|
||||
api.get.mockImplementation((url) => {
|
||||
if (url.startsWith('/questions/export/qti')) {
|
||||
return Promise.reject({ response: { data: { detail: 'Export unavailable' } } })
|
||||
}
|
||||
if (url === '/question-categories/') return Promise.resolve({ data: [] })
|
||||
if (url === '/favorites') return Promise.resolve({ data: [] })
|
||||
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
|
||||
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
it('resumes what is unfinished and offers another sitting of what is done', async () => {
|
||||
mount()
|
||||
const live = (await screen.findByText('My Custom Test')).closest('.qb-row')
|
||||
expect(within(live).getByRole('link', { name: 'Resume' })).toHaveAttribute('href', '/study/1')
|
||||
expect(within(live).getByRole('link', { name: 'Analysis' })).toHaveAttribute('href', '/sessions/91')
|
||||
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Export QTI' }))
|
||||
const done = screen.getByText('Board Review IX').closest('.qb-row')
|
||||
expect(within(done).getByRole('link', { name: 'Sit again' })).toHaveAttribute('href', '/study/2')
|
||||
expect(within(done).getByRole('link', { name: 'Analysis' })).toHaveAttribute('href', '/sessions/92')
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Export Failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('Export unavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('QuestionBankPage review regressions', () => {
|
||||
beforeEach(() => { vi.resetAllMocks(); mockInitialRequests() })
|
||||
|
||||
it('renders validation arrays for category creation without crashing', async () => {
|
||||
api.post.mockRejectedValue({ response: { data: { detail: [{ msg: 'Category name is too long' }] } } })
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: '+ Category' }))
|
||||
const name = screen.getByLabelText('Category name')
|
||||
expect(name).toHaveAttribute('maxlength', '200')
|
||||
await userEvent.type(name, 'New category')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add', exact: true }))
|
||||
expect(await screen.findByText('Category name is too long')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles selected-quiz validation arrays and bounds the title', async () => {
|
||||
const initial = api.get.getMockImplementation()
|
||||
api.get.mockImplementation(url => url === '/questions/bank' ? Promise.resolve({ data: {
|
||||
total: 1, questions: [{ id: 10, question_text: 'A full question', options: ['Yes', 'No'], correct_answer: 'Yes', question_type: 'mcq' }],
|
||||
} }) : initial(url))
|
||||
api.post.mockRejectedValue({ response: { data: { detail: [{ msg: 'Quiz title is invalid' }] } } })
|
||||
renderPage()
|
||||
await screen.findByText('A full question')
|
||||
await userEvent.click(screen.getByRole('checkbox'))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Quiz (1 selected)' }))
|
||||
const title = screen.getByLabelText('Quiz Title')
|
||||
expect(title).toHaveAttribute('maxlength', '200')
|
||||
await userEvent.type(title, 'My quiz')
|
||||
await userEvent.click(within(screen.getByRole('dialog', { name: 'Create Quiz' })).getByRole('button', { name: 'Create Quiz', exact: true }))
|
||||
expect(await screen.findByText('Quiz title is invalid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('QuestionBankPage edit modal multi-category', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInitialRequests()
|
||||
})
|
||||
|
||||
it('opens the full editor rather than a modal, and says where to come back to', async () => {
|
||||
const question = { id: 7, question_text: 'Edit me', question_type: 'mcq', options: ['A', 'B'], correct_answer: 'A', explanation: '', question_category_id: 1, category_ids: [1] }
|
||||
const initialGet = api.get.getMockImplementation()
|
||||
api.get.mockImplementation(url => url === '/questions/bank'
|
||||
? Promise.resolve({ data: { questions: [question], total: 1 } }) : initialGet(url))
|
||||
renderPage()
|
||||
|
||||
// A modal could not show option explanations, images, versions and
|
||||
// categories at once, which is what editing a question actually needs.
|
||||
const edit = await screen.findByRole('link', { name: 'Edit' })
|
||||
expect(edit).toHaveAttribute('href', '/questions/7')
|
||||
await userEvent.click(edit)
|
||||
expect(screen.queryByRole('dialog', { name: 'Edit Question' })).not.toBeInTheDocument()
|
||||
it('says so plainly when nothing has been sat', async () => {
|
||||
mount([])
|
||||
expect(await screen.findByText(/No sessions yet/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copes when the sessions cannot be loaded', async () => {
|
||||
api.get.mockRejectedValue(new Error('down'))
|
||||
render(<MemoryRouter><QuestionBankPage /></MemoryRouter>)
|
||||
expect(await screen.findByText(/No sessions yet/)).toBeInTheDocument()
|
||||
// The thing this page is for still works.
|
||||
expect(screen.getByRole('link', { name: 'Create a session' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import RichText from '../components/RichText'
|
|||
import ImagePicker from '../components/ImagePicker'
|
||||
import FigureManager from '../components/FigureManager'
|
||||
import MarkdownToolbar from '../components/MarkdownToolbar'
|
||||
import FeedbackPanel from '../components/FeedbackPanel'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
import './QuestionEditPage.css'
|
||||
|
||||
|
|
@ -51,8 +52,11 @@ export default function QuestionEditPage({ mode = 'edit' }) {
|
|||
// Back to wherever you opened this from — the bank with its filters, an
|
||||
// article, the manager — rather than always to the bank you may not have used.
|
||||
const { state } = useLocation()
|
||||
const backTo = state?.from || '/question-bank'
|
||||
const backLabel = state?.label || 'Question bank'
|
||||
// The questions page, not the Qbank landing: that is where questions are
|
||||
// listed now, and returning to a page with no questions on it is not a way
|
||||
// back from editing one.
|
||||
const backTo = state?.from || '/questions/manage'
|
||||
const backLabel = state?.label || 'Questions'
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/question-categories/').then(res => setCategories(res.data || [])).catch(() => setCategories([]))
|
||||
|
|
@ -394,6 +398,17 @@ export default function QuestionEditPage({ mode = 'edit' }) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{!isCreate && (
|
||||
<section className="qe-card">
|
||||
<h2>Feedback</h2>
|
||||
<div className="qe-card-body">
|
||||
{/* Beside the fields it is about: the answer to almost every
|
||||
report is a change to the question. */}
|
||||
<FeedbackPanel questionId={Number(id)} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="qe-card">
|
||||
<h2>Figures</h2>
|
||||
<div className="qe-card-body">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import QuestionPreview from '../components/QuestionPreview'
|
||||
import QuestionImport from '../components/QuestionImport'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import GrantsPanel from '../components/GrantsPanel'
|
||||
|
|
@ -48,6 +50,8 @@ export default function QuestionManagerPage() {
|
|||
const [bulkBusy, setBulkBusy] = useState(false)
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState(null)
|
||||
// Looking at a question without leaving the list you found it in.
|
||||
const [previewing, setPreviewing] = useState(null)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -127,9 +131,13 @@ export default function QuestionManagerPage() {
|
|||
|
||||
return (
|
||||
<div className="qm-page">
|
||||
{previewing && (
|
||||
<QuestionPreview question={previewing} onClose={() => setPreviewing(null)} />
|
||||
)}
|
||||
|
||||
<div className="qm-header">
|
||||
<div>
|
||||
<h1>Question manager</h1>
|
||||
<h1>Questions</h1>
|
||||
<p>
|
||||
{scope && !scope.is_moderator
|
||||
? `Your editorial grants cover ${scope.categories.length} categor${scope.categories.length === 1 ? 'y' : 'ies'}.`
|
||||
|
|
@ -138,9 +146,9 @@ export default function QuestionManagerPage() {
|
|||
</div>
|
||||
<div className="qm-header-actions">
|
||||
<Link className="btn btn-secondary" to="/categories">Taxonomy</Link>
|
||||
<Link className="btn btn-secondary" to="/question-bank">Open question bank</Link>
|
||||
<QuestionImport onImported={refresh} selectedIds={selected} />
|
||||
<Link className="btn btn-primary" to="/questions/new"
|
||||
state={{ from: '/questions/manage', label: 'Question manager' }}>+ New question</Link>
|
||||
state={{ from: '/questions/manage', label: 'Questions' }}>+ New question</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -220,8 +228,13 @@ export default function QuestionManagerPage() {
|
|||
{/* The whole question on its own page. The modal could show
|
||||
neither a searchable category tree nor images, versions and
|
||||
option explanations at once, which is what editing needs. */}
|
||||
{/* Preview opens over the list. Sending the reader to a
|
||||
whole page to read one question, then back, was the
|
||||
redundant part. */}
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => setPreviewing(q)}>Preview</button>
|
||||
<Link className="btn btn-secondary btn-sm" to={`/questions/${q.id}`}
|
||||
state={{ from: `${location.pathname}${location.search}`, label: 'Question manager' }}>Edit</Link>
|
||||
state={{ from: `${location.pathname}${location.search}`, label: 'Questions' }}>Edit</Link>
|
||||
{deletingId === q.id ? (
|
||||
<>
|
||||
<button className="btn btn-danger btn-sm" aria-label={`Confirm delete question ${q.id}`} onClick={() => deleteOne(q.id)}>Confirm</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { uploadUrl } from '../utils/uploads'
|
||||
import QuestionReadingLinks from '../components/QuestionReadingLinks'
|
||||
import CommentSection from '../components/CommentSection'
|
||||
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import RichText from '../components/RichText'
|
||||
|
|
@ -9,6 +8,8 @@ import { useAuth } from '../context/AuthContext'
|
|||
import api from '../api/client'
|
||||
import useMediaQuery from '../hooks/useMediaQuery'
|
||||
import FigureStrip from '../components/FigureStrip'
|
||||
import FeedbackForm from '../components/FeedbackForm'
|
||||
import '../components/Feedback.css'
|
||||
import QuizTools, { QuizDialog } from '../components/QuizTools'
|
||||
import './QuizPlayer.css'
|
||||
|
||||
|
|
@ -310,6 +311,33 @@ function ShareLinkBadge({ quiz, onShareChanged }) {
|
|||
)
|
||||
}
|
||||
|
||||
/** Occasional session actions, folded away until asked for. */
|
||||
function MoreActions({ children }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrap = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) }
|
||||
const onKey = e => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', away)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', away)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="quiz-more" ref={wrap}>
|
||||
<button type="button" className="btn btn-secondary btn-sm" aria-haspopup="menu"
|
||||
aria-expanded={open} aria-label="More options" onClick={() => setOpen(v => !v)}>⋯</button>
|
||||
{open && <div className="quiz-more-menu" role="menu">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function CourseQuizStart({ quiz, onStart, onShareChanged }) {
|
||||
const mode = quiz.mode === 'timed' || quiz.allow_review !== 1 ? 'exam' : 'study'
|
||||
const [error, setError] = useState('')
|
||||
|
|
@ -426,6 +454,15 @@ export default function QuizPage() {
|
|||
const [statsError, setStatsError] = useState('')
|
||||
const [showStats, setShowStats] = useState(() => localStorage.getItem('pedshub_show_stats') !== '0')
|
||||
const [showAllExplanations, setShowAllExplanations] = useState(false)
|
||||
// Which options have had their reasoning opened by clicking them. Separate
|
||||
// from the show-all toggle so one does not fight the other.
|
||||
const [openExplanations, setOpenExplanations] = useState(() => new Set())
|
||||
const toggleOptionExplanation = (index) => setOpenExplanations(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) next.delete(index)
|
||||
else next.add(index)
|
||||
return next
|
||||
})
|
||||
const toggleStats = () => setShowStats(value => {
|
||||
localStorage.setItem('pedshub_show_stats', value ? '0' : '1')
|
||||
return !value
|
||||
|
|
@ -556,6 +593,7 @@ export default function QuizPage() {
|
|||
setActiveReadSegment(null)
|
||||
setTtsActive(false)
|
||||
setDraftAnswer('')
|
||||
setOpenExplanations(new Set())
|
||||
savedHighlightSelectionRef.current = null
|
||||
clearTimeout(autoHighlightTimerRef.current)
|
||||
if (!readThrough) setActiveReadSegment(null)
|
||||
|
|
@ -1202,7 +1240,6 @@ const timerStarted = timeLeft !== null
|
|||
</span>
|
||||
<span>Q {currentIdx + 1} / {totalCount}</span>
|
||||
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
|
||||
<ShareLinkBadge quiz={quiz} onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
|
|
@ -1269,21 +1306,11 @@ const timerStarted = timeLeft !== null
|
|||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||||
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
|
||||
}}>
|
||||
{/* The category and the difficulty are both hints. Knowing a
|
||||
question is filed under Seizures & Epilepsy, or that it is
|
||||
"easy", narrows the answer before the stem has been read —
|
||||
so neither is shown until the answer is in. */}
|
||||
{/* Difficulty is a hint, so it waits until the answer is in. The
|
||||
category trail is gone entirely: it named the answer's own
|
||||
topic and led out of a session you are part-way through. What
|
||||
to read next belongs in the explanation, which links to it. */}
|
||||
<div className="quiz-qmeta">
|
||||
{answerRevealed && current.category_breadcrumbs?.length > 0 && (
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">
|
||||
{current.category_breadcrumbs.map((category, index) => (
|
||||
<span key={category.id}>
|
||||
{index > 0 && <span className="quiz-breadcrumb-sep" aria-hidden="true">›</span>}
|
||||
<Link to={`/study/new?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
{answerRevealed && current.difficulty && (
|
||||
<span className={`quiz-meta-pill is-${current.difficulty}`}>{current.difficulty}</span>
|
||||
)}
|
||||
|
|
@ -1316,11 +1343,21 @@ const timerStarted = timeLeft !== null
|
|||
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}>
|
||||
✎ <span>{note ? 'Notes' : 'Add notes'}</span>
|
||||
</button>
|
||||
<button type="button" className={panel === 'save' ? 'is-on' : ''}
|
||||
aria-pressed={panel === 'save'}
|
||||
onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}>
|
||||
⊞ <span>Save</span>
|
||||
</button>
|
||||
{/* Saving, sharing and reporting are occasional, so they fold
|
||||
away behind one control rather than each taking a slot in a
|
||||
bar that is read on every question. */}
|
||||
<MoreActions>
|
||||
<button type="button" className="quiz-more-item"
|
||||
onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}>
|
||||
⊞ Save to a folder
|
||||
</button>
|
||||
<ShareLinkBadge quiz={quiz}
|
||||
onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
|
||||
<details className="quiz-more-feedback">
|
||||
<summary>⚑ Give feedback</summary>
|
||||
<FeedbackForm questionId={current.id} />
|
||||
</details>
|
||||
</MoreActions>
|
||||
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
|
||||
onClick={() => toggleFavorite(current.id)}
|
||||
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}>
|
||||
|
|
@ -1471,9 +1508,16 @@ const timerStarted = timeLeft !== null
|
|||
return (
|
||||
<button type="button" key={i} aria-pressed={isSelected} aria-disabled={hasAnswered}
|
||||
className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && !hasActiveTextSelection() && chooseAnswer(opt)}
|
||||
onClick={() => {
|
||||
if (hasActiveTextSelection()) return
|
||||
if (!hasAnswered) return chooseAnswer(opt)
|
||||
// Once answered, the option is a disclosure for its
|
||||
// own reasoning: clicking it opens that, and clicking
|
||||
// it again closes it.
|
||||
if (current.option_explanations?.[opt]) toggleOptionExplanation(i)
|
||||
}}
|
||||
style={{
|
||||
cursor: hasAnswered ? 'default' : 'pointer',
|
||||
cursor: hasAnswered && current.option_explanations?.[opt] ? 'pointer' : hasAnswered ? 'default' : 'pointer',
|
||||
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
|
||||
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(59, 130, 246, 0.2)' : undefined,
|
||||
transition: 'background 0.15s ease, box-shadow 0.15s ease',
|
||||
|
|
@ -1495,7 +1539,7 @@ const timerStarted = timeLeft !== null
|
|||
</span>
|
||||
{showCorrect && <span className="option-status option-status-correct">✓ Correct</span>}
|
||||
{showWrong && <span className="option-status option-status-wrong">✗ Wrong</span>}
|
||||
{hasAnswered && showAllExplanations && current.option_explanations?.[opt] && (
|
||||
{hasAnswered && (showAllExplanations || openExplanations.has(i)) && current.option_explanations?.[opt] && (
|
||||
<span className="quiz-option-explanation">
|
||||
<RichText value={current.option_explanations[opt]} className="rich-inline" />
|
||||
</span>
|
||||
|
|
@ -1568,7 +1612,6 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
)}
|
||||
<QuestionReadingLinks questionId={current.id} />
|
||||
<CommentSection questionId={current.id} />
|
||||
{current.question_type === 'fill_blank' && (
|
||||
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
||||
<strong>Correct Answer:</strong> {current.correct_answer}
|
||||
|
|
|
|||
|
|
@ -117,16 +117,17 @@ describe('quiz player', () => {
|
|||
expect(meta).toBeInTheDocument()
|
||||
|
||||
// Category and difficulty are hints — being told a question is filed under
|
||||
// Neonatology, or that it is "hard", narrows the answer before the stem has
|
||||
// been read. They wait until the answer is in.
|
||||
expect(within(meta).queryByRole('link', { name: 'Pediatrics' })).not.toBeInTheDocument()
|
||||
// "hard" narrows the answer before the stem has been read, so it waits
|
||||
// until the answer is in.
|
||||
expect(within(meta).queryByText('hard')).not.toBeInTheDocument()
|
||||
expect(within(meta).getByText('Multiple choice')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
expect(await within(meta).findByRole('link', { name: 'Neonatology' })).toBeInTheDocument()
|
||||
expect(within(meta).getByText('hard')).toBeInTheDocument()
|
||||
expect(await within(meta).findByText('hard')).toBeInTheDocument()
|
||||
// The category trail is gone: it named the answer's own topic, and led out
|
||||
// of a session you are part-way through.
|
||||
expect(within(meta).queryByRole('link', { name: 'Neonatology' })).not.toBeInTheDocument()
|
||||
|
||||
// Mark moved off the stem into the action bar, so the stem is text only.
|
||||
const bar = screen.getByRole('toolbar', { name: 'Question actions' })
|
||||
|
|
@ -150,7 +151,6 @@ describe('quiz player', () => {
|
|||
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
|
||||
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
|
||||
// Once the answer is in, the trail is a way to more of the same topic.
|
||||
expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/study/new?category=11')
|
||||
fireEvent(window, new Event('pagehide'))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/attempts/progress', expect.objectContaining({ answers: { 1: 'First answer' } }), expect.any(Object)))
|
||||
expect(api.post.mock.calls.some(([url]) => url.startsWith('/attempts/start'))).toBe(false)
|
||||
|
|
@ -248,6 +248,30 @@ describe('quiz player', () => {
|
|||
expect(screen.queryByText('Full first clinical question.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens one option\'s reasoning on click, and closes it on the next click', async () => {
|
||||
const originalGet = api.get.getMockImplementation()
|
||||
api.get.mockImplementation(async (url, ...args) => {
|
||||
const res = await originalGet(url, ...args)
|
||||
if (url.includes('attempt_id=')) {
|
||||
res.data.questions[0] = { ...res.data.questions[0],
|
||||
option_explanations: { 'First answer': 'Because it is first.' } }
|
||||
}
|
||||
return res
|
||||
})
|
||||
await begin()
|
||||
await findStem('Full first clinical question.')
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
const option = await waitFor(() => inCard().getByText('First answer').closest('button'))
|
||||
expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument()
|
||||
await userEvent.click(option)
|
||||
expect(await screen.findByText('Because it is first.')).toBeInTheDocument()
|
||||
// Clicking it again puts it away, rather than leaving it open for good.
|
||||
await userEvent.click(option)
|
||||
await waitFor(() => expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('keeps notes with the question, not in a second notepad floating over it', async () => {
|
||||
await begin()
|
||||
const bar = await screen.findByRole('toolbar', { name: 'Question actions' })
|
||||
|
|
@ -411,7 +435,9 @@ describe('quiz player', () => {
|
|||
mount()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Start session' }))
|
||||
await findStem('Full first clinical question.')
|
||||
await screen.findByRole('button', { name: 'Make shareable' })
|
||||
// Sharing folded away behind the question bar's more-menu, alongside
|
||||
// saving and giving feedback — occasional actions, not part of every read.
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'More options' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Make shareable' }))
|
||||
await screen.findByRole('button', { name: 'Copy share link' })
|
||||
expect(api.post).toHaveBeenCalledWith('/quizzes/10/share-link')
|
||||
|
|
|
|||
|
|
@ -108,7 +108,17 @@
|
|||
.quiz-review-tabs > span:first-child { background: #333; color: white; padding: 11px 24px; font-size: .85rem; font-weight: 600; }
|
||||
.quiz-review-tabs .quiz-source-page { color: #6f7886; padding: 10px; font-size: .84rem; }
|
||||
.quiz-player .explanation { border: 0; border-radius: 0; background: transparent; padding: 16px 0; font-size: 1rem; line-height: 1.75; color: #383d43; }
|
||||
.quiz-player .quiz-stats-note { color: #7a818c; font-size: .74rem; margin: 10px 0; }
|
||||
/* A row of separate things — a note, the clocks, and two or three actions —
|
||||
not a paragraph with controls run into the text. It was a <p> with inline
|
||||
children, so everything butted together with no space at all. */
|
||||
.quiz-player .quiz-stats-note {
|
||||
display: flex; align-items: center; flex-wrap: wrap;
|
||||
gap: 10px 14px; margin: 14px 0;
|
||||
padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg); color: #7a818c; font-size: .74rem;
|
||||
}
|
||||
/* The note takes the room, so the actions sit together on the right. */
|
||||
.quiz-player .quiz-stats-note > :first-child { margin-right: auto; }
|
||||
.quiz-submit-response { margin: 16px 0; }
|
||||
.quiz-submit-error { padding: 14px; color: #962b3e; background: #fff0f2; margin-bottom: 14px; }
|
||||
.quiz-review-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; margin: 20px 0; }
|
||||
|
|
@ -143,7 +153,8 @@
|
|||
.question-reading ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.question-reading a { color: var(--primary); font-size: .86rem; }
|
||||
.quiz-option-explanation { display: block; width: 100%; margin-top: 6px; padding: 6px 10px; border-radius: 6px; background: var(--input-bg); border: 1px solid var(--border); font-size: .8rem; color: var(--text); text-align: left; }
|
||||
.quiz-stats-toggle { margin-left: 8px; background: none; border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; font-size: .72rem; color: var(--text-muted); cursor: pointer; }
|
||||
.quiz-stats-toggle { background: none; border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; font-size: .72rem; color: var(--text-muted); cursor: pointer; }
|
||||
.quiz-stats-toggle { min-height: 28px; white-space: nowrap; }
|
||||
.quiz-stats-toggle:hover { color: var(--primary); border-color: var(--primary); }
|
||||
.quiz-key-points { margin: 6px 0 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.quiz-key-points li { font-size: .86rem; }
|
||||
|
|
@ -200,7 +211,13 @@
|
|||
/* Session, this question, and the running average — in study mode too, because
|
||||
"four minutes on one question" is the number that says whether you are
|
||||
learning or stuck, countdown or no countdown. */
|
||||
.quiz-clock { display: inline-flex; align-items: center; gap: 12px; margin-right: auto; }
|
||||
/* The clocks are one group. `margin-right: auto` used to push everything after
|
||||
them to the far edge of the row, which is what left the note welded to the
|
||||
pause button at one end and the actions stranded at the other. */
|
||||
.quiz-clock {
|
||||
display: inline-flex; align-items: center; gap: 14px;
|
||||
padding: 2px 12px; border-inline: 1px solid var(--border);
|
||||
}
|
||||
.quiz-clock-pause {
|
||||
width: 28px; height: 28px; flex-shrink: 0; cursor: pointer;
|
||||
border: 1px solid var(--border); border-radius: 7px; background: var(--card-bg);
|
||||
|
|
@ -238,3 +255,25 @@
|
|||
/* Where the rail is on screen the counter is a label, not a control — there is
|
||||
nothing left for it to open. */
|
||||
.quiz-topbar .quiz-question-select.is-static { margin: 0; border: 0; cursor: default; }
|
||||
|
||||
/* Occasional session actions, folded away until asked for. */
|
||||
.quiz-more { position: relative; }
|
||||
.quiz-more-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 50;
|
||||
min-width: 250px; padding: 12px 14px;
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.quiz-more-menu .quiz-code-badge { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
|
||||
/* Items inside the more-menu read as a list of actions, not as buttons in a row. */
|
||||
.quiz-more-item, .quiz-more-feedback > summary {
|
||||
display: block; width: 100%; padding: 8px 10px; min-height: 38px;
|
||||
font: inherit; font-size: 0.84rem; text-align: left; color: var(--text);
|
||||
background: none; border: 0; border-radius: 7px; cursor: pointer;
|
||||
}
|
||||
.quiz-more-item:hover, .quiz-more-feedback > summary:hover { background: var(--bg); }
|
||||
.quiz-more-feedback[open] > summary { color: var(--primary); font-weight: 600; }
|
||||
.quiz-more-feedback { border-top: 1px solid var(--border); margin-top: 6px; padding-top: 6px; }
|
||||
.quiz-more-feedback .fb-form { padding: 4px 10px 8px; }
|
||||
.quiz-more-menu .quiz-code-badge { padding: 8px 10px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue