fix: one sessions list, at /sessions, with plan material out of it
The page at /quizzes showed the same fifteen rows twice — once under a "Sessions" tab as a list, once under a "Library" tab as cards — with nothing distinguishing them. The navbar carried the duplication too, with "Sessions" and "History" both pointing at the same page. Board Review I-XII already exist as study plans. The Library tab was showing the bulk quizzes those plans were built from, so the same twelve titles appeared in both systems. Those quizzes are now origin='plan': still real, still the parent of their questions via source_quiz_id, but no longer offered as something to pick off a list. Once a learner has actually sat one it is history, so the session list keeps it. - QuizzesPage deleted; /sessions is the only listing - /quizzes/* redirects to /sessions/*, preserving path and query - submitting a session lands on its analysis, not the old score page - the answer review drops its score hero, which the analysis owns and stated differently; a course quiz keeps its card, having no analysis - delete-attempt moves to the analysis page, where the session lives Backend 208/208, frontend 246/246. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
6993c06998
commit
ebb9e701ee
34 changed files with 236 additions and 1067 deletions
47
backend/alembic/versions/b2c3d4e5f6a7_plan_origin.py
Normal file
47
backend/alembic/versions/b2c3d4e5f6a7_plan_origin.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Mark study-plan source quizzes with origin='plan'
|
||||
|
||||
Board Review I–XII exist twice over: once as a study plan the learner works
|
||||
through block by block, and once as the bulk quiz the plan was built from. The
|
||||
session list showed both, so the same twelve titles appeared under "Sessions"
|
||||
and again under "Library" with nothing to tell them apart.
|
||||
|
||||
Tagging the bulk quizzes keeps them intact — the questions still hang off them
|
||||
via `source_quiz_id` — while taking them out of the list of things a learner
|
||||
picks from. A quiz already sat stays in the history regardless; that filtering
|
||||
lives in the endpoint, not here.
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "b2c3d4e5f6a7"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has(table: str) -> bool:
|
||||
return table in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Guarded because create_all() may have built these tables on a fresh
|
||||
# deploy before Alembic ran.
|
||||
if not (_has("quizzes") and _has("study_plans")):
|
||||
return
|
||||
# Matched on the trimmed title: one of the imports carries a trailing
|
||||
# space ("Board Review VII ").
|
||||
op.execute(sa.text("""
|
||||
UPDATE quizzes SET origin = 'plan'
|
||||
WHERE course_id IS NULL
|
||||
AND origin <> 'plan'
|
||||
AND btrim(title) IN (SELECT btrim(name) FROM study_plans)
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _has("quizzes"):
|
||||
return
|
||||
op.execute(sa.text("UPDATE quizzes SET origin = 'bank' WHERE origin = 'plan'"))
|
||||
|
|
@ -7,6 +7,11 @@ from app.database import Base
|
|||
from app.models.quiz_category import QuizCategory # noqa — ensures mapper resolves "QuizCategory"
|
||||
from app.models.quiz_question_link import QuizQuestionLink # noqa
|
||||
|
||||
#: `Quiz.origin` for a quiz that backs a study-plan block rather than standing
|
||||
#: on its own. Kept here because both the study-plan builder that sets it and
|
||||
#: the session list that filters on it need the same spelling.
|
||||
PLAN_ORIGIN = "plan"
|
||||
|
||||
|
||||
class Quiz(Base):
|
||||
__tablename__ = "quizzes"
|
||||
|
|
@ -29,7 +34,10 @@ class Quiz(Base):
|
|||
questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool
|
||||
allow_review = Column(Integer, default=1) # 1 = students can review answers after submit, 0 = no review
|
||||
share_token = Column(String(64), unique=True, nullable=True) # public /share/{token} link when set
|
||||
origin = Column(String(20), default="bank") # bank | upload | ai
|
||||
# bank | upload | ai | sample | plan.
|
||||
# "plan" marks a quiz that exists to back a study-plan block. It is still a
|
||||
# real quiz — it just is not something the learner picks off a list.
|
||||
origin = Column(String(20), default="bank")
|
||||
|
||||
section = relationship("Section", back_populates="quizzes")
|
||||
user = relationship("User", back_populates="quizzes")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||
from app.utils.upload_access import validate_image_attachments
|
||||
from app.utils.quiz_questions import validate_option_explanations
|
||||
from app.database import get_db
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.quiz import PLAN_ORIGIN, Quiz
|
||||
from app.models.quiz_category import QuizCategory
|
||||
from app.models.question import Question as QuestionModel
|
||||
from app.models.section import Section
|
||||
|
|
@ -333,6 +333,12 @@ def list_quiz_sessions(
|
|||
for quiz in quizzes:
|
||||
live = active.get(quiz.id)
|
||||
done = finished.get(quiz.id, [])
|
||||
# Material belonging to a study plan is not a session of its own. It is
|
||||
# listed, and started, on the plan — showing it here as well put the
|
||||
# same twelve titles under two headings with no way to tell them apart.
|
||||
# Once the learner has actually sat it, it is history and belongs here.
|
||||
if quiz.origin == PLAN_ORIGIN and not live and not done:
|
||||
continue
|
||||
last = done[-1] if done else None
|
||||
per_attempt = quiz.questions_per_attempt or quiz.questions_count or 0
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ const UploadPage = lazy(() => import('./pages/UploadPage'))
|
|||
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
|
||||
const QuizPage = lazy(() => import('./pages/QuizPage'))
|
||||
const CustomQuizPage = lazy(() => import('./pages/CustomQuizPage'))
|
||||
const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
|
||||
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage'))
|
||||
const AccountPage = lazy(() => import('./pages/AccountPage'))
|
||||
|
|
@ -79,6 +78,17 @@ function RequireAuth({ moderator = false }) {
|
|||
return <Outlet />
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an old /quizzes URL to its /sessions equivalent, keeping whatever
|
||||
* follows and any query string. `replace` so the browser's Back button skips
|
||||
* the dead address rather than bouncing the learner straight back to it.
|
||||
*/
|
||||
function LegacyQuizRedirect() {
|
||||
const { pathname, search, hash } = useLocation()
|
||||
const rest = pathname.replace(/^\/quizzes/, '')
|
||||
return <Navigate to={`/sessions${rest}${search}${hash}`} replace />
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <LoadingFallback />
|
||||
|
|
@ -100,10 +110,9 @@ function AppRoutes() {
|
|||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/quizzes" element={<QuizzesPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/quizzes/create" element={<CustomQuizPage />} />
|
||||
<Route path="/quizzes/:id" element={<QuizPage />} />
|
||||
<Route path="/sessions/create" element={<CustomQuizPage />} />
|
||||
<Route path="/sessions/:id" element={<QuizPage />} />
|
||||
<Route path="/results/:id" element={<ResultsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
|
|
@ -135,7 +144,7 @@ function AppRoutes() {
|
|||
<Route element={<RequireAuth moderator />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/sessions/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/trash" element={<TrashPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
|
|
@ -145,6 +154,11 @@ function AppRoutes() {
|
|||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* The word "quiz" is gone from the interface, but links to it are in
|
||||
bookmarks, in shared messages, and in anything already open in a
|
||||
second tab. These keep working rather than landing on Not Found. */}
|
||||
<Route path="/quizzes/*" element={<LegacyQuizRedirect />} />
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -56,10 +56,10 @@ export default function CategoryPerformance() {
|
|||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button>
|
||||
onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button>
|
||||
{main.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
|
||||
onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
|
||||
On my weakest topics
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export default function ContinueStudy() {
|
|||
<div className="cs-card">
|
||||
<div className="cs-card-head">
|
||||
<h3>Latest question sessions</h3>
|
||||
<Link to="/quizzes">See all</Link>
|
||||
<Link to="/sessions">See all</Link>
|
||||
</div>
|
||||
<ul className="cs-list">
|
||||
{sessions.map(row => {
|
||||
|
|
@ -63,7 +63,7 @@ export default function ContinueStudy() {
|
|||
</span>
|
||||
<span className="cs-count">{answered}/{total}</span>
|
||||
<Link className="btn btn-secondary btn-sm"
|
||||
to={done ? `/results/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
|
||||
to={done ? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
|
||||
{done ? 'Review' : 'Resume'}
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function JobsBadge({ jobs }) {
|
|||
</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>{job.last_step || 'Waiting…'}</div>
|
||||
{job.status === 'completed' && job.quiz_id && (
|
||||
<Link to={`/quizzes/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
|
||||
<Link to={`/sessions/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
|
||||
onClick={() => setOpen(false)}>Open Quiz →</Link>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -107,11 +107,9 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
{ to: '/home', label: 'Home' },
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/ai', label: 'AI Mode' },
|
||||
// "Quiz" describes the packaging; a learner sits a session. Both entries
|
||||
// are here because they answer different questions: what can I sit, and
|
||||
// what have I sat.
|
||||
{ to: '/quizzes', label: 'Sessions' },
|
||||
{ to: '/sessions', label: 'History' },
|
||||
// One entry, because there is one page. "Sessions" and "History" were two
|
||||
// names for the same list, which is what made it unreadable.
|
||||
{ to: '/sessions', label: 'Sessions' },
|
||||
{ to: '/analysis', label: 'Analysis' },
|
||||
{ to: '/question-bank', label: 'Question Bank' },
|
||||
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function PractiseTopic({ article, canEdit, questions, onUnlink })
|
|||
difficulty: null, algorithm: 'random',
|
||||
article_ids: [article.id], tag_ids: [], explicit_ids: [],
|
||||
})
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
navigate(`/sessions/${res.data.id}`)
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
setError(typeof detail === 'string' ? detail : 'Could not create a test from this topic.')
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const mount = (props = {}) => render(
|
|||
<Routes>
|
||||
<Route path="/articles/7" element={
|
||||
<PractiseTopic article={ARTICLE} canEdit={false} questions={QUESTIONS} onUnlink={vi.fn()} {...props} />} />
|
||||
<Route path="/quizzes/:id" element={<h1>Test ready</h1>} />
|
||||
<Route path="/sessions/:id" element={<h1>Test ready</h1>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const COLUMNS = [
|
|||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/question-bank', label: 'Question bank' },
|
||||
{ to: '/study-plans', label: 'Study plans' },
|
||||
{ to: '/quizzes', label: 'Quizzes' },
|
||||
{ to: '/sessions', label: 'Sessions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { render, screen, within } from '@testing-library/react'
|
|||
import { MemoryRouter } from 'react-router-dom'
|
||||
import SiteFooter from './SiteFooter'
|
||||
|
||||
const ROUTES = ['/home', '/login', '/register', '/', '/quizzes', '/question-bank', '/analysis',
|
||||
const ROUTES = ['/home', '/login', '/register', '/', '/sessions', '/question-bank', '/analysis',
|
||||
'/questions/manage', '/flashcards', '/search', '/ai', '/media', '/study-plans', '/articles',
|
||||
'/courses', '/account', '/settings', '/categories', '/editorial']
|
||||
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ export default function AnalysisPage() {
|
|||
.catch(() => setSessions([]))
|
||||
}, [])
|
||||
|
||||
const startAdaptive = () => navigate(`/quizzes/create?adaptive=1&count=${count}`)
|
||||
const startCategory = (categoryId) => navigate(`/quizzes/create?category=${categoryId}&count=${count}`)
|
||||
const startAdaptive = () => navigate(`/sessions/create?adaptive=1&count=${count}`)
|
||||
const startCategory = (categoryId) => navigate(`/sessions/create?category=${categoryId}&count=${count}`)
|
||||
|
||||
return (
|
||||
<div className={`an-layout${railOpen ? '' : ' rail-closed'}`}>
|
||||
|
|
@ -110,7 +110,7 @@ export default function AnalysisPage() {
|
|||
{/* The session's own analysis, not the raw answer list —
|
||||
that is what this rail is for. */}
|
||||
<Link to={row.last_attempt_id
|
||||
? `/analysis/session/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
|
||||
? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
|
||||
<span className="an-rail-title">
|
||||
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -90,3 +90,14 @@
|
|||
.an-pager button { width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); cursor: pointer; color: var(--text-muted); }
|
||||
.an-pager button:disabled { opacity: 0.4; cursor: default; }
|
||||
.an-pager button:not(:disabled):hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
/* Actions sit with the heading rather than under the figures: they act on the
|
||||
session as a whole, which is what the heading names. */
|
||||
.an-head-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.an-danger { color: var(--wrong-fg); border-color: var(--wrong-bd); background: var(--card-bg); }
|
||||
.an-danger:hover { background: var(--wrong-bg); }
|
||||
.an-danger-note {
|
||||
margin: 0 0 14px; padding: 10px 12px; font-size: 0.84rem; line-height: 1.55;
|
||||
color: var(--wrong-fg); background: var(--wrong-bg);
|
||||
border: 1px solid var(--wrong-bd); border-radius: 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import './AnalysisSessionPage.css'
|
||||
|
||||
|
|
@ -76,6 +76,20 @@ export default function AnalysisSessionPage() {
|
|||
// Ten at a time: a session of forty is a table nobody reads to the end of.
|
||||
const [page, setPage] = useState(0)
|
||||
const [railOpen, setRailOpen] = useState(true)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const deleteSession = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.delete(`/attempts/${attemptId}`)
|
||||
navigate('/sessions', { replace: true })
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
setError('Could not delete this session')
|
||||
}
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
|
|
@ -116,7 +130,7 @@ export default function AnalysisSessionPage() {
|
|||
{sessions.map(session => (
|
||||
<li key={session.quiz_id}>
|
||||
<Link className={`an-rail-item${session.last_attempt_id === Number(attemptId) ? ' is-active' : ''}`}
|
||||
to={session.last_attempt_id ? `/analysis/session/${session.last_attempt_id}` : `/quizzes/${session.quiz_id}`}>
|
||||
to={session.last_attempt_id ? `/analysis/session/${session.last_attempt_id}` : `/sessions/${session.quiz_id}`}>
|
||||
<span className="an-rail-mode">
|
||||
{session.mode === 'learning' ? 'Study mode' : 'Exam mode'}:
|
||||
</span>
|
||||
|
|
@ -143,8 +157,33 @@ export default function AnalysisSessionPage() {
|
|||
<main className="an-main">
|
||||
<div className="an-head">
|
||||
<h1>Your performance for <span>{data.title}</span></h1>
|
||||
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
|
||||
<div className="an-head-actions">
|
||||
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
|
||||
{data.quiz_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/sessions/${data.quiz_id}?restart=1`}>Retake</Link>
|
||||
)}
|
||||
{/* Deleting is destructive and irreversible, so it asks first —
|
||||
inline, because a browser confirm() is not something this
|
||||
codebase uses. */}
|
||||
{confirmDelete ? (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm an-danger" disabled={deleting}
|
||||
onClick={deleteSession}>{deleting ? 'Deleting…' : 'Delete for good'}</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => setConfirmDelete(false)}>Keep</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn btn-secondary btn-sm an-danger"
|
||||
onClick={() => setConfirmDelete(true)}>Delete session</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{confirmDelete && (
|
||||
<p className="an-danger-note" role="alert">
|
||||
This removes the attempt and its answers. Your overall statistics are
|
||||
recalculated without it, and it cannot be undone.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="an-figures">
|
||||
{[
|
||||
|
|
|
|||
|
|
@ -467,7 +467,7 @@ export default function CourseDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
{canAttempt && (
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/sessions/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
|
||||
{attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'}
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export default function CustomQuizPage() {
|
|||
is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random',
|
||||
article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds,
|
||||
})
|
||||
navigate(`/quizzes/${result.data.id}`)
|
||||
navigate(`/sessions/${result.data.id}`)
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
setError(typeof detail === 'string' ? detail : 'Could not create test. Check your settings and try again.')
|
||||
|
|
@ -216,7 +216,7 @@ export default function CustomQuizPage() {
|
|||
return (
|
||||
<div className="custom-test">
|
||||
<div className="custom-test-top">
|
||||
<Link to="/quizzes" className="custom-test-back">← Quizzes</Link>
|
||||
<Link to="/sessions" className="custom-test-back">← Quizzes</Link>
|
||||
<div>
|
||||
<h1>Create Custom Test</h1>
|
||||
<p className="custom-test-intro">Choose questions from your bank, {user?.name || 'learner'}.</p>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ function setupCount(count = 30) {
|
|||
})
|
||||
}
|
||||
function renderBuilder() {
|
||||
render(<MemoryRouter initialEntries={['/quizzes/create']}><Routes>
|
||||
<Route path="/quizzes/create" element={<CustomQuizPage />} />
|
||||
<Route path="/quizzes/:id" element={<h1>Saved test</h1>} />
|
||||
render(<MemoryRouter initialEntries={['/sessions/create']}><Routes>
|
||||
<Route path="/sessions/create" element={<CustomQuizPage />} />
|
||||
<Route path="/sessions/:id" element={<h1>Saved test</h1>} />
|
||||
</Routes></MemoryRouter>)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,10 +227,10 @@ export default function DocumentDetailPage() {
|
|||
setActiveJob({ jobId: res.data.job_id, sectionName })
|
||||
// If already completed (sync fallback), navigate directly
|
||||
if (res.data.status === 'completed' && res.data.quiz_id) {
|
||||
navigate(`/quizzes/${res.data.quiz_id}`)
|
||||
navigate(`/sessions/${res.data.quiz_id}`)
|
||||
}
|
||||
} else if (res.data.id) {
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
navigate(`/sessions/${res.data.id}`)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to start extraction. Check AI model config.')
|
||||
|
|
@ -285,7 +285,7 @@ export default function DocumentDetailPage() {
|
|||
<ExtractionProgress
|
||||
jobId={activeJob.jobId}
|
||||
label={activeJob.type === 'flashcard' ? 'Generating Cards' : 'Extracting Questions'}
|
||||
onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/quizzes/${quizId}`) }}
|
||||
onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/sessions/${quizId}`) }}
|
||||
onClose={() => setActiveJob(null)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function JobDetail({ job }) {
|
|||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{job.quiz_id && (
|
||||
<Link to={`/quizzes/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
|
||||
<Link to={`/sessions/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
|
||||
)}
|
||||
{job.status === 'running' && (
|
||||
<button className="btn btn-danger btn-sm" onClick={async () => {
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ export default function LandingPage() {
|
|||
<div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{user ? <>
|
||||
<Link to="/" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Dashboard</Link>
|
||||
<Link to="/quizzes" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>My Quizzes</Link>
|
||||
<Link to="/sessions" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>My Quizzes</Link>
|
||||
<Link to="/question-bank" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>Question Bank</Link>
|
||||
</> : <>
|
||||
<button onClick={() => setAuthModal('login')} className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Sign In</button>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default function PublicQuizPage() {
|
|||
</p>
|
||||
)}
|
||||
{user ? (
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${quiz.quiz_id}`)}>
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/sessions/${quiz.quiz_id}`)}>
|
||||
Take this quiz
|
||||
</button>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) {
|
|||
time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null,
|
||||
}
|
||||
})
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
navigate(`/sessions/${res.data.id}`)
|
||||
} else {
|
||||
const res = await api.post('/questions/from-bank', {
|
||||
title: form.title,
|
||||
|
|
@ -187,7 +187,7 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) {
|
|||
mode: form.mode,
|
||||
time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null,
|
||||
})
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
navigate(`/sessions/${res.data.id}`)
|
||||
}
|
||||
} catch (err) { setError(apiError(err, 'Could not create quiz')) }
|
||||
finally { setLoading(false) }
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ export default function QuizEditPage() {
|
|||
setQuiz(qRes.data)
|
||||
setTitleValue(qRes.data.title)
|
||||
setQuestions(questRes.data)
|
||||
}).catch(() => navigate('/quizzes'))
|
||||
}).catch(() => navigate('/sessions'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
|
|
@ -268,7 +268,7 @@ export default function QuizEditPage() {
|
|||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/quizzes/${id}`} className="btn btn-secondary btn-sm">← Back to Quiz</Link>
|
||||
<Link to={`/sessions/${id}`} className="btn btn-secondary btn-sm">← Back to Quiz</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -920,9 +920,13 @@ const timerStarted = timeLeft !== null
|
|||
clearInterval(timerRef.current)
|
||||
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
|
||||
if (returnTo) {
|
||||
// A course quiz reports back to its course, and has no analysis of its
|
||||
// own — the answer review is the whole of its result.
|
||||
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
|
||||
} else {
|
||||
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
||||
// Everywhere else the session ends on its analysis: score, timing and
|
||||
// what to do next. The answer-by-answer review is one link from there.
|
||||
navigate(`/analysis/session/${attemptId}`, { state: { result: res.data } })
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
|
|
@ -961,7 +965,7 @@ const timerStarted = timeLeft !== null
|
|||
<div>
|
||||
{isModerator && (
|
||||
<div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||||
<Link to={`/sessions/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||||
</div>
|
||||
)}
|
||||
{starting ? (
|
||||
|
|
@ -1204,7 +1208,7 @@ const timerStarted = timeLeft !== null
|
|||
) : (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning">↺ Restart</button>
|
||||
)}
|
||||
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||||
{isModerator && <Link to={`/sessions/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||||
</div>
|
||||
</div>
|
||||
{voices.length > 1 && (
|
||||
|
|
@ -1255,7 +1259,7 @@ const timerStarted = timeLeft !== null
|
|||
{current.category_breadcrumbs.map((category, index) => (
|
||||
<span key={category.id}>
|
||||
{index > 0 && <span className="quiz-breadcrumb-sep" aria-hidden="true">›</span>}
|
||||
<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
|
||||
<Link to={`/sessions/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -47,8 +47,14 @@ beforeEach(() => {
|
|||
api.delete.mockResolvedValue({})
|
||||
})
|
||||
|
||||
function mount(entry = '/quizzes/10') {
|
||||
render(<MemoryRouter initialEntries={[entry]}><Routes><Route path="/quizzes/:id" element={<QuizPage />} /><Route path="/results/:id" element={<div>Submitted results</div>} /></Routes></MemoryRouter>)
|
||||
function mount(entry = '/sessions/10') {
|
||||
render(<MemoryRouter initialEntries={[entry]}><Routes>
|
||||
<Route path="/sessions/:id" element={<QuizPage />} />
|
||||
{/* Submitting a general session ends on its analysis; a course quiz, which
|
||||
has no analysis of its own, still ends on the answer review. */}
|
||||
<Route path="/analysis/session/:attemptId" element={<div>Submitted results</div>} />
|
||||
<Route path="/results/:id" element={<div>Course results</div>} />
|
||||
</Routes></MemoryRouter>)
|
||||
}
|
||||
/** The session rail repeats each stem as an excerpt, so stem lookups are
|
||||
* scoped to the question card to stay unambiguous. */
|
||||
|
|
@ -145,7 +151,7 @@ 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', '/quizzes/create?category=11')
|
||||
expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/sessions/create?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)
|
||||
|
|
@ -189,7 +195,7 @@ describe('quiz player', () => {
|
|||
const response = await originalGet(url, ...args)
|
||||
return url.startsWith('/quizzes/10') ? { data: { ...response.data, mode: 'learning', allow_review: null, course_id: 1 } } : response
|
||||
})
|
||||
mount('/quizzes/10?return_to=%2Fcourses%2F1')
|
||||
mount('/sessions/10?return_to=%2Fcourses%2F1')
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Begin Quiz' }))
|
||||
await findStem('Full first clinical question.')
|
||||
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
|
||||
|
|
|
|||
|
|
@ -226,3 +226,11 @@
|
|||
.qz-overview-meta { margin: 0 0 10px; font-size: 0.86rem; color: var(--text-muted); }
|
||||
.qz-overview-note { margin: 0 0 18px; font-size: 0.85rem; color: var(--text-subtle); }
|
||||
.qz-overview-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* Answer review header. The score belongs to the analysis page; what this line
|
||||
carries is the way back and just enough of the result to know where you are. */
|
||||
.results-crumb { margin-bottom: 20px; }
|
||||
.results-crumb > a { font-size: 0.85rem; color: var(--primary); text-decoration: none; }
|
||||
.results-crumb > a:hover { text-decoration: underline; }
|
||||
.results-crumb h1 { margin: 6px 0 2px; font-size: 1.35rem; }
|
||||
.results-crumb-score { font-size: 0.85rem; color: var(--text-muted); }
|
||||
|
|
|
|||
|
|
@ -1,266 +0,0 @@
|
|||
/* Quiz management area — session list, library grid and category admin.
|
||||
Mobile-first: rows stack under 640px and the action menu becomes a sheet. */
|
||||
|
||||
.quizzes-page { max-width: 1080px; margin: 0 auto; }
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────────────────────── */
|
||||
.qz-hero {
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qz-hero-text { flex: 1; min-width: 220px; }
|
||||
.qz-hero h1 { margin: 0 0 4px; font-size: 1.25rem; }
|
||||
.qz-hero p { margin: 0; color: var(--text-muted); font-size: 0.88rem; line-height: 1.5; }
|
||||
.qz-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Tabs ─────────────────────────────────────────────────────────── */
|
||||
.qz-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 14px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.qz-tabs::-webkit-scrollbar { display: none; }
|
||||
.qz-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 9px 14px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qz-tab:hover { color: var(--text); }
|
||||
.qz-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.qz-tab-count { font-weight: 400; opacity: 0.7; margin-left: 5px; }
|
||||
|
||||
/* ── Toolbar (search + filters) ───────────────────────────────────── */
|
||||
.qz-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.qz-search { flex: 1; min-width: 190px; position: relative; }
|
||||
.qz-search input {
|
||||
width: 100%;
|
||||
padding: 9px 13px 9px 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.qz-search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-subtle); }
|
||||
.qz-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.qz-chip {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-muted);
|
||||
border-radius: 20px;
|
||||
padding: 6px 13px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qz-chip[aria-pressed='true'] { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
|
||||
|
||||
/* ── Session list ─────────────────────────────────────────────────── */
|
||||
.qz-daygroup { margin-bottom: 18px; }
|
||||
.qz-dayhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
.qz-dayhead::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
.qz-sessions { display: flex; flex-direction: column; gap: 8px; }
|
||||
.qz-session {
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 13px 15px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.qz-session-main { min-width: 0; }
|
||||
.qz-session-kicker {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-subtle);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.qz-badge { border-radius: 20px; padding: 1px 8px; font-size: 0.66rem; letter-spacing: 0.04em; }
|
||||
.qz-badge-study { background: #d1fae5; color: #065f46; }
|
||||
.qz-badge-exam { background: #e0e7ff; color: #3730a3; }
|
||||
.qz-badge-draft { background: #fef3c7; color: #92400e; }
|
||||
.qz-badge-shared { background: #ede9fe; color: #5b21b6; }
|
||||
|
||||
.qz-session-title {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.qz-session-title:hover { color: var(--primary); }
|
||||
.qz-session-meta { font-size: 0.76rem; color: var(--text-muted); margin-top: 3px; }
|
||||
|
||||
.qz-progress { display: flex; align-items: center; gap: 8px; margin-top: 7px; }
|
||||
.qz-progress-track {
|
||||
flex: 1;
|
||||
max-width: 260px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.qz-progress-fill { height: 100%; border-radius: 3px; background: var(--primary); transition: width 0.25s ease; }
|
||||
.qz-progress-fill.is-done { background: var(--correct-fg); }
|
||||
.qz-progress-label { font-size: 0.76rem; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.qz-session-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||
.qz-menu-wrap { position: relative; }
|
||||
.qz-kebab {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
.qz-kebab:hover { background: var(--bg); color: var(--text); }
|
||||
.qz-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 60;
|
||||
min-width: 190px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.qz-menu-item {
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.qz-menu-item:hover:not(:disabled) { background: var(--bg); }
|
||||
.qz-menu-item:disabled { color: var(--text-subtle); cursor: not-allowed; }
|
||||
.qz-menu-item.is-danger { color: var(--wrong-fg); }
|
||||
.qz-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }
|
||||
.qz-menu-backdrop { position: fixed; inset: 0; z-index: 55; }
|
||||
|
||||
.qz-rename { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.qz-rename input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.87rem;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.qz-inline-confirm {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--wrong-fg);
|
||||
}
|
||||
.qz-error { color: var(--wrong-fg); font-size: 0.8rem; margin: 6px 0 0; }
|
||||
|
||||
/* ── Library grid ─────────────────────────────────────────────────── */
|
||||
.qz-group-head {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.qz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin-bottom: 22px; }
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────────── */
|
||||
.qz-empty {
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 34px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.qz-empty strong { display: block; color: var(--text); font-size: 1rem; margin-bottom: 6px; }
|
||||
|
||||
/* ── Mobile ───────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.qz-hero { padding: 15px; }
|
||||
.qz-hero-actions { width: 100%; }
|
||||
.qz-hero-actions .btn { flex: 1; text-align: center; }
|
||||
|
||||
.qz-session { grid-template-columns: 1fr; padding: 13px; }
|
||||
.qz-session-actions { width: 100%; }
|
||||
.qz-session-actions .btn { flex: 1; }
|
||||
.qz-progress-track { max-width: none; }
|
||||
|
||||
/* Action menu becomes a bottom sheet so it never overflows the viewport. */
|
||||
.qz-menu-backdrop { background: rgba(15, 23, 42, 0.4); }
|
||||
.qz-menu {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
top: auto;
|
||||
min-width: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -10px 34px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.qz-menu-item { padding: 13px 14px; font-size: 0.92rem; }
|
||||
|
||||
.qz-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.qz-seemore { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin: 6px 0 4px; }
|
||||
@media (max-width: 640px) { .qz-seemore .btn { flex: 1; text-align: center; } }
|
||||
|
|
@ -1,548 +0,0 @@
|
|||
import { useState, useEffect, useRef, useMemo, lazy, Suspense } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
import ConfirmButton from '../components/ConfirmButton'
|
||||
import Dialog from '../components/Dialog'
|
||||
import { useDialog } from '../hooks/useDialog'
|
||||
import './QuizzesPage.css'
|
||||
|
||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||
import QuestionReadingLinks from '../components/QuestionReadingLinks'
|
||||
|
||||
const dayLabel = (iso) => {
|
||||
if (!iso) return 'Earlier'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return 'Earlier'
|
||||
const today = new Date()
|
||||
const sameDay = (a, b) => a.toDateString() === b.toDateString()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
if (sameDay(d, today)) return 'Today'
|
||||
if (sameDay(d, yesterday)) return 'Yesterday'
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function HighlightText({ text, query }) {
|
||||
if (!query || !text) return <span>{text}</span>
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase())
|
||||
if (idx === -1) return <span>{text}</span>
|
||||
return (
|
||||
<span>
|
||||
{text.slice(0, idx)}
|
||||
<mark style={{ background: '#fef08a', padding: '0 1px', borderRadius: 2 }}>{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionStudyModal({ question, query, onClose }) {
|
||||
const [answered, setAnswered] = useState(null)
|
||||
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', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Study Question</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||
</div>
|
||||
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>
|
||||
<HighlightText text={question.question_text} query={query} />
|
||||
</p>
|
||||
{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 isSelected = answered === opt
|
||||
const hasAnswered = !!answered
|
||||
const isCorrectOpt = opt === question.correct_answer
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && setAnswered(opt)}
|
||||
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
|
||||
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
|
||||
<span style={{ flex: 1 }}><HighlightText text={opt} query={query} /></span>
|
||||
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ {isSelected ? 'Your answer' : 'Correct'}</span>}
|
||||
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{answered && (question.explanation || question.explanation_image_path) && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation:</strong>
|
||||
{question.explanation && <div style={{ marginTop: 8 }}>{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>
|
||||
)}
|
||||
<QuestionReadingLinks questionId={question.id} />
|
||||
{answered && (
|
||||
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── One management row: progress, primary action, and the action menu ── */
|
||||
function SessionRow({ row, isModerator, onChanged, onDelete }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [draftTitle, setDraftTitle] = useState(row.title)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const canManage = isModerator || row.is_owner
|
||||
const total = row.total || row.questions_per_attempt || row.questions_count || 0
|
||||
const answered = Math.min(row.answered || 0, total)
|
||||
const percent = total > 0 ? Math.round((answered / total) * 100) : 0
|
||||
const done = row.state === 'completed'
|
||||
|
||||
const primary = done
|
||||
? { label: 'Review', to: `/results/${row.last_attempt_id}` }
|
||||
: row.state === 'in_progress'
|
||||
? { label: 'Resume', to: `/quizzes/${row.quiz_id}` }
|
||||
: { label: 'Start', to: `/quizzes/${row.quiz_id}` }
|
||||
|
||||
const closeMenu = () => setMenuOpen(false)
|
||||
|
||||
const rename = async () => {
|
||||
const title = draftTitle.trim()
|
||||
if (!title || title === row.title) { setRenaming(false); return }
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const res = await api.patch(`/quizzes/${row.quiz_id}`, { title })
|
||||
onChanged(row.quiz_id, { title: res.data.title })
|
||||
setRenaming(false)
|
||||
} catch (err) { setError(err.response?.data?.detail || 'Could not rename this test') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const toggleShare = async () => {
|
||||
closeMenu(); setBusy(true); setError('')
|
||||
try {
|
||||
const res = await api.patch(`/quizzes/${row.quiz_id}/share`, null, { params: { shared: row.is_shared !== 1 } })
|
||||
onChanged(row.quiz_id, { is_shared: res.data.is_shared, is_published: res.data.is_published })
|
||||
} catch (err) { setError(err.response?.data?.detail || 'Could not update sharing') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const togglePublish = async () => {
|
||||
closeMenu(); setBusy(true); setError('')
|
||||
try {
|
||||
const res = await api.patch(`/quizzes/${row.quiz_id}/publish`, null, { params: { published: row.is_published === 0 } })
|
||||
onChanged(row.quiz_id, { is_published: res.data.is_published, is_shared: res.data.is_shared })
|
||||
} catch (err) { setError(err.response?.data?.detail || 'Could not change visibility') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="qz-session">
|
||||
<div className="qz-session-main">
|
||||
<div className="qz-session-kicker">
|
||||
<span className={`qz-badge ${row.mode === 'learning' ? 'qz-badge-study' : 'qz-badge-exam'}`}>
|
||||
{row.mode === 'learning' ? 'Study mode' : 'Exam mode'}
|
||||
</span>
|
||||
{row.is_published === 0 && <span className="qz-badge qz-badge-draft">Hidden</span>}
|
||||
{row.is_shared === 1 && <span className="qz-badge qz-badge-shared">Shared</span>}
|
||||
{row.category_name && <span>{row.category_name}</span>}
|
||||
</div>
|
||||
|
||||
<Link className="qz-session-title" to={`/quizzes/${row.quiz_id}`}>{row.title}</Link>
|
||||
|
||||
<div className="qz-session-meta">
|
||||
{total} question{total !== 1 ? 's' : ''}
|
||||
{row.time_limit_minutes ? ` · ${row.time_limit_minutes} min` : ''}
|
||||
{row.attempts_count > 0 && ` · ${row.attempts_count} attempt${row.attempts_count !== 1 ? 's' : ''}`}
|
||||
{done && row.last_percentage !== null && ` · last score ${row.last_percentage}%`}
|
||||
</div>
|
||||
|
||||
<div className="qz-progress">
|
||||
<div className="qz-progress-track">
|
||||
<div className={`qz-progress-fill${done ? ' is-done' : ''}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span className="qz-progress-label">{answered}/{total}</span>
|
||||
</div>
|
||||
|
||||
{renaming && (
|
||||
<div className="qz-rename">
|
||||
<input value={draftTitle} autoFocus aria-label="Test name"
|
||||
onChange={e => setDraftTitle(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') rename(); if (e.key === 'Escape') setRenaming(false) }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={rename} disabled={busy}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setRenaming(false); setDraftTitle(row.title) }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<div className="qz-inline-confirm" role="alert">
|
||||
Move this test to Trash?
|
||||
<button className="btn btn-danger btn-sm" onClick={() => { setConfirmDelete(false); onDelete(row.quiz_id) }}>Yes, move to Trash</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setConfirmDelete(false)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{row.is_shared === 1 && <div className="qz-session-meta"><Link to={`/quizzes/${row.quiz_id}`}>Shared test link</Link></div>}
|
||||
{error && <p className="qz-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="qz-session-actions">
|
||||
<Link className="btn btn-primary btn-sm" to={primary.to}>{primary.label}</Link>
|
||||
<div className="qz-menu-wrap">
|
||||
<button className="qz-kebab" aria-haspopup="menu" aria-expanded={menuOpen}
|
||||
aria-label={`Actions for ${row.title}`} onClick={() => setMenuOpen(v => !v)}>⋯</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="qz-menu-backdrop" onClick={closeMenu} />
|
||||
<div className="qz-menu" role="menu">
|
||||
<Link className="qz-menu-item" role="menuitem" to={`/results/${row.last_attempt_id}`}
|
||||
onClick={e => { if (!row.last_attempt_id) e.preventDefault(); else closeMenu() }}
|
||||
aria-disabled={!row.last_attempt_id}
|
||||
style={row.last_attempt_id ? undefined : { color: 'var(--text-subtle)', pointerEvents: 'none' }}>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link className="qz-menu-item" role="menuitem" to={`/quizzes/${row.quiz_id}?restart=1`} onClick={closeMenu}>
|
||||
Repeat
|
||||
</Link>
|
||||
{canManage && (
|
||||
<button className="qz-menu-item" role="menuitem"
|
||||
onClick={() => { closeMenu(); setDraftTitle(row.title); setRenaming(true) }}>Rename</button>
|
||||
)}
|
||||
{canManage && (
|
||||
<button className="qz-menu-item" role="menuitem" disabled={busy} onClick={toggleShare}>
|
||||
{row.is_shared === 1 ? 'Unshare test' : 'Share test'}
|
||||
</button>
|
||||
)}
|
||||
{isModerator && <>
|
||||
<div className="qz-menu-sep" />
|
||||
<Link className="qz-menu-item" role="menuitem" to={`/quizzes/${row.quiz_id}/edit`} onClick={closeMenu}>Edit questions</Link>
|
||||
<button className="qz-menu-item" role="menuitem" disabled={busy} onClick={togglePublish}>
|
||||
{row.is_published === 0 ? 'Publish' : 'Hide from learners'}
|
||||
</button>
|
||||
</>}
|
||||
{canManage && <>
|
||||
<div className="qz-menu-sep" />
|
||||
<button className="qz-menu-item is-danger" role="menuitem"
|
||||
onClick={() => { closeMenu(); setConfirmDelete(true) }}>Delete</button>
|
||||
</>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuizCard({ quiz, isModerator, onOpenSessions }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="qz-session" style={{ gridTemplateColumns: '1fr', cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/quizzes/${quiz.quiz_id}`)}>
|
||||
<div className="qz-session-main">
|
||||
<div className="qz-session-kicker">
|
||||
<span className={`qz-badge ${quiz.mode === 'learning' ? 'qz-badge-study' : 'qz-badge-exam'}`}>
|
||||
{quiz.mode === 'learning' ? 'Study mode' : 'Exam mode'}
|
||||
</span>
|
||||
{quiz.is_published === 0 && isModerator && <span className="qz-badge qz-badge-draft">Hidden</span>}
|
||||
</div>
|
||||
<Link className="qz-session-title" to={`/quizzes/${quiz.quiz_id}`} onClick={e => e.stopPropagation()}>{quiz.title}</Link>
|
||||
<div className="qz-session-meta">
|
||||
{quiz.questions_per_attempt || quiz.questions_count} question{(quiz.questions_per_attempt || quiz.questions_count) !== 1 ? 's' : ''}
|
||||
{quiz.last_percentage !== null && quiz.last_percentage !== undefined && ` · last score ${quiz.last_percentage}%`}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }} onClick={e => e.stopPropagation()}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onOpenSessions}>Manage in Sessions</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuizzesPage() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState('sessions')
|
||||
const [stateFilter, setStateFilter] = useState('all')
|
||||
// The full history lives on the analysis page; this is a launcher.
|
||||
const SESSION_PREVIEW = 6
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchMode, setSearchMode] = useState('all')
|
||||
const [searchResults, setSearchResults] = useState(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [expandedSearch, setExpandedSearch] = useState(false)
|
||||
const [studyQuestion, setStudyQuestion] = useState(null)
|
||||
const { dialogProps, openAlert } = useDialog()
|
||||
const debounceRef = useRef(null)
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/quizzes/sessions')
|
||||
.then(res => setRows(Array.isArray(res.data) ? res.data : [])).catch(console.error).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
if (searchQuery.trim().length < 2) { setSearchResults(null); return }
|
||||
setSearching(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.get('/quizzes/search', { params: { q: searchQuery.trim(), mode: searchMode } })
|
||||
setSearchResults(res.data)
|
||||
} catch { setSearchResults([]) }
|
||||
finally { setSearching(false) }
|
||||
}, 350)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery, searchMode])
|
||||
|
||||
const deleteQuiz = async (quizId) => {
|
||||
try {
|
||||
await api.delete(`/quizzes/${quizId}`)
|
||||
setRows(prev => prev.filter(r => r.quiz_id !== quizId))
|
||||
if (searchResults) setSearchResults(prev => prev.filter(r => r.quiz_id !== quizId))
|
||||
} catch (err) { console.error(err) }
|
||||
}
|
||||
|
||||
const patchRow = (quizId, patch) =>
|
||||
setRows(prev => prev.map(r => (r.quiz_id === quizId ? { ...r, ...patch } : r)))
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: rows.length,
|
||||
in_progress: rows.filter(r => r.state === 'in_progress').length,
|
||||
completed: rows.filter(r => r.state === 'completed').length,
|
||||
not_started: rows.filter(r => r.state === 'not_started').length,
|
||||
}), [rows])
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => (stateFilter === 'all' ? rows : rows.filter(r => r.state === stateFilter)),
|
||||
[rows, stateFilter],
|
||||
)
|
||||
|
||||
const previewRows = useMemo(
|
||||
() => visibleRows.slice(0, SESSION_PREVIEW),
|
||||
[visibleRows],
|
||||
)
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const groups = []
|
||||
for (const row of previewRows) {
|
||||
const label = dayLabel(row.last_activity)
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.label === label) last.rows.push(row)
|
||||
else groups.push({ label, rows: [row] })
|
||||
}
|
||||
return groups
|
||||
}, [previewRows])
|
||||
|
||||
// One list, newest first. Quiz categories were a second taxonomy beside the
|
||||
// real one and are gone; grouping by them left a heading over every test.
|
||||
const libraryRows = useMemo(
|
||||
() => [...rows].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')),
|
||||
[rows])
|
||||
|
||||
const isSearching = searchQuery.trim().length >= 2
|
||||
const allSearchQuestions = searchResults?.flatMap(r => r.matching_questions.map(q => ({ ...q, quiz_title: r.quiz_title }))) ?? []
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="quizzes-page">
|
||||
<Dialog {...dialogProps} />
|
||||
{studyQuestion && (
|
||||
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
|
||||
)}
|
||||
|
||||
<div className="qz-hero">
|
||||
<div className="qz-hero-text">
|
||||
<h1>Your tests</h1>
|
||||
<p>Build a custom test from the question bank, then start, resume, review or repeat it from here.</p>
|
||||
</div>
|
||||
<div className="qz-hero-actions">
|
||||
<Link className="btn btn-primary" to="/quizzes/create">Create Custom Test</Link>
|
||||
<a className="btn btn-secondary" href="https://t.me/pedshubbot" target="_blank" rel="noopener noreferrer">
|
||||
Telegram bot
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="qz-toolbar">
|
||||
<div className="qz-search">
|
||||
<span className="qz-search-icon" aria-hidden="true">🔍</span>
|
||||
<input type="text" placeholder="Search tests or questions…" aria-label="Search tests or questions"
|
||||
value={searchQuery} onChange={e => { setSearchQuery(e.target.value); setExpandedSearch(false) }} />
|
||||
</div>
|
||||
<div className="qz-chips">
|
||||
{[['all', 'All'], ['title', 'Title only'], ['questions', 'Questions only']].map(([val, label]) => (
|
||||
<button key={val} className="qz-chip" aria-pressed={searchMode === val}
|
||||
onClick={() => setSearchMode(val)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSearching && (
|
||||
<div style={{ marginBottom: 12, fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span>{searching ? 'Searching…' : searchResults ? `${searchResults.length} test result${searchResults.length !== 1 ? 's' : ''}, ${allSearchQuestions.length} matching questions` : ''}</span>
|
||||
{!searching && allSearchQuestions.length > 0 && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setExpandedSearch(v => !v)}>
|
||||
{expandedSearch ? 'Show summary' : `View all ${allSearchQuestions.length} questions →`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded full question search results */}
|
||||
{isSearching && expandedSearch && allSearchQuestions.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: '0.95rem' }}>
|
||||
All matching questions for "{searchQuery}"
|
||||
</div>
|
||||
{allSearchQuestions.map((q, i) => (
|
||||
<div key={`${q.quiz_id}-${q.id}-${i}`} style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 16px', marginBottom: 8, display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 180 }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 4 }}>{q.quiz_title}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text)', lineHeight: 1.5 }}>
|
||||
<HighlightText text={q.question_text.slice(0, 200) + (q.question_text.length > 200 ? '…' : '')} query={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => setStudyQuestion(q)} style={{ flexShrink: 0 }}>Study</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search summary results (compact) */}
|
||||
{isSearching && !expandedSearch && searchResults !== null && (
|
||||
searchResults.length === 0 ? (
|
||||
<div className="qz-empty">No matches for "{searchQuery}"</div>
|
||||
) : searchResults.map(result => (
|
||||
<div key={result.quiz_id} className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10, marginBottom: result.matching_questions.length > 0 ? 10 : 0 }}>
|
||||
<div style={{ cursor: 'pointer' }} onClick={() => navigate(`/quizzes/${result.quiz_id}`)}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 2 }}>
|
||||
<HighlightText text={result.quiz_title} query={result.match_type !== 'questions' ? searchQuery : ''} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{result.questions_count} questions
|
||||
{result.match_type !== 'title' && ` · ${result.matching_questions.length} question match${result.matching_questions.length !== 1 ? 'es' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
{isModerator && (
|
||||
<ConfirmButton label="✕" confirmLabel="Trash?" cancelLabel="✕"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-subtle)', fontSize: '1rem', padding: '4px 8px' }}
|
||||
onConfirm={() => deleteQuiz(result.quiz_id)} />
|
||||
)}
|
||||
</div>
|
||||
{result.matching_questions.slice(0, 2).map(q => (
|
||||
<div key={q.id} style={{ padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, marginBottom: 6, fontSize: '0.85rem', color: 'var(--text-muted)', borderLeft: '3px solid var(--primary)', display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 160 }}><HighlightText text={q.question_text.slice(0, 130) + (q.question_text.length > 130 ? '…' : '')} query={searchQuery} /></div>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion({ ...q, quiz_title: result.quiz_title })} style={{ flexShrink: 0 }}>Study</button>
|
||||
</div>
|
||||
))}
|
||||
{result.matching_questions.length > 2 && (
|
||||
<button className="btn btn-sm btn-secondary" style={{ marginTop: 4 }} onClick={() => setExpandedSearch(true)}>
|
||||
+{result.matching_questions.length - 2} more — view all questions →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{!isSearching && (
|
||||
<>
|
||||
<div className="qz-tabs" role="tablist">
|
||||
<button className="qz-tab" role="tab" aria-selected={tab === 'sessions'} onClick={() => setTab('sessions')}>
|
||||
Sessions<span className="qz-tab-count">{counts.all}</span>
|
||||
</button>
|
||||
<button className="qz-tab" role="tab" aria-selected={tab === 'library'} onClick={() => setTab('library')}>
|
||||
Library<span className="qz-tab-count">{libraryRows.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'sessions' && (
|
||||
rows.length === 0 ? (
|
||||
<div className="qz-empty">
|
||||
<strong>No tests yet</strong>
|
||||
Create a custom test from the question bank to start studying.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qz-chips" style={{ marginBottom: 14 }}>
|
||||
{[['all', 'All'], ['in_progress', 'In progress'], ['completed', 'Completed'], ['not_started', 'Not started']].map(([val, label]) => (
|
||||
<button key={val} className="qz-chip" aria-pressed={stateFilter === val} onClick={() => setStateFilter(val)}>
|
||||
{label} ({counts[val]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visibleRows.length === 0 ? (
|
||||
<div className="qz-empty">Nothing here yet — try another filter.</div>
|
||||
) : dayGroups.map(group => (
|
||||
<div key={group.label} className="qz-daygroup">
|
||||
<h2 className="qz-dayhead">{group.label}</h2>
|
||||
<div className="qz-sessions">
|
||||
{group.rows.map(row => (
|
||||
<SessionRow key={row.quiz_id} row={row}
|
||||
isModerator={isModerator} onChanged={patchRow} onDelete={deleteQuiz} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visibleRows.length > SESSION_PREVIEW && (
|
||||
<div className="qz-seemore">
|
||||
{/* Expanding a list in place answers a smaller question than
|
||||
the one being asked. The history page has the filters. */}
|
||||
<Link className="btn btn-secondary btn-sm" to="/sessions">
|
||||
Full session history ({visibleRows.length}) →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'library' && (
|
||||
libraryRows.length === 0 ? (
|
||||
<div className="qz-empty">No tests to show yet.</div>
|
||||
) : (
|
||||
<div className="qz-grid">
|
||||
{libraryRows.map(row => (
|
||||
<QuizCard key={row.quiz_id} quiz={row} isModerator={isModerator}
|
||||
onOpenSessions={() => { setTab('sessions'); setStateFilter('all') }} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { expect, it, vi, beforeEach } from 'vitest'
|
||||
import QuizzesPage from './QuizzesPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), patch: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, role: 'user' } }) }))
|
||||
|
||||
const session = (over = {}) => ({
|
||||
quiz_id: 10, title: 'My Test', mode: 'learning', origin: 'bank',
|
||||
questions_count: 20, questions_per_attempt: 20, time_limit_minutes: null,
|
||||
category_id: null, category_name: null, is_published: 0, is_shared: 0, is_owner: true,
|
||||
created_at: '2026-09-09T10:00:00', state: 'not_started', active_attempt_id: null,
|
||||
answered: 0, total: 20, attempts_count: 0, last_attempt_id: null, last_percentage: null,
|
||||
last_score: null, last_total: null, last_completed_at: null, best_percentage: null,
|
||||
last_activity: '2026-09-09T10:00:00', ...over,
|
||||
})
|
||||
|
||||
const mockSessions = (rows) => {
|
||||
api.get.mockImplementation(url =>
|
||||
Promise.resolve({ data: url === '/quizzes/sessions' ? rows : [] }))
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('offers creation, sharing and revocation for an owner, and displays server errors', async () => {
|
||||
mockSessions([session()])
|
||||
render(<MemoryRouter><QuizzesPage /></MemoryRouter>)
|
||||
expect(await screen.findByRole('link', { name: 'Create Custom Test' })).toHaveAttribute('href', '/quizzes/create')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Actions for My Test' }))
|
||||
api.patch.mockResolvedValueOnce({ data: { is_shared: 1, is_published: 0 } })
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Share test' }))
|
||||
expect(await screen.findByRole('link', { name: 'Shared test link' })).toHaveAttribute('href', '/quizzes/10')
|
||||
expect(api.patch).toHaveBeenLastCalledWith('/quizzes/10/share', null, { params: { shared: true } })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Actions for My Test' }))
|
||||
api.patch.mockResolvedValueOnce({ data: { is_shared: 0, is_published: 0 } })
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Unshare test' }))
|
||||
await waitFor(() => expect(screen.queryByRole('link', { name: 'Shared test link' })).not.toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Actions for My Test' }))
|
||||
api.patch.mockRejectedValueOnce({ response: { data: { detail: 'This test contains private or course-only questions' } } })
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Share test' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('private or course-only')
|
||||
})
|
||||
|
||||
it('shows the right primary action and progress for each session state', async () => {
|
||||
mockSessions([
|
||||
session({ quiz_id: 1, title: 'Fresh', state: 'not_started', answered: 0, total: 20 }),
|
||||
session({ quiz_id: 2, title: 'Halfway', state: 'in_progress', answered: 4, total: 20, active_attempt_id: 55 }),
|
||||
session({
|
||||
quiz_id: 3, title: 'Finished', state: 'completed', answered: 20, total: 20,
|
||||
attempts_count: 1, last_attempt_id: 77, last_percentage: 75,
|
||||
}),
|
||||
])
|
||||
render(<MemoryRouter><QuizzesPage /></MemoryRouter>)
|
||||
|
||||
const fresh = (await screen.findByText('Fresh')).closest('.qz-session')
|
||||
expect(within(fresh).getByRole('link', { name: 'Start' })).toHaveAttribute('href', '/quizzes/1')
|
||||
expect(within(fresh).getByText('0/20')).toBeInTheDocument()
|
||||
|
||||
const halfway = screen.getByText('Halfway').closest('.qz-session')
|
||||
expect(within(halfway).getByRole('link', { name: 'Resume' })).toHaveAttribute('href', '/quizzes/2')
|
||||
expect(within(halfway).getByText('4/20')).toBeInTheDocument()
|
||||
|
||||
const finished = screen.getByText('Finished').closest('.qz-session')
|
||||
expect(within(finished).getByRole('link', { name: 'Review' })).toHaveAttribute('href', '/results/77')
|
||||
expect(within(finished).getByText(/last score 75%/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters sessions by state', async () => {
|
||||
mockSessions([
|
||||
session({ quiz_id: 1, title: 'Fresh', state: 'not_started' }),
|
||||
session({ quiz_id: 2, title: 'Halfway', state: 'in_progress' }),
|
||||
])
|
||||
render(<MemoryRouter><QuizzesPage /></MemoryRouter>)
|
||||
await screen.findByText('Fresh')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'In progress (1)' }))
|
||||
expect(screen.queryByText('Fresh')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Halfway')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers Analysis and Repeat, and renames a test inline', async () => {
|
||||
mockSessions([session({ state: 'completed', last_attempt_id: 77, attempts_count: 1, answered: 20 })])
|
||||
render(<MemoryRouter><QuizzesPage /></MemoryRouter>)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Actions for My Test' }))
|
||||
expect(screen.getByRole('menuitem', { name: 'Analysis' })).toHaveAttribute('href', '/results/77')
|
||||
expect(screen.getByRole('menuitem', { name: 'Repeat' })).toHaveAttribute('href', '/quizzes/10?restart=1')
|
||||
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
const input = screen.getByLabelText('Test name')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Renamed test')
|
||||
api.patch.mockResolvedValueOnce({ data: { title: 'Renamed test' } })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(api.patch).toHaveBeenCalledWith('/quizzes/10', { title: 'Renamed test' })
|
||||
expect(await screen.findByText('Renamed test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes a test only after inline confirmation', async () => {
|
||||
mockSessions([session()])
|
||||
api.delete.mockResolvedValue({})
|
||||
render(<MemoryRouter><QuizzesPage /></MemoryRouter>)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Actions for My Test' }))
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
|
||||
expect(api.delete).not.toHaveBeenCalled()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Yes, move to Trash' }))
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/quizzes/10'))
|
||||
await waitFor(() => expect(screen.queryByText('My Test')).not.toBeInTheDocument())
|
||||
})
|
||||
|
|
@ -13,8 +13,6 @@ export default function ResultsPage() {
|
|||
const returnTo = searchParams.get('return_to')
|
||||
const [result, setResult] = useState(location.state?.result || null)
|
||||
const [loading, setLoading] = useState(!result)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [reviewIndex, setReviewIndex] = useState(0)
|
||||
const [tool, setTool] = useState(null)
|
||||
const [responseStats, setResponseStats] = useState(null)
|
||||
|
|
@ -22,23 +20,11 @@ export default function ResultsPage() {
|
|||
const isCourseQuiz = result?.course_id != null
|
||||
const reviewAllowed = result?.allow_review !== false
|
||||
|
||||
const deleteAttempt = async () => {
|
||||
if (!confirmDelete) { setConfirmDelete(true); return }
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.delete(`/attempts/${result.id}`)
|
||||
navigate(returnTo || '/quizzes')
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
setConfirmDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!result) {
|
||||
api.get(`/attempts/${id}`)
|
||||
.then(res => setResult(res.data))
|
||||
.catch(() => navigate(returnTo || '/quizzes'))
|
||||
.catch(() => navigate(returnTo || '/sessions'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [id])
|
||||
|
|
@ -63,72 +49,33 @@ export default function ResultsPage() {
|
|||
return (
|
||||
<div className="quiz-results">
|
||||
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
|
||||
{/* Score card */}
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="score-display">
|
||||
<div className={`score-value ${scoreClass}`}>{pct}%</div>
|
||||
<div style={{ fontSize: '1.05rem', color: 'var(--text-muted)', marginTop: 10 }}>
|
||||
{correct} of {total} correct
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: '0.95rem', color: 'var(--text-muted)' }}>
|
||||
{pct >= 90 ? "Excellent — you've mastered this material." :
|
||||
pct >= 75 ? 'Good work! Review any missed questions below.' :
|
||||
pct >= 50 ? 'Getting there — study the explanations and retake.' :
|
||||
'Review all explanations carefully before retaking.'}
|
||||
</div>
|
||||
{/* The score, the bands and "what next" are the analysis page's job.
|
||||
Repeating them here gave the learner two different-looking verdicts on
|
||||
the same attempt. This page is the answer-by-answer review, so it
|
||||
opens with the way back to the analysis and nothing else.
|
||||
|
||||
{/* Score bar */}
|
||||
<div style={{ maxWidth: 320, margin: '20px auto 24px' }}>
|
||||
<div style={{ height: 8, background: 'var(--border)', borderRadius: 999, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 999, transition: 'width 0.6s ease',
|
||||
width: `${pct}%`,
|
||||
background: pct >= 75 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444',
|
||||
}} />
|
||||
A course quiz has no analysis page — there the score card stays. */}
|
||||
{isCourseQuiz ? (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="score-display">
|
||||
<div className={`score-value ${scoreClass}`}>{pct}%</div>
|
||||
<div style={{ fontSize: '1.05rem', color: 'var(--text-muted)', marginTop: 10 }}>
|
||||
{correct} of {total} correct
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4, fontSize: '0.75rem', color: 'var(--text-subtle)' }}>
|
||||
<span>0%</span><span style={{ color: '#d97706' }}>75%</span><span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{isCourseQuiz ? (
|
||||
<>
|
||||
{returnTo && (
|
||||
<Link to={returnTo} className="btn btn-primary">Back to Course</Link>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
||||
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
|
||||
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
||||
{confirmDelete ? (
|
||||
<>
|
||||
<button
|
||||
onClick={deleteAttempt}
|
||||
disabled={deleting}
|
||||
className="btn btn-secondary"
|
||||
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Confirm Delete'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={deleteAttempt}
|
||||
className="btn btn-secondary"
|
||||
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
||||
>
|
||||
Delete Attempt
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{returnTo && (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<Link to={returnTo} className="btn btn-primary">Back to Course</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-crumb">
|
||||
<Link to={`/analysis/session/${result.id}`}>← Session analysis</Link>
|
||||
<h1>Answer review</h1>
|
||||
<span className="results-crumb-score">{correct} of {total} correct · {pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question review — only if allowed */}
|
||||
{reviewAllowed && result.answers && result.answers.length > 0 && (
|
||||
|
|
@ -159,7 +106,7 @@ export default function ResultsPage() {
|
|||
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
|
||||
return (
|
||||
<div className={`review-card ${cardClass}`} key={ans.question_id}>
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' › '}<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' › '}<Link to={`/sessions/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
|
||||
{/* Question header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
|
|
|
|||
|
|
@ -75,3 +75,5 @@
|
|||
.sx-actions { width: 100%; }
|
||||
.sx-actions .btn { flex: 1; }
|
||||
}
|
||||
|
||||
.sx-header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ const SORTS = [
|
|||
/**
|
||||
* Every session, in full, with the sidebar doing the narrowing.
|
||||
*
|
||||
* The quizzes page shows a handful and stops. That is right for a landing
|
||||
* point and wrong for the question "what have I actually done" — which is what
|
||||
* this page answers, so nothing here is truncated and nothing needs a
|
||||
* "show all".
|
||||
* This is the only list of sessions there is. It replaced a page that showed
|
||||
* the same rows twice — once as "Sessions" and again as "Library" — which gave
|
||||
* two names to one thing and no way to tell them apart. Material a learner has
|
||||
* not started is not a session: it is a study plan, and it is listed there.
|
||||
*/
|
||||
export default function SessionsPage() {
|
||||
const [rows, setRows] = useState([])
|
||||
|
|
@ -93,10 +93,16 @@ export default function SessionsPage() {
|
|||
<div className="sx-page">
|
||||
<div className="sx-header">
|
||||
<div>
|
||||
<h1>Session history</h1>
|
||||
<h1>Sessions</h1>
|
||||
<p>Every test you have started or finished.</p>
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to="/quizzes">Quizzes</Link>
|
||||
{/* Where a new one comes from. The course material itself lives in the
|
||||
study plans — this page is the record of working through it, not a
|
||||
second copy of the catalogue. */}
|
||||
<div className="sx-header-actions">
|
||||
<Link className="btn btn-secondary" to="/study-plans">Study plans</Link>
|
||||
<Link className="btn btn-primary" to="/sessions/create">Create custom test</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sx-body">
|
||||
|
|
@ -175,7 +181,7 @@ export default function SessionsPage() {
|
|||
once it has been sat, the session itself otherwise.
|
||||
It used to launch the test on click. */}
|
||||
<Link className="sx-title" to={row.last_attempt_id
|
||||
? `/analysis/session/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
|
||||
? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
|
||||
{row.title}
|
||||
</Link>
|
||||
<span className="sx-meta">
|
||||
|
|
@ -202,13 +208,13 @@ export default function SessionsPage() {
|
|||
|
||||
<span className="sx-actions">
|
||||
{row.state === 'in_progress' && (
|
||||
<Link className="btn btn-primary btn-sm" to={`/quizzes/${row.quiz_id}`}>Resume</Link>
|
||||
<Link className="btn btn-primary btn-sm" to={`/sessions/${row.quiz_id}`}>Resume</Link>
|
||||
)}
|
||||
{row.last_attempt_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/analysis/session/${row.last_attempt_id}`}>Review</Link>
|
||||
)}
|
||||
{row.state === 'not_started' && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/quizzes/${row.quiz_id}`}>Start</Link>
|
||||
<Link className="btn btn-secondary btn-sm" to={`/sessions/${row.quiz_id}`}>Start</Link>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export default function StudyPlanPage() {
|
|||
setBusy(true); setError('')
|
||||
try {
|
||||
const res = await api.post(`/study-plans/blocks/${block.id}/start`, null, { params: { mode } })
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
navigate(`/sessions/${res.data.id}`)
|
||||
} catch (err) { setError(apiError(err, 'Could not start this block')) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
|
@ -300,7 +300,7 @@ export default function StudyPlanPage() {
|
|||
<div className="block-sessions">
|
||||
<h3>Sessions</h3>
|
||||
{block.quiz_id ? (
|
||||
<Link className="btn btn-primary btn-sm" to={`/quizzes/${block.quiz_id}`}>
|
||||
<Link className="btn btn-primary btn-sm" to={`/sessions/${block.quiz_id}`}>
|
||||
{block.completed ? 'Review this block' : 'Continue this block'}
|
||||
</Link>
|
||||
) : block.question_count === 0 ? (
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ describe('study plans', () => {
|
|||
it('offers both modes on a fresh block and continues one already started', async () => {
|
||||
mountPlan()
|
||||
const started = (await screen.findByText('Block 1')).closest('.block')
|
||||
expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/quizzes/77')
|
||||
expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/sessions/77')
|
||||
|
||||
const fresh = screen.getByText('Block 2').closest('.block')
|
||||
api.post.mockResolvedValue({ data: { id: 91 } })
|
||||
|
|
|
|||
Loading…
Reference in a new issue