From 808c09321cc38468f14d204a3c07caaf71f080f4 Mon Sep 17 00:00:00 2001 From: Sanjay-07-Sk Date: Sun, 18 Oct 2026 23:04:34 +0530 Subject: [PATCH] feat: add quiz question deletion --- .../frontend/src/screens/QuizBuilder.test.tsx | 107 +++++++++++++++++- apps/frontend/src/screens/QuizBuilder.tsx | 102 ++++++++++++++++- .../src/controllers/quizController.ts | 62 +++++++++- apps/http-server/src/routes/quiz.ts | 7 +- 4 files changed, 274 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/screens/QuizBuilder.test.tsx b/apps/frontend/src/screens/QuizBuilder.test.tsx index c2ab470..5b3d3fa 100644 --- a/apps/frontend/src/screens/QuizBuilder.test.tsx +++ b/apps/frontend/src/screens/QuizBuilder.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import QuizBuilder from './QuizBuilder'; vi.mock('../services/api', () => ({ - api: { get: vi.fn(), post: vi.fn() }, + api: { get: vi.fn(), post: vi.fn(), delete: vi.fn() }, })); vi.mock('../services/genaiApi', async (importOriginal) => ({ @@ -44,8 +44,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.get).mockResolvedValue({ data: quiz }); vi.mocked(api.post).mockResolvedValue({ data: {} }); + vi.mocked(api.delete).mockResolvedValue({ data: { success: true } }); }); + describe('QuizBuilder AI integration', () => { it('shows each question difficulty', async () => { renderBuilder(); @@ -121,3 +123,106 @@ describe('QuizBuilder AI integration', () => { ); }); }); + +describe('QuizBuilder question deletion', () => { + it('shows confirmation dialog when Delete button is clicked and cancels when requested', async () => { + const user = renderBuilder(); + await screen.findByText('What is ATP?'); + + const deleteBtn = screen.getByRole('button', { name: /delete/i }); + await user.click(deleteBtn); + + // Confirmation modal is visible + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText('Delete Question')).toBeInTheDocument(); + expect(within(dialog).getByText(/"What is ATP\?"/i)).toBeInTheDocument(); + + // Clicking cancel dismisses the dialog without deleting + const cancelBtn = within(dialog).getByRole('button', { name: /cancel/i }); + await user.click(cancelBtn); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(api.delete).not.toHaveBeenCalled(); + expect(screen.getByText('What is ATP?')).toBeInTheDocument(); + }); + + it('deletes question when confirmed and removes it from the UI', async () => { + const user = renderBuilder(); + await screen.findByText('What is ATP?'); + + await user.click(screen.getByRole('button', { name: /delete/i })); + + const dialog = screen.getByRole('dialog'); + const confirmDeleteBtn = within(dialog).getByRole('button', { name: /^delete$/i }); + await user.click(confirmDeleteBtn); + + await waitFor(() => + expect(api.delete).toHaveBeenCalledWith('/quizzes/quiz-1/questions/q1') + ); + + // Modal closed and question is gone + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('What is ATP?')).not.toBeInTheDocument(); + }); + + // Zero questions state is shown + expect(screen.getByText('No questions added yet.')).toBeInTheDocument(); + }); + + it('shows error and keeps question when deletion fails', async () => { + vi.mocked(api.delete).mockRejectedValueOnce({ + response: { data: { error: 'Failed to delete question' } }, + }); + + const user = renderBuilder(); + await screen.findByText('What is ATP?'); + + await user.click(screen.getByRole('button', { name: /delete/i })); + + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: /^delete$/i })); + + // Error message displayed + await screen.findByText('Failed to delete question'); + + // Question is still in the document + expect(screen.getByText('What is ATP?')).toBeInTheDocument(); + }); + + it('deletes the correct question when multiple questions exist', async () => { + const multiQuiz = { + ...quiz, + questions: [ + { id: 'q1', text: 'First question', timeLimit: 15, difficulty: 'Low', answers }, + { id: 'q2', text: 'Second question', timeLimit: 20, difficulty: 'High', answers }, + ], + }; + vi.mocked(api.get).mockResolvedValueOnce({ data: multiQuiz }); + + const user = renderBuilder(); + await screen.findByText('First question'); + await screen.findByText('Second question'); + + // Find the delete button on the second question card + const secondCard = screen.getByText('Second question').closest('div.group') as HTMLElement; + const deleteSecondBtn = within(secondCard).getByRole('button', { name: /delete/i }); + await user.click(deleteSecondBtn); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText(/"Second question"/i)).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: /^delete$/i })); + + await waitFor(() => + expect(api.delete).toHaveBeenCalledWith('/quizzes/quiz-1/questions/q2') + ); + + // Second question removed, first question remains + await waitFor(() => { + expect(screen.queryByText('Second question')).not.toBeInTheDocument(); + }); + expect(screen.getByText('First question')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/screens/QuizBuilder.tsx b/apps/frontend/src/screens/QuizBuilder.tsx index 159eaec..073f54c 100644 --- a/apps/frontend/src/screens/QuizBuilder.tsx +++ b/apps/frontend/src/screens/QuizBuilder.tsx @@ -12,8 +12,17 @@ import { DIFFICULTY_STYLES } from "../components/ai/difficulty" const DIFFICULTIES: Difficulty[] = ['Low', 'Medium', 'High']; +interface QuizQuestion { + id: string; + text: string; + timeLimit: number; + difficulty?: Difficulty; + answers: Array<{ id: string; text: string; isCorrect: boolean }>; +} + function QuizBuilder() { + const { id } = useParams(); @@ -34,6 +43,10 @@ function QuizBuilder() { const [inviteEmail, setInviteEmail] = useState(''); const [inviteMsg, setInviteMsg] = useState(''); + const [questionToDelete, setQuestionToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(''); + @@ -144,6 +157,32 @@ function QuizBuilder() { } }; + const handleDeleteQuestion = async () => { + if (!questionToDelete || deleting) return; + try { + setDeleting(true); + setDeleteError(''); + await api.delete(`/quizzes/${id}/questions/${questionToDelete.id}`); + setQuiz((prev: { questions?: QuizQuestion[]; [key: string]: unknown } | null) => ({ + ...prev, + questions: prev?.questions ? prev.questions.filter((q) => q.id !== questionToDelete.id) : [] + })); + setQuestionToDelete(null); + } catch (err: unknown) { + console.error('Failed to delete question', err); + let errorMsg = 'Failed to delete question'; + if (err && typeof err === 'object' && 'response' in err) { + const resp = (err as { response?: { data?: { error?: string } } }).response; + if (resp?.data?.error) { + errorMsg = resp.data.error; + } + } + setDeleteError(errorMsg); + } finally { + setDeleting(false); + } + }; + return (
@@ -289,7 +328,18 @@ function QuizBuilder() { {q.difficulty} )} {q.timeLimit}s - +
@@ -307,6 +357,56 @@ function QuizBuilder() {
)} + + {questionToDelete && ( +
+
+

+ Delete Question +

+

+ Are you sure you want to delete this question? This action cannot be undone. +

+
+ "{questionToDelete.text}" +
+ {deleteError && ( +

+ {deleteError} +

+ )} +
+ + +
+
+
+ )} {/* Sidebar */} diff --git a/apps/http-server/src/controllers/quizController.ts b/apps/http-server/src/controllers/quizController.ts index 624b486..93a1f9b 100644 --- a/apps/http-server/src/controllers/quizController.ts +++ b/apps/http-server/src/controllers/quizController.ts @@ -328,4 +328,64 @@ export const startQuizSessionController = async (req: Request, res: Response) => return errorResponse(res, 500, "Internal Server Error") } -} \ No newline at end of file +} + + + + + + +// deleting question from a quiz +export const deleteQuestionController = async (req: Request, res: Response) => { + try { + const { quizId, questionId } = req.params; + + if (!questionId || typeof questionId !== 'string' || !questionId.trim()) { + return errorResponse(res, 400, "Question ID is required"); + } + + const question = await prisma.question.findUnique({ + where: { id: questionId as string }, + include: { + quiz: { + include: { + quizOrganizers: { + where: { + userId: req.userId, + inviteStatus: 'ACCEPTED' + } + } + } + } + } + }); + + if (!question) { + return errorResponse(res, 404, "Question not found"); + } + + if (quizId && question.quizId !== quizId) { + return errorResponse(res, 404, "Question not found in this quiz"); + } + + const isOrganizer = question.quiz.quizOrganizers.length > 0; + const isCreator = question.quiz.creatorId === req.userId; + + if (!isOrganizer && !isCreator) { + return errorResponse(res, 403, "You don't have access to this quiz"); + } + + await prisma.question.delete({ + where: { id: questionId as string } + }); + + return res.status(200).json({ + success: true, + message: "Question deleted successfully" + }); + + } catch (error) { + console.log("Delete question error: ", error); + return errorResponse(res, 500, "Internal Server Error"); + } +} diff --git a/apps/http-server/src/routes/quiz.ts b/apps/http-server/src/routes/quiz.ts index 1861fee..3517e70 100644 --- a/apps/http-server/src/routes/quiz.ts +++ b/apps/http-server/src/routes/quiz.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { authMiddleware } from "../middlewares/authmiddleware"; -import { createQuestionController, createQuizController, generateAccessCodeController, getAllQuizesController, getQuizByIdController, startQuizSessionController } from "../controllers/quizController"; +import { createQuestionController, createQuizController, deleteQuestionController, generateAccessCodeController, getAllQuizesController, getQuizByIdController, startQuizSessionController } from "../controllers/quizController"; const router: Router = Router(); @@ -24,6 +24,11 @@ router.post("/:quizId/generate-access-code", authMiddleware, generateAccessCodeC router.post("/:quizId/questions", authMiddleware, createQuestionController) +// deleting questions +router.delete("/:quizId/questions/:questionId", authMiddleware, deleteQuestionController) +router.delete("/questions/:questionId", authMiddleware, deleteQuestionController) + +