user test results and testlist in profile
This commit is contained in:
parent
01446071e5
commit
cba24b05c9
@ -10,7 +10,7 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>test-ai</title>
|
||||
<title>HoshiAI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import axios from "axios";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL;
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: "http://127.0.0.1:8000",
|
||||
baseURL,
|
||||
timeout: 30 * 1000,
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
|
||||
|
||||
type GetQuestionsParams = {
|
||||
page?: number;
|
||||
id?: number;
|
||||
|
||||
@ -28,8 +28,8 @@ export const getUserTestById = async (userTestId: number) => {
|
||||
};
|
||||
|
||||
export const getUserTests = async () => {
|
||||
const res = await axiosInstance.get<UserTestType[]>("/api/user-tests/me");
|
||||
return res.data;
|
||||
const res = await axiosInstance.get<{data: UserTestType[]}>("/api/user-tests/me");
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const submitAnswer = async (data: SubmitAnswerPayload) => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { arraysEqual } from "../../utils/functions";
|
||||
|
||||
interface Variant {
|
||||
id: number;
|
||||
@ -9,31 +10,67 @@ interface Variant {
|
||||
interface LearningAnswersProps {
|
||||
variants?: Variant[];
|
||||
correctAnswers: number[] | string[];
|
||||
type: "single" | "text";
|
||||
type: "single" | "multiple" | "text";
|
||||
userAnswers?: number[];
|
||||
}
|
||||
|
||||
const LearningAnswers = ({
|
||||
variants = [],
|
||||
correctAnswers = [],
|
||||
type,
|
||||
userAnswers,
|
||||
}: LearningAnswersProps) => {
|
||||
const [showAnswer, setShowAnswer] = useState(false);
|
||||
|
||||
const hasAnswered = userAnswers !== null && userAnswers !== undefined;
|
||||
const isEmptyAnswer = hasAnswered && userAnswers.length === 0;
|
||||
const isCorrect = hasAnswered
|
||||
? arraysEqual(correctAnswers as number[], userAnswers as number[])
|
||||
: false;
|
||||
const normalizedUserAnswers = userAnswers ?? [];
|
||||
|
||||
const getUserAnswerText = () => {
|
||||
if (!normalizedUserAnswers.length) return "";
|
||||
const selectedVariants = variants.filter((v) =>
|
||||
normalizedUserAnswers.includes(v.id)
|
||||
);
|
||||
return selectedVariants.map((v) => v.text).join(", ");
|
||||
};
|
||||
|
||||
if (type === "text") {
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{showAnswer && (
|
||||
{hasAnswered ? (
|
||||
isEmptyAnswer ? (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
|
||||
>
|
||||
You didn’t answer
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="primary"
|
||||
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
|
||||
>
|
||||
Your answer: {userAnswers.join(", ")}
|
||||
</Typography>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{(hasAnswered || showAnswer) && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="success.main"
|
||||
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
|
||||
>
|
||||
Correct answer:{" "}
|
||||
{Array.isArray(correctAnswers)
|
||||
? correctAnswers.join(", ")
|
||||
: correctAnswers}
|
||||
Correct answer: {correctAnswers.join(", ")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!hasAnswered && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
@ -42,12 +79,30 @@ const LearningAnswers = ({
|
||||
>
|
||||
Reveal Answer
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{hasAnswered ? (
|
||||
isEmptyAnswer ? (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
You didn’t answer
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color={isCorrect ? "success.main" : "error.main"}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Your answer: {getUserAnswerText()} —{" "}
|
||||
{isCorrect ? "Correct" : "Incorrect"}
|
||||
</Typography>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{variants.map((variant) => (
|
||||
<Box
|
||||
key={variant.id}
|
||||
@ -57,8 +112,12 @@ const LearningAnswers = ({
|
||||
borderRadius: 2,
|
||||
border: "1px solid #ccc",
|
||||
backgroundColor:
|
||||
showAnswer && (correctAnswers as number[]).includes(variant.id)
|
||||
showAnswer || hasAnswered
|
||||
? (correctAnswers as number[]).includes(variant.id)
|
||||
? "success.light"
|
||||
: normalizedUserAnswers.includes(variant.id)
|
||||
? "error.light"
|
||||
: "background.paper"
|
||||
: "background.paper",
|
||||
}}
|
||||
>
|
||||
@ -66,14 +125,16 @@ const LearningAnswers = ({
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{!hasAnswered && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={() => setShowAnswer(true)}
|
||||
onClick={() => setShowAnswer(!showAnswer)}
|
||||
>
|
||||
Reveal Answer
|
||||
{showAnswer ? "Hide Answer" : "Reveal Answer"}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
80
src/components/TestCard/TestCard.tsx
Normal file
80
src/components/TestCard/TestCard.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
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";
|
||||
|
||||
interface TestCardProps {
|
||||
test: UserTestType;
|
||||
}
|
||||
|
||||
const TestCard = ({ test }: TestCardProps) => {
|
||||
const title = test.test?.title || "User Test";
|
||||
|
||||
let statusLabel = "";
|
||||
let statusColor: "primary" | "success" | "error" = "primary";
|
||||
|
||||
if (test.is_completed) {
|
||||
statusLabel = "Completed";
|
||||
statusColor = "success";
|
||||
} else if (!test.is_available) {
|
||||
statusLabel = "Expired";
|
||||
statusColor = "error";
|
||||
} else {
|
||||
statusLabel = "Active";
|
||||
statusColor = "primary";
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
mb: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{test.closed_at && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Expires at: {formatDate(test.closed_at)}
|
||||
</Typography>
|
||||
)}
|
||||
{test.is_completed && test.score !== undefined && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Score: {test.score}%
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Chip label={statusLabel} color={statusColor} />
|
||||
{test.is_available && !test.is_completed && (
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/tests/${test.id}`}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
Continue Test
|
||||
</Button>
|
||||
)}
|
||||
{test.is_completed && (
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/tests/${test.id}`}
|
||||
variant="contained"
|
||||
color="success"
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestCard;
|
||||
@ -1,12 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { completeUserTest } from "../../api/testApi";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useCompleteTest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: completeUserTest,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
queryClient.invalidateQueries({ queryKey: ["current-test"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message);
|
||||
|
||||
@ -4,6 +4,6 @@ import { getUserTests } from "../../api/testApi";
|
||||
export const useGetUserTests = () => {
|
||||
return useQuery({
|
||||
queryKey: ["user-tests"],
|
||||
queryFn: () => getUserTests,
|
||||
queryFn: () => getUserTests(),
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { startTest } from "../../api/testApi";
|
||||
import { toast } from "react-toastify";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const useStartTest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: startTest,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
|
||||
toast.success("Test Started");
|
||||
navigate(`/tests/${data.id}`);
|
||||
},
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { submitAnswer } from "../../api/testApi";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useSubmitAnswer = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: submitAnswer,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
console.log(data);
|
||||
queryClient.invalidateQueries({ queryKey: ["current-test"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Something went wrong");
|
||||
|
||||
@ -34,7 +34,7 @@ const IndexPage = () => {
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
|
||||
{questions.map((q: QuestionType) => (
|
||||
<Question key={q.id} question={q} />
|
||||
<Question key={q.id} question={q}/>
|
||||
))}
|
||||
|
||||
{meta && meta.last_page > 1 && (
|
||||
|
||||
@ -1,11 +1,25 @@
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
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 TestCard from "../../components/TestCard/TestCard";
|
||||
import { useState } from "react";
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { data: tests, isLoading, error } = useGetUserTests();
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
|
||||
const activeTests = tests?.filter((t) => t.is_available && !t.is_completed);
|
||||
const completedTests = tests?.filter((t) => t.is_completed);
|
||||
const expiredTests = tests?.filter((t) => !t.is_available && !t.is_completed);
|
||||
|
||||
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||
setTabIndex(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@ -27,7 +41,7 @@ const ProfilePage = () => {
|
||||
</Button>
|
||||
{user?.type === "admin" && (
|
||||
<Button
|
||||
sx={{marginLeft: "10px"}}
|
||||
sx={{ marginLeft: "10px" }}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate("/admin")}
|
||||
@ -36,6 +50,43 @@ const ProfilePage = () => {
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h5" mb={2}>
|
||||
Your Tests
|
||||
</Typography>
|
||||
|
||||
<Tabs value={tabIndex} onChange={handleTabChange}>
|
||||
<Tab label={`Active (${activeTests?.length || 0})`} />
|
||||
<Tab label={`Completed (${completedTests?.length || 0})`} />
|
||||
<Tab label={`Expired (${expiredTests?.length || 0})`} />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{isLoading && <Typography>Loading tests...</Typography>}
|
||||
{error && (
|
||||
<Typography color="error">Failed to load tests.</Typography>
|
||||
)}
|
||||
|
||||
{tabIndex === 0 &&
|
||||
(activeTests?.length ? (
|
||||
activeTests.map((t) => <TestCard key={t.id} test={t} />)
|
||||
) : (
|
||||
<Typography>No active tests</Typography>
|
||||
))}
|
||||
{tabIndex === 1 &&
|
||||
(completedTests?.length ? (
|
||||
completedTests.map((t) => <TestCard key={t.id} test={t} />)
|
||||
) : (
|
||||
<Typography>No completed tests</Typography>
|
||||
))}
|
||||
{tabIndex === 2 &&
|
||||
(expiredTests?.length ? (
|
||||
expiredTests.map((t) => <TestCard key={t.id} test={t} />)
|
||||
) : (
|
||||
<Typography>No expired tests</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@ -6,6 +6,8 @@ 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 LearningAnswers from "../../components/Answers/Answers";
|
||||
import { formatDate } from "../../utils/functions";
|
||||
|
||||
export const TestPage = () => {
|
||||
const { id } = useParams();
|
||||
@ -13,6 +15,7 @@ export const TestPage = () => {
|
||||
const [currentQuestion, setCurrentQuestion] = useState(1);
|
||||
const submitAnswerMutation = useSubmitAnswer();
|
||||
const completeTestMutation = useCompleteTest();
|
||||
const allAnswered = test?.answers?.every((ans) => ans.answer !== null);
|
||||
|
||||
const handleCompleteTest = () => {
|
||||
if (test) completeTestMutation.mutate(test.id);
|
||||
@ -54,15 +57,34 @@ export const TestPage = () => {
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (test.is_completed)
|
||||
if (test.is_completed) {
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
You have already completed this test.
|
||||
<Typography variant="h2" sx={{ mb: 3 }}>
|
||||
Results
|
||||
</Typography>
|
||||
<Typography variant="h5" color="secondary" sx={{ mb: 3 }}>
|
||||
Your score: {test.score}%
|
||||
</Typography>
|
||||
|
||||
{test?.answers?.map((ans) => (
|
||||
<Box key={ans.question_id} sx={{ mb: 4 }}>
|
||||
<Typography variant="h6">{ans.question.title}</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{ans.question.description}
|
||||
</Typography>
|
||||
|
||||
<LearningAnswers
|
||||
type={ans.question.type as "single" | "multiple" | "text"}
|
||||
variants={ans.question.variants}
|
||||
correctAnswers={ans.question.correct_answers}
|
||||
userAnswers={ans.answer as number[]}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
|
||||
}
|
||||
const questionsAndAnswers = test.answers;
|
||||
const currentQA = questionsAndAnswers?.[currentQuestion - 1];
|
||||
|
||||
@ -78,17 +100,7 @@ export const TestPage = () => {
|
||||
>
|
||||
<Typography variant="h2">{test.test?.title ?? "User Test"}</Typography>
|
||||
<Typography variant="subtitle1">
|
||||
Expires at:{" "}
|
||||
{test.closed_at
|
||||
? new Date(test.closed_at).toLocaleString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "N/A"}
|
||||
Expires at: {test.closed_at ? formatDate(test.closed_at) : "N/A"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{currentQA ? (
|
||||
@ -120,16 +132,27 @@ export const TestPage = () => {
|
||||
page={currentQuestion}
|
||||
onChange={(_, page) => setCurrentQuestion(page)}
|
||||
/>
|
||||
|
||||
{currentQuestion == questionsAndAnswers?.length && (
|
||||
<Button
|
||||
onClick={handleCompleteTest}
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!allAnswered}
|
||||
sx={{ mt: 15, fontSize: "32px" }}
|
||||
>
|
||||
Complete test
|
||||
</Button>
|
||||
)}
|
||||
{currentQuestion == questionsAndAnswers?.length && !allAnswered && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="error"
|
||||
sx={{ fontWeight: 500, textAlign: "center", mt: 2 }}
|
||||
>
|
||||
You must answer all questions before completing the test
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
|
||||
29
src/utils/functions.ts
Normal file
29
src/utils/functions.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export function arraysEqual(a: number[], b: number[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
|
||||
a = [...a].sort((x, y) => x - y);
|
||||
b = [...b].sort((x, y) => x - y);
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
|
||||
const date = new Date(dateString);
|
||||
|
||||
const pad = (num: number) => num.toString().padStart(2, "0");
|
||||
|
||||
const day = pad(date.getDate());
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const year = date.getFullYear();
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user