diff --git a/src/api/questionsApi.ts b/src/api/questionsApi.ts index e0a67f2..a6227b8 100644 --- a/src/api/questionsApi.ts +++ b/src/api/questionsApi.ts @@ -1,3 +1,4 @@ +import type { Variant } from "../components/shared/types/QuestionTypes"; import axiosInstance from "./axiosInstance"; type GetQuestionsParams = { @@ -5,14 +6,33 @@ type GetQuestionsParams = { id?: number; }; +export type QuestionPayload = { + title: string; + description?: string; + type: "single" | "multiple" | "text"; + difficulty: number; + variants: Variant[]; + correct_answers: number[] | string[]; + category_id: number; +}; + +export type OpenaiPayload = { + type: "single" | "multiple" | "text"; + category_id: number; + language: string; + difficulty: number; + promt: string; +}; + +type OpenaiGenerateResponse = { + question: QuestionPayload; +}; + export const getQuestions = async ({ page = 1, id }: GetQuestionsParams) => { const params: Record = { page }; if (id) params.category_id = id; - const { data } = await axiosInstance.get( - "/api/questions", - { params } - ); + const { data } = await axiosInstance.get("/api/questions", { params }); return data; }; @@ -21,3 +41,29 @@ export const getQuestionById = async (id: number) => { const response = await axiosInstance.get(`/api/questions/${id}`); return response.data.data; }; + +export const createQuestion = async (payload: QuestionPayload) => { + const res = await axiosInstance.post("/api/questions", payload); + return res.data; +}; + +export const updateQuestion = async (id: number, payload: QuestionPayload) => { + const res = await axiosInstance.put(`/api/questions/${id}`, payload); + return res.data; +}; + +export const deleteQuestion = async (id: number) => { + const res = await axiosInstance.delete(`/api/questions/${id}`); + return res.data; +}; + +export const openaiGenerateQuestion = async ( + payload: OpenaiPayload +): Promise => { + const res = await axiosInstance.post( + '/api/questions/openai-generate', + payload + ); + + return res.data.question; +}; diff --git a/src/api/testApi.ts b/src/api/testApi.ts index f770b88..9749350 100644 --- a/src/api/testApi.ts +++ b/src/api/testApi.ts @@ -31,6 +31,14 @@ export type UserTestsResponse = { }; }; +export type TestPayload = { + title: string; + description: string; + closed_at: string | null; + category_id: number; + questions: number[]; +}; + export const startTest = async (data: StartTestPayload) => { const res = await axiosInstance.post<{ data: UserTestType }>( "/api/user-tests", @@ -104,3 +112,18 @@ export const deleteUserTest = async (id: number) => { const res = await axiosInstance.delete(`/api/user-tests/${id}`); return res.data; }; + +export const createTest = async (payload: TestPayload) => { + const res = await axiosInstance.post("/api/tests", payload); + return res.data; +}; + +export const updateTest = async (id: number, payload: TestPayload) => { + const res = await axiosInstance.put(`/api/tests/${id}`, payload); + return res.data; +}; + +export const deleteTest = async (id: number) => { + const res = await axiosInstance.delete(`/api/tests/${id}`); + return res.data; +}; diff --git a/src/components/GenerateQuestionModal/GenerateQuestionModal.tsx b/src/components/GenerateQuestionModal/GenerateQuestionModal.tsx new file mode 100644 index 0000000..4c9e34e --- /dev/null +++ b/src/components/GenerateQuestionModal/GenerateQuestionModal.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + MenuItem, + CircularProgress, +} from "@mui/material"; + +import { useOpenaiGenerateQuestion } from "../../hooks/questions/useOpenaiGenerateQuestion"; +import type { QuestionPayload } from "../../api/questionsApi"; + +type Props = { + open: boolean; + onClose: () => void; + onGenerated: (question: QuestionPayload) => void; + +}; + +const GenerateQuestionModal = ({ open, onClose, onGenerated }: Props) => { + const generateMutation = useOpenaiGenerateQuestion(); + + const [type, setType] = useState<"single" | "multiple" | "text">("single"); + const [categoryId, setCategoryId] = useState(""); + const [language, setLanguage] = useState<"en" | "sr" | "hu" | "ru">("en"); + const [difficulty, setDifficulty] = useState(5); + const [prompt, setPrompt] = useState(""); + + const handleGenerate = () => { + if (!categoryId || !prompt.trim()) return; + + generateMutation.mutate( + { + type, + category_id: Number(categoryId), + language, + difficulty, + promt: prompt, + }, + { + onSuccess: (data) => { + onGenerated(data); + onClose(); + }, + } + ); + }; + + return ( + + Generate Question (AI) + + + + setType(e.target.value as "single" | "multiple" | "text") + } + fullWidth + sx={{ mb: 2 }} + > + Single + Multiple + Text + + + setCategoryId(Number(e.target.value))} + fullWidth + sx={{ mb: 2 }} + /> + + + setLanguage(e.target.value as "en" | "sr" | "hu" | "ru") + } + fullWidth + sx={{ mb: 2 }} + > + English + Serbian + Hungarian + Russian + + + setDifficulty(Number(e.target.value))} + fullWidth + sx={{ mb: 2 }} + /> + + setPrompt(e.target.value)} + fullWidth + multiline + rows={3} + /> + + + + + + + + ); +}; + +export default GenerateQuestionModal; diff --git a/src/components/StartTestForm/StartTestForm.tsx b/src/components/StartTestForm/StartTestForm.tsx index 6feb232..dd41b91 100644 --- a/src/components/StartTestForm/StartTestForm.tsx +++ b/src/components/StartTestForm/StartTestForm.tsx @@ -11,7 +11,7 @@ import { StartTestWrapper } from "./StartTestForm.styles"; import { useState } from "react"; import { useCategories } from "../../hooks/categories/useCategories"; import type { Category } from "../shared/types/QuestionTypes"; -import { useStartTest } from "../../hooks/Tests/useStartTest"; +import { useStartTest } from "../../hooks/tests/useStartTest"; import { toast } from "react-toastify"; const StartTestForm = () => { diff --git a/src/components/TestCard/TestCard.tsx b/src/components/TestCard/TestCard.tsx index 4dfa9bc..5198ed3 100644 --- a/src/components/TestCard/TestCard.tsx +++ b/src/components/TestCard/TestCard.tsx @@ -2,7 +2,7 @@ import { Box, Button, Chip, Typography } from "@mui/material"; import { Link } from "react-router-dom"; import { formatDate } from "../../utils/functions"; import type { UserTestType } from "../shared/types/TestTypes"; -import { useGetTestById } from "../../hooks/Tests/useGetTestById"; +import { useGetTestById } from "../../hooks/tests/useGetTestById"; interface TestCardProps { test: UserTestType; diff --git a/src/components/TestListCard/TestListCard.tsx b/src/components/TestListCard/TestListCard.tsx index f57b850..77612d8 100644 --- a/src/components/TestListCard/TestListCard.tsx +++ b/src/components/TestListCard/TestListCard.tsx @@ -1,7 +1,7 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material"; import CategoryIcon from "@mui/icons-material/Category"; import type { TestType } from "../shared/types/TestTypes"; -import { useStartTestById } from "../../hooks/Tests/useStartTestById"; +import { useStartTestById } from "../../hooks/tests/useStartTestById"; import { useNavigate } from "react-router-dom"; interface TestCardProps { diff --git a/src/components/UserTestRow/UserTestRow.tsx b/src/components/UserTestRow/UserTestRow.tsx index a21af0d..9ef318e 100644 --- a/src/components/UserTestRow/UserTestRow.tsx +++ b/src/components/UserTestRow/UserTestRow.tsx @@ -1,7 +1,7 @@ import { TableRow, TableCell, Button } from "@mui/material"; import { useNavigate } from "react-router-dom"; import type { UserTestType } from "../../components/shared/types/TestTypes"; -import { useGetTestById } from "../../hooks/Tests/useGetTestById"; +import { useGetTestById } from "../../hooks/tests/useGetTestById"; import { formatDate } from "../../utils/functions"; type Props = { diff --git a/src/hooks/questions/useCreateQuestion.ts b/src/hooks/questions/useCreateQuestion.ts new file mode 100644 index 0000000..211fd48 --- /dev/null +++ b/src/hooks/questions/useCreateQuestion.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createQuestion, type QuestionPayload } from "../../api/questionsApi"; +import { toast } from "react-toastify"; + +export const useCreateQuestion = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: QuestionPayload) => createQuestion(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["questions"] }); + toast.success("Question Created"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/questions/useDeleteQuestion.ts b/src/hooks/questions/useDeleteQuestion.ts new file mode 100644 index 0000000..7939a6c --- /dev/null +++ b/src/hooks/questions/useDeleteQuestion.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteQuestion } from "../../api/questionsApi"; +import { toast } from "react-toastify"; + +export const useDeleteQuestion = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => deleteQuestion(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["questions"] }); + toast.success("Question Deleted"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/questions/useOpenaiGenerateQuestion.ts b/src/hooks/questions/useOpenaiGenerateQuestion.ts new file mode 100644 index 0000000..e1ca47b --- /dev/null +++ b/src/hooks/questions/useOpenaiGenerateQuestion.ts @@ -0,0 +1,10 @@ +import { useMutation } from "@tanstack/react-query"; +import { openaiGenerateQuestion } from "../../api/questionsApi"; +import type { OpenaiPayload } from "../../api/questionsApi"; +import type { QuestionPayload } from "../../api/questionsApi"; + +export const useOpenaiGenerateQuestion = () => { + return useMutation({ + mutationFn: openaiGenerateQuestion, + }); +}; diff --git a/src/hooks/Question/useQuestionById.ts b/src/hooks/questions/useQuestionById.ts similarity index 76% rename from src/hooks/Question/useQuestionById.ts rename to src/hooks/questions/useQuestionById.ts index bac39ff..8cc0bd1 100644 --- a/src/hooks/Question/useQuestionById.ts +++ b/src/hooks/questions/useQuestionById.ts @@ -4,6 +4,7 @@ import { getQuestionById } from "../../api/questionsApi"; export const useQuestionById = (id?: number) => { return useQuery({ queryKey: ["single-question", id], - queryFn: () => getQuestionById(Number(id)), + queryFn: () => getQuestionById(Number(id!)), + enabled: !!id, }); }; diff --git a/src/hooks/Question/useQuestions.ts b/src/hooks/questions/useQuestions.ts similarity index 100% rename from src/hooks/Question/useQuestions.ts rename to src/hooks/questions/useQuestions.ts diff --git a/src/hooks/questions/useUpdateQuestion.ts b/src/hooks/questions/useUpdateQuestion.ts new file mode 100644 index 0000000..90079ff --- /dev/null +++ b/src/hooks/questions/useUpdateQuestion.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateQuestion, type QuestionPayload } from "../../api/questionsApi"; +import { toast } from "react-toastify"; + +export const useUpdateQuestion = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, payload }: { id: number; payload: QuestionPayload }) => + updateQuestion(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["questions"] }); + toast.success("Question Updated"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/Tests/useCompleteTest.ts b/src/hooks/tests/useCompleteTest.ts similarity index 100% rename from src/hooks/Tests/useCompleteTest.ts rename to src/hooks/tests/useCompleteTest.ts diff --git a/src/hooks/tests/useCreateTest.ts b/src/hooks/tests/useCreateTest.ts new file mode 100644 index 0000000..a5474b5 --- /dev/null +++ b/src/hooks/tests/useCreateTest.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createTest } from "../../api/testApi"; +import { toast } from "react-toastify"; + +export const useCreateTest = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createTest, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tests"] }); + toast.success("Test Created"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/tests/useDeleteTest.ts b/src/hooks/tests/useDeleteTest.ts new file mode 100644 index 0000000..58871c9 --- /dev/null +++ b/src/hooks/tests/useDeleteTest.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteTest } from "../../api/testApi"; +import { toast } from "react-toastify"; + +export const useDeleteTest = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => deleteTest(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tests"] }); + toast.success("Test deleted"); + }, + onError: (error) => { + toast.error(error.message ?? "Failed to delete test"); + }, + }); +}; diff --git a/src/hooks/Tests/useDeteleUserTest.ts b/src/hooks/tests/useDeteleUserTest.ts similarity index 100% rename from src/hooks/Tests/useDeteleUserTest.ts rename to src/hooks/tests/useDeteleUserTest.ts diff --git a/src/hooks/Tests/useGetAllUserTests.ts b/src/hooks/tests/useGetAllUserTests.ts similarity index 100% rename from src/hooks/Tests/useGetAllUserTests.ts rename to src/hooks/tests/useGetAllUserTests.ts diff --git a/src/hooks/Tests/useGetTestById.ts b/src/hooks/tests/useGetTestById.ts similarity index 69% rename from src/hooks/Tests/useGetTestById.ts rename to src/hooks/tests/useGetTestById.ts index 6a85ce1..5e5e66a 100644 --- a/src/hooks/Tests/useGetTestById.ts +++ b/src/hooks/tests/useGetTestById.ts @@ -2,9 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { getTestById } from "../../api/testApi"; import type { TestType } from "../../components/shared/types/TestTypes"; -export const useGetTestById = (id: number) => { +export const useGetTestById = (id?: number) => { return useQuery({ queryKey: ["tests", id], - queryFn: () => getTestById(id), + queryFn: () => getTestById(id!), + enabled: !!id, }); }; diff --git a/src/hooks/Tests/useGetTests.ts b/src/hooks/tests/useGetTests.ts similarity index 100% rename from src/hooks/Tests/useGetTests.ts rename to src/hooks/tests/useGetTests.ts diff --git a/src/hooks/Tests/useGetUserTests.ts b/src/hooks/tests/useGetUserTests.ts similarity index 100% rename from src/hooks/Tests/useGetUserTests.ts rename to src/hooks/tests/useGetUserTests.ts diff --git a/src/hooks/Tests/useStartTest.ts b/src/hooks/tests/useStartTest.ts similarity index 100% rename from src/hooks/Tests/useStartTest.ts rename to src/hooks/tests/useStartTest.ts diff --git a/src/hooks/Tests/useStartTestById.ts b/src/hooks/tests/useStartTestById.ts similarity index 100% rename from src/hooks/Tests/useStartTestById.ts rename to src/hooks/tests/useStartTestById.ts diff --git a/src/hooks/Tests/useSubmitAnswer.ts b/src/hooks/tests/useSubmitAnswer.ts similarity index 100% rename from src/hooks/Tests/useSubmitAnswer.ts rename to src/hooks/tests/useSubmitAnswer.ts diff --git a/src/hooks/tests/useUpdateTest.ts b/src/hooks/tests/useUpdateTest.ts new file mode 100644 index 0000000..4d06906 --- /dev/null +++ b/src/hooks/tests/useUpdateTest.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateTest, type TestPayload } from "../../api/testApi"; + +import { toast } from "react-toastify"; + +export const useUpdateTest = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, payload }: { id: number; payload: TestPayload }) => + updateTest(id, payload), + + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tests"] }); + queryClient.invalidateQueries({ queryKey: ["tests", variables.id] }); + toast.success("Test updated"); + }, + + onError: (error: any) => { + toast.error(error.message ?? "Failed to update test"); + }, + }); +}; diff --git a/src/hooks/Tests/useUserTestById.ts b/src/hooks/tests/useUserTestById.ts similarity index 100% rename from src/hooks/Tests/useUserTestById.ts rename to src/hooks/tests/useUserTestById.ts diff --git a/src/pages/AdminTestsPage/AdminTestsPage.tsx b/src/pages/AdminTestsPage/AdminTestsPage.tsx new file mode 100644 index 0000000..3fe6371 --- /dev/null +++ b/src/pages/AdminTestsPage/AdminTestsPage.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { + Box, + Button, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Pagination, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; + +import Container from "../../components/shared/Container"; + +import type { TestType } from "../../components/shared/types/TestTypes"; +import { useGetTests } from "../../hooks/tests/useGetTests"; +import { useDeleteTest } from "../../hooks/tests/useDeleteTest"; +import { formatDate } from "../../utils/functions"; + +const AdminTestsPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const [deleteTestId, setDeleteTestId] = useState(null); + + const { data, isLoading, isError } = useGetTests({ page: currentPage }); + const deleteMutation = useDeleteTest(); + const navigate = useNavigate(); + + const handleViewTest = (id: number) => { + navigate(`/tests/view/${id}`); + }; + + const handleUpdateTest = (id: number) => { + navigate(`/dashboard/tests/${id}/update`); + }; + + const handleDeleteTest = () => { + if (deleteTestId !== null) { + deleteMutation.mutate(deleteTestId, { + onSuccess: () => setDeleteTestId(null), + }); + } + }; + + if (isLoading) return ; + if (isError) + return Error loading tests.; + + return ( + + + Tests + + + + + + + + ID + Title + Category + Author + Available + Created At + Closed At + Actions + + + + + {data?.data.map((test: TestType) => ( + + {test.id} + {test.title} + {test.category?.name} + {test.author?.username} + {test.is_available ? "Yes" : "No"} + {formatDate(test.created_at)} + {formatDate(test.closed_at)} + + + + + + + ))} + +
+
+ + setCurrentPage(value)} + sx={{ mt: 3, display: "flex", justifyContent: "center" }} + /> + + setDeleteTestId(null)} + > + Confirm Delete + + Are you sure you want to delete this test? + + + + + + +
+ ); +}; + +export default AdminTestsPage; diff --git a/src/pages/AdminUserTestsPage/AdminUserTestsPage.tsx b/src/pages/AdminUserTestsPage/AdminUserTestsPage.tsx index 5172f34..8a72084 100644 --- a/src/pages/AdminUserTestsPage/AdminUserTestsPage.tsx +++ b/src/pages/AdminUserTestsPage/AdminUserTestsPage.tsx @@ -19,8 +19,8 @@ import { } from "@mui/material"; import Container from "../../components/shared/Container"; -import { useGetAllUserTests } from "../../hooks/Tests/useGetAllUserTests"; -import { useDeleteUserTest } from "../../hooks/Tests/useDeteleUserTest"; +import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests"; +import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest"; import UserTestRow from "../../components/UserTestRow/UserTestRow"; diff --git a/src/pages/IndexPage/IndexPage.tsx b/src/pages/IndexPage/IndexPage.tsx index a5463ee..27dfd6a 100644 --- a/src/pages/IndexPage/IndexPage.tsx +++ b/src/pages/IndexPage/IndexPage.tsx @@ -12,7 +12,7 @@ import Container from "../../components/shared/Container"; import { WelcomeContainer } from "./IndexPage.styles"; import StartTestForm from "../../components/StartTestForm/StartTestForm"; import Question from "../../components/Question/Question"; -import { useQuestions } from "../../hooks/Question/useQuestions"; +import { useQuestions } from "../../hooks/questions/useQuestions"; import type { QuestionType } from "../../components/shared/types/QuestionTypes"; import { useAuth } from "../../context/AuthContext"; import { useCategories } from "../../hooks/categories/useCategories"; diff --git a/src/pages/ProfilePage/Profilepage.tsx b/src/pages/ProfilePage/Profilepage.tsx index cd7436a..3d24134 100644 --- a/src/pages/ProfilePage/Profilepage.tsx +++ b/src/pages/ProfilePage/Profilepage.tsx @@ -2,7 +2,7 @@ import { Box, Button, Tab, Tabs, Typography } from "@mui/material"; import { useAuth } from "../../context/AuthContext"; import Container from "../../components/shared/Container"; import { useNavigate } from "react-router-dom"; -import { useGetUserTests } from "../../hooks/Tests/useGetUserTests"; +import { useGetUserTests } from "../../hooks/tests/useGetUserTests"; import TestCard from "../../components/TestCard/TestCard"; import { useState } from "react"; diff --git a/src/pages/QuestionForm/QuestionForm.tsx b/src/pages/QuestionForm/QuestionForm.tsx new file mode 100644 index 0000000..c83244b --- /dev/null +++ b/src/pages/QuestionForm/QuestionForm.tsx @@ -0,0 +1,285 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + CircularProgress, + MenuItem, + TextField, + Typography, + IconButton, + FormControlLabel, + Checkbox, + Paper, +} from "@mui/material"; +import { Add, Remove } from "@mui/icons-material"; + +import { useCategories } from "../../hooks/categories/useCategories"; +import { useCreateQuestion } from "../../hooks/questions/useCreateQuestion"; +import { useUpdateQuestion } from "../../hooks/questions/useUpdateQuestion"; + +import type { QuestionPayload } from "../../api/questionsApi"; +import type { Variant } from "../../components/shared/types/QuestionTypes"; +import Container from "../../components/shared/Container"; +import { useQuestionById } from "../../hooks/questions/useQuestionById"; +import GenerateQuestionModal from "../../components/GenerateQuestionModal/GenerateQuestionModal"; + +const QuestionForm = () => { + const { id } = useParams<{ id: string }>(); + const isUpdate = Boolean(id); + const navigate = useNavigate(); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [type, setType] = useState<"single" | "multiple" | "text">("single"); + const [difficulty, setDifficulty] = useState(1); + const [categoryId, setCategoryId] = useState(""); + const [variants, setVariants] = useState([{ id: 1, text: "" }]); + const [correctAnswers, setCorrectAnswers] = useState([]); + const [textAnswer, setTextAnswer] = useState(""); + const [openAiModal, setOpenAiModal] = useState(false); + const [generatedQuestion, setGeneratedQuestion] = + useState(null); + + const { data: categories } = useCategories(); + const { data: question, isLoading: isLoadingQuestion } = useQuestionById( + isUpdate ? Number(id) : undefined + ); + + const createMutation = useCreateQuestion(); + const updateMutation = useUpdateQuestion(); + + useEffect(() => { + if (question || generatedQuestion) { + const q = question ?? generatedQuestion; + console.log(q); + setTitle(q.title); + setDescription(q.description ?? ""); + setType(q.type); + setDifficulty(q.difficulty); + setCategoryId(q.category_id); + if (q.type === "text") { + setTextAnswer(q.correct_answers[0] as string); + } else { + setVariants(q.variants.length ? q.variants : [{ id: 1, text: "" }]); + + setCorrectAnswers( + q.correct_answers.map((i: number) => + typeof i === "number" ? i - 1 : 0 + ) + ); + } + } + }, [question, generatedQuestion]); + + if (isUpdate && isLoadingQuestion) return ; + + const handleVariantChange = (index: number, value: string) => { + const newVariants = [...variants]; + newVariants[index].text = value; + setVariants(newVariants); + }; + + const addVariant = () => { + setVariants([...variants, { id: variants.length + 1, text: "" }]); + }; + + const removeVariant = (index: number) => { + const newVariants = variants.filter((_, i) => i !== index); + setVariants(newVariants); + setCorrectAnswers((prev) => prev.filter((i) => i !== index)); + }; + + const toggleCorrectAnswer = (index: number) => { + if (type === "single") { + setCorrectAnswers([index]); + } else { + setCorrectAnswers((prev) => + prev.includes(index) + ? prev.filter((i) => i !== index) + : [...prev, index] + ); + } + }; + + const validate = () => { + if (!title || !categoryId) return false; + if (type !== "text") { + if (variants.length < 2) return false; + if (correctAnswers.length === 0) return false; + if (type === "single" && correctAnswers.length !== 1) return false; + } else { + if (!textAnswer.trim()) return false; + } + if (difficulty < 1 || difficulty > 10) return false; + return true; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + const payload: QuestionPayload = { + title, + description, + type, + difficulty, + category_id: Number(categoryId), + variants: type === "text" ? [] : variants, + correct_answers: + type === "text" ? [textAnswer] : correctAnswers.map((i) => i + 1), + }; + + if (isUpdate && id) { + updateMutation.mutate( + { id: Number(id), payload }, + { onSuccess: () => navigate("/dashboard/questions") } + ); + } else { + createMutation.mutate(payload, { + onSuccess: () => navigate("/dashboard/questions"), + }); + } + }; + + return ( + + + {isUpdate ? "Update Question" : "Create Question"} + + +
+ setTitle(e.target.value)} + fullWidth + required + sx={{ mb: 2 }} + /> + + setDescription(e.target.value)} + fullWidth + multiline + rows={2} + sx={{ mb: 2 }} + /> + + + setType(e.target.value as "single" | "multiple" | "text") + } + fullWidth + sx={{ mb: 2 }} + > + Single Choice + Multiple Choice + Text Answer + + + setDifficulty(Number(e.target.value))} + fullWidth + sx={{ mb: 2 }} + /> + + setCategoryId(Number(e.target.value))} + fullWidth + required + sx={{ mb: 3 }} + > + {categories?.map((cat) => ( + + {cat.name} + + ))} + + + {type === "text" ? ( + setTextAnswer(e.target.value)} + fullWidth + required + sx={{ mb: 2 }} + /> + ) : ( + + + Variants & Correct Answer + + {variants.map((v, i) => ( + + handleVariantChange(i, e.target.value)} + fullWidth + required + /> + toggleCorrectAnswer(i)} + /> + } + label="Correct" + sx={{ ml: 1 }} + /> + removeVariant(i)}> + + + + ))} + + + )} + + + {!isUpdate && ( + + )} + + setOpenAiModal(false)} + onGenerated={(q) => setGeneratedQuestion(q)} + /> +
+ ); +}; + +export default QuestionForm; diff --git a/src/pages/QuestionsPage/QuestionsPage.tsx b/src/pages/QuestionsPage/QuestionsPage.tsx new file mode 100644 index 0000000..ddbaaf5 --- /dev/null +++ b/src/pages/QuestionsPage/QuestionsPage.tsx @@ -0,0 +1,161 @@ +import { useState } from "react"; +import { + Box, + Button, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Pagination, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { useQuestions } from "../../hooks/questions/useQuestions"; +import { useDeleteQuestion } from "../../hooks/questions/useDeleteQuestion"; +import Container from "../../components/shared/Container"; +import { useNavigate } from "react-router-dom"; +import type { QuestionType } from "../../components/shared/types/QuestionTypes"; + +const QuestionsPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const [deleteQuestionId, setDeleteQuestionId] = useState(null); + const { data, isLoading, isError } = useQuestions({ page: currentPage }); + const deleteMutation = useDeleteQuestion(); + const navigate = useNavigate(); + + const handleCreateQuestion = () => { + navigate("/dashboard/questions/create"); + }; + + const handleUpdateQuestion = (id: number) => { + navigate(`/dashboard/questions/${id}/update`); + }; + + const handleViewQuestion = (id: number) => { + navigate(`/questions/${id}`); + }; + + const handleDeleteQuestion = () => { + if (deleteQuestionId !== null) { + deleteMutation.mutate(deleteQuestionId, { + onSuccess: () => setDeleteQuestionId(null), + }); + } + }; + + if (isLoading) return ; + if (isError) + return Error loading questions.; + + return ( + + + Questions + + + + + + + + ID + Title + Type + Difficulty + Category + Author + Actions + + + + + {data?.data.map((question: QuestionType) => ( + + {question.id} + {question.title} + {question.type} + {question.difficulty} + {question.category?.name ?? "—"} + {question.author.username} + + + + + + + + + ))} + +
+
+ + setCurrentPage(value)} + sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }} + /> + + setDeleteQuestionId(null)} + > + Confirm Delete + + Are you sure you want to delete this question? + + + + + + +
+ ); +}; + +export default QuestionsPage; diff --git a/src/pages/SingleQuestionPage/SingleQuestionPage.tsx b/src/pages/SingleQuestionPage/SingleQuestionPage.tsx index 47a716f..eb0f4e4 100644 --- a/src/pages/SingleQuestionPage/SingleQuestionPage.tsx +++ b/src/pages/SingleQuestionPage/SingleQuestionPage.tsx @@ -1,6 +1,6 @@ import { Container } from "@mui/material"; import { useParams } from "react-router-dom"; -import { useQuestionById } from "../../hooks/Question/useQuestionById"; +import { useQuestionById } from "../../hooks/questions/useQuestionById"; import Question from "../../components/Question/Question"; import LearningAnswers from "../../components/Answers/Answers"; diff --git a/src/pages/SingleTestPage/SingleTestPage.tsx b/src/pages/SingleTestPage/SingleTestPage.tsx index 86422c2..354c2e3 100644 --- a/src/pages/SingleTestPage/SingleTestPage.tsx +++ b/src/pages/SingleTestPage/SingleTestPage.tsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetTestById } from "../../hooks/Tests/useGetTestById"; +import { useGetTestById } from "../../hooks/tests/useGetTestById"; import Container from "../../components/shared/Container"; import { Box, @@ -22,9 +22,9 @@ import LearningAnswers from "../../components/Answers/Answers"; import NotFoundPage from "../NotFoundPage/NotFoundPage"; import { useAuth } from "../../context/AuthContext"; import { useState } from "react"; -import { useGetAllUserTests } from "../../hooks/Tests/useGetAllUserTests"; +import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests"; import UserTestRow from "../../components/UserTestRow/UserTestRow"; -import { useDeleteUserTest } from "../../hooks/Tests/useDeteleUserTest"; +import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest"; export const SingleTestPage = () => { const { id } = useParams(); diff --git a/src/pages/TestForm/TestForm.tsx b/src/pages/TestForm/TestForm.tsx new file mode 100644 index 0000000..6205ff6 --- /dev/null +++ b/src/pages/TestForm/TestForm.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + CircularProgress, + FormControlLabel, + Checkbox, + MenuItem, + TextField, + Typography, + Pagination, +} from "@mui/material"; + +import { useCategories } from "../../hooks/categories/useCategories"; +import { useGetTestById } from "../../hooks/tests/useGetTestById"; +import { useQuestions } from "../../hooks/questions/useQuestions"; +import { useCreateTest } from "../../hooks/tests/useCreateTest"; +import { useUpdateTest } from "../../hooks/tests/useUpdateTest"; +import type { QuestionType } from "../../components/shared/types/QuestionTypes"; +import Container from "../../components/shared/Container"; + +const TestForm = () => { + const { id } = useParams<{ id: string }>(); + const isUpdate = Boolean(id); + const navigate = useNavigate(); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [closedAt, setClosedAt] = useState(""); + const [categoryId, setCategoryId] = useState(""); + const [selectedQuestions, setSelectedQuestions] = useState([]); + const [questionsPage, setQuestionsPage] = useState(1); + + const { data: test, isLoading: isLoadingTest } = useGetTestById( + isUpdate ? Number(id) : undefined + ); + + const { data: categories } = useCategories(); + const { data: questions, isLoading: isLoadingQuestions } = useQuestions({ + page: questionsPage, + id: categoryId || undefined, + }); + + const createMutation = useCreateTest(); + const updateMutation = useUpdateTest(); + + useEffect(() => { + if (test) { + setTitle(test.title); + setDescription(test.description ?? ""); + setClosedAt(test.closed_at); + setCategoryId(test.category_id); + setSelectedQuestions(test.questions?.map((q) => q.id) ?? []); + } + }, [test]); + + if (isUpdate && isLoadingTest) return ; + + const toggleQuestion = (id: number) => { + setSelectedQuestions((prev) => + prev.includes(id) ? prev.filter((q) => q !== id) : [...prev, id] + ); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const payload = { + title, + description, + closed_at: closedAt || null, + category_id: Number(categoryId), + questions: selectedQuestions, + }; + + if (isUpdate && id) { + updateMutation.mutate( + { id: Number(id), payload }, + { onSuccess: () => navigate("/dashboard/tests") } + ); + } else { + createMutation.mutate(payload, { + onSuccess: () => navigate("/dashboard/tests"), + }); + } + }; + + return ( + + + {isUpdate ? "Update Test" : "Create Test"} + + +
+ setTitle(e.target.value)} + fullWidth + required + sx={{ mb: 2 }} + /> + + setDescription(e.target.value)} + fullWidth + multiline + rows={3} + sx={{ mb: 2 }} + /> + + setClosedAt(e.target.value)} + fullWidth + InputLabelProps={{ shrink: true }} + sx={{ mb: 2 }} + /> + + { + setCategoryId(Number(e.target.value)); + setQuestionsPage(1); + setSelectedQuestions([]); + }} + fullWidth + required + sx={{ mb: 3 }} + > + {categories?.map((cat) => ( + + {cat.name} + + ))} + + + {categoryId && ( + <> + + Questions + + + {isLoadingQuestions ? ( + + ) : ( + questions?.data.map((q: QuestionType) => ( + toggleQuestion(q.id)} + /> + } + label={q.title} + /> + )) + )} + + + )} + {!categoryId && ( + + Please select category to be able to select questions + + )} + + setQuestionsPage(page)} + sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "start" }} + /> + + + +
+ ); +}; + +export default TestForm; diff --git a/src/pages/TestPage/TestPage.tsx b/src/pages/TestPage/TestPage.tsx index b6f3246..933f511 100644 --- a/src/pages/TestPage/TestPage.tsx +++ b/src/pages/TestPage/TestPage.tsx @@ -1,11 +1,11 @@ import { useParams } from "react-router-dom"; import Container from "../../components/shared/Container"; -import { useUserTestById } from "../../hooks/Tests/useUserTestById"; +import { useUserTestById } from "../../hooks/tests/useUserTestById"; import { useState } from "react"; import { Box, Button, Pagination, Typography } from "@mui/material"; import TestQuestion from "../../components/TestQuestion/TestQuestion"; -import { useSubmitAnswer } from "../../hooks/Tests/useSubmitAnswer"; -import { useCompleteTest } from "../../hooks/Tests/useCompleteTest"; +import { useSubmitAnswer } from "../../hooks/tests/useSubmitAnswer"; +import { useCompleteTest } from "../../hooks/tests/useCompleteTest"; import LearningAnswers from "../../components/Answers/Answers"; import { formatDate } from "../../utils/functions"; import NotFoundPage from "../NotFoundPage/NotFoundPage"; diff --git a/src/pages/TestsPage/TestsPage.tsx b/src/pages/TestsPage/TestsPage.tsx index 1d405d8..49c65cd 100644 --- a/src/pages/TestsPage/TestsPage.tsx +++ b/src/pages/TestsPage/TestsPage.tsx @@ -3,7 +3,7 @@ import Pagination from "@mui/material/Pagination"; import CircularProgress from "@mui/material/CircularProgress"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; -import { useGetTests } from "../../hooks/Tests/useGetTests"; +import { useGetTests } from "../../hooks/tests/useGetTests"; import Container from "../../components/shared/Container"; import TestListCard from "../../components/TestListCard/TestListCard"; import { diff --git a/src/router/router.tsx b/src/router/router.tsx index 328f9c0..f7a7626 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -13,7 +13,6 @@ import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage"; import { TestPage } from "../pages/TestPage/TestPage"; import TestsPage from "../pages/TestsPage/TestsPage"; import AdminLayout from "../layouts/AdminLayout/AdminLayout"; -import Container from "../components/shared/Container"; import UsersPage from "../pages/UsersPage/UsersPage"; import UserForm from "../pages/UserForm/UserForm"; import CategoriesPage from "../pages/CategoriesPage/CategoriesPage"; @@ -22,6 +21,10 @@ import LogsPage from "../pages/LogsPage/LogsPage"; import HitCountsPage from "../pages/HitcountsPage/HitcountsPage"; import UserTestsPage from "../pages/AdminUserTestsPage/AdminUserTestsPage"; import { SingleTestPage } from "../pages/SingleTestPage/SingleTestPage"; +import AdminTestsPage from "../pages/AdminTestsPage/AdminTestsPage"; +import TestForm from "../pages/TestForm/TestForm"; +import QuestionsPage from "../pages/QuestionsPage/QuestionsPage"; +import QuestionForm from "../pages/QuestionForm/QuestionForm"; const router = createBrowserRouter([ { @@ -72,9 +75,7 @@ const router = createBrowserRouter([ ), }, - {path: "/tests/view/:id", - element: - }, + { path: "/tests/view/:id", element: }, { path: "/tests", @@ -99,9 +100,23 @@ const router = createBrowserRouter([ { path: ":id/update", element: }, ], }, - { path: "questions", element: questions }, - { path: "tests", element: tests }, - { path: "user-tests", element: }, + { + path: "questions", + children: [ + { index: true, element: }, + { path: "create", element: }, + { path: ":id/update", element: }, + ], + }, + { + path: "tests", + children: [ + { index: true, element: }, + { path: "create", element: }, + { path: ":id/update", element: }, + ], + }, + { path: "user-tests", element: }, { path: "categories", children: [ @@ -111,7 +126,7 @@ const router = createBrowserRouter([ ], }, { path: "logs", element: }, - {path: "hitcounts", element: } + { path: "hitcounts", element: }, ], }, ]);