Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion apps/frontend/src/screens/QuizBuilder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
});
102 changes: 101 additions & 1 deletion apps/frontend/src/screens/QuizBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@
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();


Expand All @@ -34,6 +43,10 @@
const [inviteEmail, setInviteEmail] = useState('');
const [inviteMsg, setInviteMsg] = useState('');

const [questionToDelete, setQuestionToDelete] = useState<QuizQuestion | null>(null);
const [deleting, setDeleting] = useState<boolean>(false);
const [deleteError, setDeleteError] = useState<string>('');




Expand All @@ -41,7 +54,7 @@
if (id) {
fetchQuiz();
}
}, [id]);

Check warning on line 57 in apps/frontend/src/screens/QuizBuilder.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

React Hook useEffect has a missing dependency: 'fetchQuiz'. Either include it or remove the dependency array


const fetchQuiz = async () => {
Expand Down Expand Up @@ -144,6 +157,32 @@
}
};

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 (
<div className="min-h-screen w-full bg-[#000000]/98 opacity-99">
Expand Down Expand Up @@ -289,7 +328,18 @@
<span className={`text-xs px-2 py-1 rounded border font-medium ${DIFFICULTY_STYLES[q.difficulty as Difficulty] ?? DIFFICULTY_STYLES.Medium}`}>{q.difficulty}</span>
)}
<span className="bg-green-600/10 border border-white/10 px-4 py-1 font-semibold tracking-wide rounded text-xs text-zinc-400">{q.timeLimit}s</span>
<button className="text-red-400 font-semibold font-main tracking-wider rounded-md border border-rose-700/20 cursor-pointer bg-rose-600/20 hover:bg-rose-600/30 active:scale-95 hover:text-red-400 transition-colors py-1 px-4 flex items-center gap-1"><BsTrash2 className="w-4 h-4" />Delete</button>
<button
type="button"
onClick={() => {
setDeleteError('');
setQuestionToDelete(q);
}}
disabled={deleting && questionToDelete?.id === q.id}
className="text-red-400 font-semibold font-main tracking-wider rounded-md border border-rose-700/20 cursor-pointer bg-rose-600/20 hover:bg-rose-600/30 active:scale-95 hover:text-red-400 transition-colors py-1 px-4 flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
<BsTrash2 className="w-4 h-4" />
Delete
</button>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-6">
Expand All @@ -307,6 +357,56 @@
</div>
)}
</div>

{questionToDelete && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-dialog-title"
className="fixed inset-0 bg-black/60 backdrop-blur-xs flex items-center justify-center z-50 p-4"
>
<div className="bg-zinc-900 border border-white/10 rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl">
<h3 id="delete-dialog-title" className="text-xl font-bold font-secondary text-gray-200 mb-2">
Delete Question
</h3>
<p className="text-zinc-400 text-sm mb-4 font-main">
Are you sure you want to delete this question? This action cannot be undone.
</p>
<div className="bg-black/40 border border-white/5 rounded-lg p-3 mb-6 text-sm text-gray-300 font-secondary line-clamp-2">
"{questionToDelete.text}"
</div>
{deleteError && (
<p className="text-sm text-rose-400 mb-4 font-secondary font-medium" role="alert">
{deleteError}
</p>
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => {
if (!deleting) {
setQuestionToDelete(null);
setDeleteError('');
}
}}
disabled={deleting}
className="px-4 py-2 text-sm text-zinc-400 hover:text-zinc-200 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="button"
onClick={handleDeleteQuestion}
disabled={deleting}
className="px-4 py-2 text-sm text-red-300 hover:text-red-200 bg-rose-600/20 hover:bg-rose-600/30 border border-rose-600/40 rounded-xl font-semibold transition-colors cursor-pointer active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<BsTrash2 className="w-4 h-4" />
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>

{/* Sidebar */}
Expand Down
62 changes: 61 additions & 1 deletion apps/http-server/src/controllers/quizController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,64 @@ export const startQuizSessionController = async (req: Request, res: Response) =>
return errorResponse(res, 500, "Internal Server Error")

}
}
}






// 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");
}
}
7 changes: 6 additions & 1 deletion apps/http-server/src/routes/quiz.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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)





Expand Down
Loading