Conversation
There was a problem hiding this comment.
Pull request overview
Adds core student exam-taking flow (start exam, question rendering, navigation overview, saving responses, and submission) across the frontend and backend.
Changes:
- Implemented student exam UI components for section overview, question display, navigation grid, and submission.
- Added backend endpoints to start an exam session, save a response (upsert), and submit an exam.
- Updated DB schema to enforce one response per (test_session, question) via a unique constraint.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/components/dashboard/student/ExamQuestion.jsx | New question UI with answer selection, mark-for-review, and save flow. |
| frontend/src/components/dashboard/student/ExamOverview.jsx | New per-section question navigation grid and instructions shortcut. |
| frontend/src/components/dashboard/student/ExamNavigation.jsx | Adds timer display and submit action to the exam header. |
| frontend/src/components/dashboard/student/ExamInfo.jsx | Starts exam via API and initializes session state (status/start time/sections). |
| frontend/src/components/dashboard/StudentExam.jsx | Wires exam status state machine and lays out question + overview panes. |
| docker/mysql/init.sql | Adds unique constraint to support response upsert semantics. |
| backend/routes/api/student.js | Expands start_exam payload; adds save_response and submit endpoints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| selected_option: | ||
| status === "marked_for_review" | ||
| ? null | ||
| : (selectedOption ?? null), |
There was a problem hiding this comment.
Marking a question for review sets selected_option to null in state (status === "marked_for_review" ? null : ...). This will clear an already selected answer when the user navigates away/back, which is surprising for “mark for review” flows (they usually preserve the current response). Consider keeping the existing selected_option and tracking review status separately.
| selected_option: | |
| status === "marked_for_review" | |
| ? null | |
| : (selectedOption ?? null), | |
| selected_option: selectedOption ?? null, |
| const { question_id, selected_option } = req.body; | ||
|
|
||
| try { | ||
| if (selected_option === null) { | ||
| await pool.query( | ||
| "DELETE FROM student_response WHERE question_id=? AND test_session_id=?", | ||
| [question_id, test_session.id], | ||
| ); | ||
|
|
||
| return res.status(200).send({ | ||
| status: 200, | ||
| success: true, | ||
| }); | ||
| } | ||
|
|
||
| await pool.query( | ||
| "INSERT INTO student_response (question_id, test_session_id, selected_option) VALUES (?, ?, ?) \ | ||
| ON DUPLICATE KEY UPDATE \ | ||
| selected_option = VALUES(selected_option), \ | ||
| saved_at = CURRENT_TIMESTAMP", | ||
| [question_id, test_session.id, selected_option], | ||
| ); |
There was a problem hiding this comment.
/save_response accepts question_id from the client but does not verify that the question belongs to the student’s assigned exam/session (it only uses test_session.id for the response row). A student could submit responses for arbitrary question IDs. Add a server-side check that question_id is part of the current session’s exam (e.g., join questions -> sections -> exams using the session’s exam_id) before insert/delete.
| if ( | ||
| currentQuestionIndex === currentSection.questions.length - 1 && | ||
| currentSectionIndex < sections.length | ||
| ) { | ||
| setCurrentSectionIndex(currentSectionIndex + 1); | ||
| setCurrentQuestionIndex(-1); | ||
| } else if (currentQuestionIndex < currentSection.questions.length) { |
There was a problem hiding this comment.
handleNext has off-by-one conditions that can set indexes out of bounds: currentSectionIndex < sections.length allows incrementing to sections.length, and currentQuestionIndex < currentSection.questions.length allows incrementing to questions.length. That can make currentQuestion undefined and crash rendering (e.g., currentQuestion.status). Tighten the comparisons to sections.length - 1 / questions.length - 1 and/or clamp before updating state.
| if ( | |
| currentQuestionIndex === currentSection.questions.length - 1 && | |
| currentSectionIndex < sections.length | |
| ) { | |
| setCurrentSectionIndex(currentSectionIndex + 1); | |
| setCurrentQuestionIndex(-1); | |
| } else if (currentQuestionIndex < currentSection.questions.length) { | |
| if (!currentSection?.questions?.length || sections.length === 0) { | |
| return; | |
| } | |
| const lastQuestionIndex = currentSection.questions.length - 1; | |
| const lastSectionIndex = sections.length - 1; | |
| if ( | |
| currentQuestionIndex === lastQuestionIndex && | |
| currentSectionIndex < lastSectionIndex | |
| ) { | |
| setCurrentSectionIndex(currentSectionIndex + 1); | |
| setCurrentQuestionIndex(0); | |
| } else if (currentQuestionIndex < lastQuestionIndex) { |
| {section.instructions !== null && ( | ||
| <Button | ||
| size="small" | ||
| fullWidth | ||
| variant="outlined" | ||
| sx={{ color: "gray", borderColor: "gray" }} | ||
| onClick={() => { | ||
| setCurrentSectionIndex(i); | ||
| setCurrentQuestionIndex(-1); | ||
| }} | ||
| > | ||
| Instructions | ||
| </Button> | ||
| )} |
There was a problem hiding this comment.
The “Instructions” button is shown whenever section.instructions !== null, which will also be true for undefined or an empty string. Use a stricter check (e.g., truthy / non-empty string) so you don’t navigate to an empty instructions view.
| {section.instructions !== null && ( | |
| <Button | |
| size="small" | |
| fullWidth | |
| variant="outlined" | |
| sx={{ color: "gray", borderColor: "gray" }} | |
| onClick={() => { | |
| setCurrentSectionIndex(i); | |
| setCurrentQuestionIndex(-1); | |
| }} | |
| > | |
| Instructions | |
| </Button> | |
| )} | |
| {typeof section.instructions === "string" && | |
| section.instructions.trim() !== "" && ( | |
| <Button | |
| size="small" | |
| fullWidth | |
| variant="outlined" | |
| sx={{ color: "gray", borderColor: "gray" }} | |
| onClick={() => { | |
| setCurrentSectionIndex(i); | |
| setCurrentQuestionIndex(-1); | |
| }} | |
| > | |
| Instructions | |
| </Button> | |
| )} |
No description provided.