added tests by test and polished adaptivity
This commit is contained in:
parent
cba24b05c9
commit
6d9fa9061d
@ -1,6 +1,11 @@
|
||||
import type { CategoryType } from "../components/shared/types/TestTypes";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
interface CategoriesResponse {
|
||||
data: CategoryType[];
|
||||
}
|
||||
|
||||
export const getCategories = async () => {
|
||||
const res = await axiosInstance.get("/api/categories");
|
||||
const res = await axiosInstance.get<CategoriesResponse>("/api/categories");
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type {
|
||||
StartTestPayload,
|
||||
SubmitAnswerPayload,
|
||||
UserTestType,
|
||||
import {
|
||||
type TestType,
|
||||
type PaginatedTests,
|
||||
type StartTestPayload,
|
||||
type SubmitAnswerPayload,
|
||||
type UserTestType,
|
||||
} from "../components/shared/types/TestTypes";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
@ -13,6 +15,14 @@ export const startTest = async (data: StartTestPayload) => {
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const startTestById = async (id: number) => {
|
||||
const res = await axiosInstance.post<{ data: UserTestType }>(
|
||||
"/api/user-tests/by-test",
|
||||
{ test_id: id }
|
||||
);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const completeUserTest = async (userTestId: number) => {
|
||||
const res = await axiosInstance.post<{ message: string }>(
|
||||
`/api/user-tests/${userTestId}/complete`
|
||||
@ -28,7 +38,9 @@ export const getUserTestById = async (userTestId: number) => {
|
||||
};
|
||||
|
||||
export const getUserTests = async () => {
|
||||
const res = await axiosInstance.get<{data: UserTestType[]}>("/api/user-tests/me");
|
||||
const res = await axiosInstance.get<{ data: UserTestType[] }>(
|
||||
"/api/user-tests/me"
|
||||
);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
@ -39,3 +51,16 @@ export const submitAnswer = async (data: SubmitAnswerPayload) => {
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const getTests = async (categoryId?: number, page = 1) => {
|
||||
const params: Record<string, number> = { page };
|
||||
if (categoryId !== undefined) params.category_id = categoryId;
|
||||
|
||||
const res = await axiosInstance.get<PaginatedTests>("/api/tests", { params });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const getTestById = async (id: number) => {
|
||||
const res = await axiosInstance.get<{ data: TestType }>(`/api/tests/${id}`);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
@ -109,6 +109,15 @@ function Header() {
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCloseNavMenu();
|
||||
navigate("/tests");
|
||||
}}
|
||||
>
|
||||
<Typography textAlign="center">Tests</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
@ -160,6 +169,15 @@ function Header() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
sx={{ my: 2, color: "white", display: "block" }}
|
||||
onClick={() => {
|
||||
handleCloseNavMenu();
|
||||
navigate("/tests");
|
||||
}}
|
||||
>
|
||||
<Typography textAlign="center">Tests</Typography>
|
||||
</Button>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Box } from "@mui/material";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const QuestionWrapper = styled(Link)({
|
||||
@ -6,24 +7,27 @@ export const QuestionWrapper = styled(Link)({
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
padding: "15px",
|
||||
border: "3px solid #4B2981",
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "10px",
|
||||
marginBottom: "10px",
|
||||
textDecoration: "none",
|
||||
color: "inherit"
|
||||
color: "inherit",
|
||||
});
|
||||
|
||||
export const QuestionTitle = styled("div")({
|
||||
export const QuestionTitle = styled(Box)({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: "10px"
|
||||
marginBottom: "10px",
|
||||
"@media (max-width:600px)": {
|
||||
flexDirection: "column",
|
||||
},
|
||||
});
|
||||
|
||||
export const QuestionMetadata = styled("div")({
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "#4c4c4c",
|
||||
marginTop: "10px"
|
||||
marginTop: "10px",
|
||||
});
|
||||
|
||||
export const AuthorMeta = styled("div")({
|
||||
|
||||
@ -75,6 +75,7 @@ const StartTestForm = () => {
|
||||
<FormControl sx={{ minWidth: "300px" }} variant="filled">
|
||||
<InputLabel id="category-select-label">Category</InputLabel>
|
||||
<Select
|
||||
sx={{ maxWidth: "270px" }}
|
||||
labelId="category-select-label"
|
||||
id="category-select"
|
||||
value={category}
|
||||
@ -93,6 +94,7 @@ const StartTestForm = () => {
|
||||
<FormControl sx={{ minWidth: "200px" }} variant="filled">
|
||||
<InputLabel id="min-difficulty-select-label">Min Difficulty</InputLabel>
|
||||
<Select
|
||||
sx={{ maxWidth: "270px" }}
|
||||
labelId="min-difficulty-select-label"
|
||||
id="min-difficulty-select"
|
||||
value={minDifficulty}
|
||||
@ -110,6 +112,7 @@ const StartTestForm = () => {
|
||||
<FormControl sx={{ minWidth: "200px" }} variant="filled">
|
||||
<InputLabel id="max-difficulty-select-label">Max Difficulty</InputLabel>
|
||||
<Select
|
||||
sx={{ maxWidth: "270px" }}
|
||||
labelId="max-difficulty-select-label"
|
||||
id="max-difficulty-select"
|
||||
value={maxDifficulty}
|
||||
|
||||
@ -2,13 +2,19 @@ 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";
|
||||
|
||||
interface TestCardProps {
|
||||
test: UserTestType;
|
||||
}
|
||||
|
||||
const TestCard = ({ test }: TestCardProps) => {
|
||||
const title = test.test?.title || "User Test";
|
||||
let title: string | undefined = "User Test";
|
||||
|
||||
if (test.test_id) {
|
||||
const { data } = useGetTestById(test.test_id);
|
||||
title = data?.title;
|
||||
}
|
||||
|
||||
let statusLabel = "";
|
||||
let statusColor: "primary" | "success" | "error" = "primary";
|
||||
|
||||
88
src/components/TestListCard/TestListCard.tsx
Normal file
88
src/components/TestListCard/TestListCard.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { Box, Button, Chip, Typography } from "@mui/material";
|
||||
import CategoryIcon from "@mui/icons-material/Category";
|
||||
import type { TestType } from "../shared/types/TestTypes";
|
||||
import { useStartTestById } from "../../hooks/Tests/useStartTestById";
|
||||
|
||||
interface TestCardProps {
|
||||
test: TestType;
|
||||
}
|
||||
|
||||
const TestListCard = ({ test }: TestCardProps) => {
|
||||
const { mutate: startTest } = useStartTestById();
|
||||
const title = test.title;
|
||||
|
||||
let statusLabel = "";
|
||||
let statusColor: "primary" | "success" | "error" = "primary";
|
||||
|
||||
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",
|
||||
flexDirection: {
|
||||
xs: "column",
|
||||
sm: "column",
|
||||
md: "row"
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Box sx={{display: "flex", gap: 1, flexDirection: {xs: "column", sm: "row"}}}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
<Chip
|
||||
icon={<CategoryIcon />}
|
||||
label={test.category.name}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{test.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{test.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
mt={1}
|
||||
>
|
||||
Closes: {new Date(test.closed_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 1 }}>
|
||||
|
||||
<Chip label={statusLabel} color={statusColor} />
|
||||
{test.is_available && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => startTest(test.id)}
|
||||
>
|
||||
Start Test
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestListCard;
|
||||
@ -33,7 +33,7 @@ export type TestType = {
|
||||
description: string | null;
|
||||
category_id: number;
|
||||
category: CategoryType;
|
||||
questions: QuestionType[];
|
||||
questions?: QuestionType[];
|
||||
is_available: boolean;
|
||||
author_id: number;
|
||||
author: User;
|
||||
@ -61,3 +61,28 @@ export type SubmitAnswerPayload = {
|
||||
answerId: number;
|
||||
answer: (string | number)[];
|
||||
};
|
||||
|
||||
export type PaginatedTests = {
|
||||
data: TestType[];
|
||||
links: {
|
||||
first: string | null;
|
||||
last: string | null;
|
||||
prev: string | null;
|
||||
next: string | null;
|
||||
};
|
||||
meta: {
|
||||
current_page: number;
|
||||
from: number | null;
|
||||
last_page: number;
|
||||
links: {
|
||||
url: string | null;
|
||||
label: string;
|
||||
page: number | null;
|
||||
active: boolean;
|
||||
}[];
|
||||
path: string;
|
||||
per_page: number;
|
||||
to: number | null;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
10
src/hooks/Tests/useGetTestById.ts
Normal file
10
src/hooks/Tests/useGetTestById.ts
Normal file
@ -0,0 +1,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) => {
|
||||
return useQuery<TestType>({
|
||||
queryKey: ["tests", id],
|
||||
queryFn: () => getTestById(id),
|
||||
});
|
||||
};
|
||||
15
src/hooks/Tests/useGetTests.ts
Normal file
15
src/hooks/Tests/useGetTests.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getTests } from "../../api/testApi";
|
||||
|
||||
type UseTestsParams = {
|
||||
page?: number;
|
||||
categoryId?: number;
|
||||
};
|
||||
|
||||
export const useGetTests = ({ page = 1, categoryId }: UseTestsParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["tests", { page, categoryId }],
|
||||
queryFn: () => getTests(categoryId, page),
|
||||
staleTime: 1000 * 60,
|
||||
});
|
||||
};
|
||||
20
src/hooks/Tests/useStartTestById.ts
Normal file
20
src/hooks/Tests/useStartTestById.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { startTestById } from "../../api/testApi";
|
||||
import { toast } from "react-toastify";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const useStartTestById = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => startTestById(id),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
|
||||
toast.success("Test Started");
|
||||
navigate(`/tests/${data.id}`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,40 +1,89 @@
|
||||
import { Typography, Pagination } from "@mui/material";
|
||||
import {
|
||||
Typography,
|
||||
Pagination,
|
||||
type SelectChangeEvent,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import Container from "../../components/shared/Container";
|
||||
import { WelcomeContainer } from "./IndexPage.styles";
|
||||
import { WelcomeContainer } from "./IndexPage.styles";
|
||||
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
||||
import Question from "../../components/Question/Question";
|
||||
import { useQuestions } from "../../hooks/Question/useQuestions";
|
||||
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useCategories } from "../../hooks/Question/useCategories";
|
||||
|
||||
const IndexPage = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const questionsQuery = useQuestions({ page });
|
||||
|
||||
const { data: categories } = useCategories();
|
||||
const { user } = useAuth();
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | "all">(
|
||||
"all"
|
||||
);
|
||||
const questionsQuery = useQuestions({
|
||||
page,
|
||||
id: selectedCategory === "all" ? undefined : selectedCategory,
|
||||
});
|
||||
|
||||
const questions = questionsQuery.data?.data ?? [];
|
||||
const loading = questionsQuery.isLoading;
|
||||
const error = questionsQuery.error?.message ?? null;
|
||||
const meta = questionsQuery.data?.meta ?? null;
|
||||
|
||||
const handleCategoryChange = (event: SelectChangeEvent<number | "all">) => {
|
||||
const value = event.target.value;
|
||||
setSelectedCategory(value === "all" ? "all" : Number(value));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<WelcomeContainer>
|
||||
<Typography variant="h2">Welcome To HoshiAI!</Typography>
|
||||
<Typography align="center" variant="h2">Welcome To HoshiAI!</Typography>
|
||||
<Typography variant="subtitle1">The best place to learn!</Typography>
|
||||
</WelcomeContainer>
|
||||
|
||||
<StartTestForm />
|
||||
{user && (
|
||||
<>
|
||||
<Typography align="center" sx={{ mb: "36px" }} variant="h3">
|
||||
Start a Test
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ mb: "36px" }} variant="h3">
|
||||
<StartTestForm />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography align="center" sx={{ mb: "36px" }} variant="h3">
|
||||
Browse Questions
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{ mb: 2, minWidth: 200 }}>
|
||||
<InputLabel id="category-select-label">Category</InputLabel>
|
||||
<Select
|
||||
labelId="category-select-label"
|
||||
value={selectedCategory}
|
||||
onChange={handleCategoryChange}
|
||||
label="Category"
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
{categories?.map((category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{loading && <Typography>Loading questions...</Typography>}
|
||||
{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,10 +0,0 @@
|
||||
|
||||
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
||||
|
||||
const QuestionsPage = () => {
|
||||
return(
|
||||
<StartTestForm/>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionsPage;
|
||||
83
src/pages/TestsPage/TestsPage.tsx
Normal file
83
src/pages/TestsPage/TestsPage.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
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 Container from "../../components/shared/Container";
|
||||
import TestListCard from "../../components/TestListCard/TestListCard";
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
type SelectChangeEvent,
|
||||
} from "@mui/material";
|
||||
import { useCategories } from "../../hooks/Question/useCategories";
|
||||
|
||||
const TestsPage = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | "all">(
|
||||
"all"
|
||||
);
|
||||
|
||||
const { data: categories } = useCategories();
|
||||
const { data, isLoading, isError } = useGetTests({
|
||||
categoryId: selectedCategory === "all" ? undefined : selectedCategory,
|
||||
page: currentPage,
|
||||
});
|
||||
|
||||
const handleCategoryChange = (event: SelectChangeEvent<number | "all">) => {
|
||||
const value = event.target.value;
|
||||
setSelectedCategory(value === "all" ? "all" : Number(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
if (isLoading) return <CircularProgress />;
|
||||
if (isError) return <Alert severity="error">Failed to load tests</Alert>;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h2">Curated tests</Typography>
|
||||
<Box p={2}>
|
||||
<FormControl sx={{ mb: 2, minWidth: 200 }}>
|
||||
<InputLabel id="category-select-label">Category</InputLabel>
|
||||
<Select
|
||||
labelId="category-select-label"
|
||||
value={selectedCategory}
|
||||
onChange={handleCategoryChange}
|
||||
label="Category"
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
{categories?.map((category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{isLoading ? (
|
||||
<div>Loading tests...</div>
|
||||
) : isError ? (
|
||||
<div>Failed to load tests</div>
|
||||
) : (
|
||||
data?.data.map((test) => <TestListCard key={test.id} test={test} />)
|
||||
)}
|
||||
|
||||
{data?.meta?.last_page && data.meta.last_page > 1 && (
|
||||
<Pagination
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
count={data.meta.last_page}
|
||||
page={currentPage}
|
||||
onChange={(_, value) => setCurrentPage(value)}
|
||||
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestsPage;
|
||||
@ -11,6 +11,7 @@ import NotFoundPage from "../pages/NotFoundPage/NotFoundPage";
|
||||
import ResetPasswordPage from "../pages/ResetPasswordPage/ResetPasswordPage";
|
||||
import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage";
|
||||
import { TestPage } from "../pages/TestPage/TestPage";
|
||||
import TestsPage from "../pages/TestsPage/TestsPage";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -57,6 +58,10 @@ const router = createBrowserRouter([
|
||||
path: "/tests/:id",
|
||||
element: <TestPage/>
|
||||
},
|
||||
{
|
||||
path: "/tests",
|
||||
element: <TestsPage/>
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFoundPage />,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user