Skip to content

Commit

Permalink
refactor(client, backend): add pagination controls to quiz, refactor …
Browse files Browse the repository at this point in the history
…schema structure, dispose msw worker
  • Loading branch information
umitcan07 committed Nov 20, 2024
1 parent f4b7c6e commit 67feee8
Show file tree
Hide file tree
Showing 15 changed files with 155 additions and 76 deletions.
7 changes: 7 additions & 0 deletions backend/core/views/quiz_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
from rest_framework import permissions
from ..serializers.serializers import QuizSerializer
from ..permissions import IsAuthorOrReadOnly
from rest_framework.pagination import PageNumberPagination

class QuizPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'per_page'
max_page_size = 100

class QuizViewSet(viewsets.ModelViewSet):
queryset = Quiz.objects.all().order_by('-created_at')
serializer_class = QuizSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
pagination_class = QuizPagination

def calculate_difficulty(self, questions):
# implement this method to calculate the difficulty of a quiz via external api
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RiLogoutBoxRLine } from "@remixicon/react";
import { Link } from "react-router-dom";
import type { User } from "../types/user";
import type { User } from "../schemas";
import { buttonClass, buttonInnerRing } from "./button";

const routes = [
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/quiz-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { cva } from "cva";
import { Link } from "react-router-dom";
import { Avatar } from "../components/avatar";
import { buttonClass, buttonInnerRing } from "../components/button";
import { Quiz } from "../routes/Quizzes.data";
import { Quiz } from "../routes/Quiz/Quizzes.data";
import { getRelativeTime } from "../utils";

type QuizCardProps = {
Expand Down
Empty file added client/src/hooks/pagination.ts
Empty file.
25 changes: 9 additions & 16 deletions client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,13 @@ import { Sprite } from "./components/sprite";
import { ToastWrapper } from "./components/toast";
import "./index.css";
import { routes } from "./router";
import { enableMocking } from "./utils";

export const router = () => {
return createBrowserRouter(routes);
};

enableMocking().then(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Sprite />
<div>
<RouterProvider router={router()} />
</div>
<ToastWrapper />
</StrictMode>,
);
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Sprite />
<div>
<RouterProvider router={createBrowserRouter(routes)} />
</div>
<ToastWrapper />
</StrictMode>,
);
1 change: 0 additions & 1 deletion client/src/routes/Forum/Forum.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ export const Forum = () => {
</aside>
<main className="flex flex-col items-stretch justify-stretch gap-10">
<div className="flex flex-col gap-4">
{/* Pagination Controls */}
<div className="flex items-center justify-between">
<div>
<label htmlFor="perPage" className="mr-2">
Expand Down
2 changes: 1 addition & 1 deletion client/src/routes/Home/Home.data.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { safeParse } from "valibot";
import { USER } from "../../constants";
import { userSchema } from "../../schemas";
import { useToastStore } from "../../store";
import { userSchema } from "../../types/user";

export const homeLoader = () => {
const user = sessionStorage.getObject(USER) || localStorage.getObject(USER);
Expand Down
36 changes: 18 additions & 18 deletions client/src/routes/Quiz/Quiz.data.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { LoaderFunction } from "react-router";
import { safeParse } from "valibot";
import { quizDetailsSchema } from "../../types/quiz";
import { BASE_URL, logger } from "../../utils";
import apiClient from "../../api";
import { logger } from "../../utils";
import { quizDetailsSchema } from "./Quiz.schema";

export const quizLoader = (async ({ params }) => {
const { quizId } = params;
Expand All @@ -10,23 +11,22 @@ export const quizLoader = (async ({ params }) => {
throw new Error("Quiz ID is required.");
}

const res = await fetch(`${BASE_URL}/quizzes/${quizId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
try {
const response = await apiClient.get(`/quizzes/${quizId}`);

if (!res.ok) {
throw new Error(`Failed to fetch quiz with ID: ${quizId}`);
}
const data = response.data; // Extract data from axios response
logger.log(data);

const data = await res.json();
logger.log(data);
const { output, issues, success } = safeParse(quizDetailsSchema, data);
if (!success) {
throw new Error(`Failed to parse quiz response: ${issues}`);
}
const { output, issues, success } = safeParse(quizDetailsSchema, data);

return output;
if (!success) {
logger.error("Failed to parse quiz response:", issues);
throw new Error(`Failed to parse quiz response: ${issues}`);
}

return output;
} catch (error) {
logger.error(`Error fetching quiz with ID: ${quizId}`, error);
throw new Error(`Failed to fetch quiz with ID: ${quizId}`);
}
}) satisfies LoaderFunction;
File renamed without changes.
4 changes: 2 additions & 2 deletions client/src/routes/Quiz/Quiz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { useNavigate } from "react-router-dom";
import { useLoaderData, useRouteLoaderData } from "react-router-typesafe";
import { buttonClass, buttonInnerRing } from "../../components/button";
import { PageHead } from "../../components/page-head";
import { QuizDetails } from "../../types/quiz";
import { logger } from "../../utils";
import { homeLoader } from "../Home/Home.data";
import { quizLoader } from "./Quiz.data";
import { QuizDetails } from "./Quiz.schema";

const StartQuizComponent = ({
quiz,
Expand Down Expand Up @@ -151,7 +151,7 @@ export const QuizPage = () => {
setIsQuizEnded(true);
let correct = 0;
Object.entries(answers).forEach(([index, answer]) => {
const correctOption = quiz.questions[Number(index)].options.find(
const correctOption = quiz.results[Number(index)].options.find(
(o) => o.is_correct === "true",
);
if (correctOption && correctOption.id === answer) {
Expand Down
44 changes: 28 additions & 16 deletions client/src/routes/Quiz/Quizzes.data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
array,
boolean,
InferInput,
nullable,
number,
object,
safeParse,
string,
} from "valibot";
import { BASE_URL } from "../../utils";
import apiClient from "../../api"; // Axios instance
import { logger } from "../../utils";

export type Quiz = InferInput<typeof quizSchema>;

Expand Down Expand Up @@ -40,26 +42,36 @@ const quizSchema = object({
});

const quizzesResponseSchema = object({
quizzes: array(quizSchema),
count: number(),
next: nullable(string()),
previous: nullable(string()),
results: array(quizSchema),
});

export const quizzesLoader = (async ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const per_page = Number(url.searchParams.get("per_page")) || 20;
const res = await fetch(
`${BASE_URL}/quizzes/?page=${page}&per_page=${per_page}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
const data = await res.json();
const { output, issues, success } = safeParse(quizzesResponseSchema, data);
if (!success) {
throw new Error(`Failed to parse quizzes response: ${issues}`);

try {
const response = await apiClient.get("/quizzes/", {
params: { page, per_page },
});

const data = response.data; // Extract data from the axios response
const { output, issues, success } = safeParse(
quizzesResponseSchema,
data,
);

if (!success) {
logger.error("Failed to parse quizzes response", issues);
throw new Error(`Failed to parse quizzes response: ${issues}`);
}

return output;
} catch (error) {
logger.error("Error fetching quizzes", error);
throw new Error("Failed to fetch quizzes");
}
return output;
}) satisfies LoaderFunction;
85 changes: 82 additions & 3 deletions client/src/routes/Quiz/Quizzes.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { RiCloseFill } from "@remixicon/react";
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useLoaderData, useRouteLoaderData } from "react-router-typesafe";
import { buttonClass } from "../../components/button";
import { buttonClass, buttonInnerRing } from "../../components/button";
import { inputClass } from "../../components/input";
import { PageHead } from "../../components/page-head";
import { QuizCard } from "../../components/quiz-card";
Expand All @@ -10,13 +11,33 @@ import { quizzesLoader } from "./Quizzes.data";

export const Quizzes = () => {
const data = useLoaderData<typeof quizzesLoader>();
const [searchParams, setSearchParams] = useSearchParams();

const { user, logged_in } =
useRouteLoaderData<typeof homeLoader>("home-main");
const [searchTerm, setSearchTerm] = useState("");
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
const [sortBy, setSortBy] = useState("newest");

const filteredQuizzes = data.quizzes
const currentPage = parseInt(searchParams.get("page") || "1");
const perPage = parseInt(searchParams.get("per_page") || "10");
const totalPages = Math.ceil(data.count / perPage);

const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set("page", page.toString());
newParams.set("per_page", perPage.toString());
setSearchParams(newParams);
};

const handlePerPageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams);
newParams.set("per_page", e.target.value);
newParams.set("page", "1"); // Reset to the first page
setSearchParams(newParams);
};

const filteredQuizzes = data.results
.filter(
(quiz) =>
quiz.title.toLowerCase().includes(searchTerm.toLowerCase()) &&
Expand All @@ -43,7 +64,7 @@ export const Quizzes = () => {
});

const allTags = Array.from(
new Set(data.quizzes.flatMap((quiz) => quiz.tags)),
new Set(data.results.flatMap((quiz) => quiz.tags)),
).sort((a, b) => a.name.localeCompare(b.name));

const description = logged_in
Expand All @@ -54,6 +75,64 @@ export const Quizzes = () => {
<div className="container flex max-w-screen-xl flex-col items-stretch gap-8 py-12">
<PageHead title="Quizzes" description={description} />
<aside className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<label htmlFor="perPage" className="mr-2">
Questions per page:
</label>
<select
id="perPage"
value={perPage}
onChange={handlePerPageChange}
className={`${inputClass()} w-24`}
>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
<div className="flex gap-4">
<button
onClick={() =>
handlePageChange(currentPage - 1)
}
disabled={!data.previous}
aria-disabled={!data.previous}
className={buttonClass({
intent: "secondary",
})}
>
<div
className={buttonInnerRing({
intent: "secondary",
})}
/>
Previous
</button>
<span className="flex items-center">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() =>
handlePageChange(currentPage + 1)
}
disabled={!data.next}
aria-disabled={!data.next}
className={buttonClass({
intent: "secondary",
})}
>
<div
className={buttonInnerRing({
intent: "secondary",
})}
/>
Next
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<div>
<select
Expand Down
File renamed without changes.
10 changes: 0 additions & 10 deletions client/src/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
export const BASE_URL = "http://localhost:8000/api/v1";

export async function enableMocking() {
if (import.meta.env.VITE_ENABLE_MOCKS === "false") {
return;
}

const { worker } = await import("./mocks/browser");

return worker.start();
}

type Logger = {
log: typeof console.log;
error: typeof console.error;
Expand Down
13 changes: 6 additions & 7 deletions mobile/app/screens/Leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import React, { useState } from "react";
import { useState } from "react";
import {
StyleSheet,
Text,
View,
Dimensions,
FlatList,
Image,
TouchableOpacity,
Dimensions,
StyleSheet,
Text,
View
} from "react-native";
import { TabView, SceneMap, TabBar } from "react-native-tab-view";
import { SceneMap, TabBar, TabView } from "react-native-tab-view";

const Leaderboard = () => {
const [index, setIndex] = useState(0);
Expand Down

0 comments on commit 67feee8

Please sign in to comment.