From 5f886c97b951364316e73f976c36834d4a931956 Mon Sep 17 00:00:00 2001 From: Lok Yx Date: Wed, 29 Jan 2025 17:55:09 +0000 Subject: [PATCH 1/5] feat(hooks): PUT, DELETE hooks - feat(hooks): implement `use-put-data` hook for handling PUT requests - feat(hooks): implement `use-delete-data` hook for handling DELETE requests --- client/src/context/auth-provider.tsx | 1 - client/src/hooks/use-delete-data.ts | 83 +++++++++++++++++++++++++++ client/src/hooks/use-post-data.ts | 4 +- client/src/hooks/use-put-data.ts | 85 ++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 client/src/hooks/use-delete-data.ts create mode 100644 client/src/hooks/use-put-data.ts diff --git a/client/src/context/auth-provider.tsx b/client/src/context/auth-provider.tsx index 96922f8e..abf42ae1 100644 --- a/client/src/context/auth-provider.tsx +++ b/client/src/context/auth-provider.tsx @@ -59,7 +59,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { mutateAsync: postLogin } = usePostMutation< AxiosResponse, - Error, { username: string; password: string } >(["login"], "/auth/token/", 2000); diff --git a/client/src/hooks/use-delete-data.ts b/client/src/hooks/use-delete-data.ts new file mode 100644 index 00000000..8b322d5f --- /dev/null +++ b/client/src/hooks/use-delete-data.ts @@ -0,0 +1,83 @@ +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQueryClient, +} from "@tanstack/react-query"; +import axios, { AxiosError } from "axios"; +import { toast } from "sonner"; + +import api from "@/lib/api"; + +/** + * Custom hook for performing a DELETE request mutation. + * + * This hook provides a wrapper around the `useMutation` hook from React Query to handle DELETE requests using Axios. + * It allows you to send a DELETE request to a specified API endpoint and automatically updates the React Query cache on success. + * + * @template TData - The type of the data returned from the API response. + * @template TError - The type of the error returned from the mutation (default is `AxiosError`). + * @template TVariables - The type of the variables that will be sent in the request body (default is `void`). + * + * @param {string[]} mutationKey - A unique key for identifying the mutation in React Query. + * @param {string} endpoint - The API endpoint to which the DELETE request is sent. + * @param {Array[]} queryKeys - Array of query keys that should be invalidated after a successful mutation. + * @param {number} [timeout=10000] - The timeout for the DELETE request in milliseconds. Defaults to 10000ms (10 seconds). + * @param {UseMutationOptions} [args] - Optional configuration options for the mutation. This can include callbacks like `onSuccess`, `onError`, etc. + * + * @returns {UseMutationResult} The result of the mutation, which includes properties like `mutate`, `isPending`, `isError`, etc. + * + * @example + * const { mutate, isPending, isError } = useDeleteMutation( + * ["deleteItem"], + * "/api/items/1", + * [["items"]], + * 5000, + * { + * onSuccess: () => { + * console.log("Item deleted successfully"); + * }, + * onError: (error) => { + * console.error("Failed to delete item", error); + * }, + * } + * ); + */ + +export const useDeleteMutation = < + TData, + TError = AxiosError<{ error: string; message: string }>, + TVariables = void, +>( + mutationKey: string[], + endpoint: string, + queryKeys: Array[] = [], + timeout: number = 10000, // Default timeout of 10 seconds + args?: Omit< + UseMutationOptions, + "mutationKey" | "mutationFn" + >, +): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + ...args, + mutationKey, + mutationFn: () => { + return api.delete(endpoint, { timeout }); + }, + onError: (error: TError) => { + // extract error message from BE response + if (axios.isAxiosError(error) && error.response?.data) { + const { message, error: detailedError } = error.response.data; + toast.error(message || detailedError || "Something went wrong"); + } + }, + onSuccess: (data, variables, context) => { + queryKeys.forEach((queryKey) => { + queryClient.invalidateQueries({ queryKey }); + }); + if (args?.onSuccess) args.onSuccess(data, variables, context); + }, + }); +}; diff --git a/client/src/hooks/use-post-data.ts b/client/src/hooks/use-post-data.ts index 130f0e7d..31af9ba7 100644 --- a/client/src/hooks/use-post-data.ts +++ b/client/src/hooks/use-post-data.ts @@ -24,10 +24,10 @@ import api from "@/lib/api"; * @param {number} [timeout=10000] - The timeout for the POST request in milliseconds. Defaults to 10000ms (10 seconds). * @param {UseMutationOptions} [args] - Optional configuration options for the mutation. This can include callbacks like `onSuccess`, `onError`, etc. * - * @returns {UseMutationResult} The result of the mutation, which includes properties like `mutate`, `isLoading`, `isError`, etc. + * @returns {UseMutationResult} The result of the mutation, which includes properties like `mutate`, `isPending`, `isError`, etc. * * @example - * const { mutate, isLoading, isError } = usePostMutation(["login"], "/api/login", { + * const { mutate, isPending, isError } = usePostMutation(["login"], "/api/login", { * onSuccess: () => { * console.log("Logged in successfully"); * }, diff --git a/client/src/hooks/use-put-data.ts b/client/src/hooks/use-put-data.ts new file mode 100644 index 00000000..90899eef --- /dev/null +++ b/client/src/hooks/use-put-data.ts @@ -0,0 +1,85 @@ +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQueryClient, +} from "@tanstack/react-query"; +import axios, { AxiosError } from "axios"; +import { toast } from "sonner"; + +import api from "@/lib/api"; + +/** + * Custom hook for performing a `PUT` request mutation with automatic cache invalidation and error handling. + * + * This hook wraps the `useMutation` hook from React Query, providing a way to update data on the server + * and automatically invalidate related queries to keep the UI in sync. + * + * @template TData - The expected response data type from the API. + * @template TVariables - The request payload type sent in the `PUT` request. + * @template TError - The error type returned from the mutation (default is `AxiosError` with an error message structure). + * + * @param {string[]} mutationKey - A unique key for identifying the mutation in React Query. + * @param {(string | number)[][]} queryKeys - An array of query keys to invalidate after a successful mutation. + * @param {string} endpoint - The API endpoint to send the `PUT` request. + * @param {number} [timeout=10000] - The timeout duration for the `PUT` request in milliseconds (default: 10 seconds). + * @param {Omit, "mutationKey" | "mutationFn">} [args] + * - Additional mutation options such as `onSuccess`, `onError`, etc. + * + * @returns {UseMutationResult} - The mutation result, including methods like `mutate`, `isPending`, and `isError`. + * + * @example + * const { mutate, isPending, isError } = usePutMutation>( + * ["updateUser"], + * [["user", userId], ["users"]], + * `/api/users/${userId}`, + * 15000, + * { + * onSuccess: () => { + * toast.success("User updated successfully"); + * }, + * onError: (error) => { + * console.error("Update failed", error); + * }, + * } + * ); + * + * mutate({ name: "John Doe", email: "john@example.com" }); + */ +export const usePutMutation = < + TData, + TVariables = unknown, + TError = AxiosError<{ error: string; message: string }>, +>( + mutationKey: string[], + queryKeys: Array[], + endpoint: string, + timeout: number = 10000, // Default timeout of 10 seconds + args?: Omit< + UseMutationOptions, + "mutationKey" | "mutationFn" + >, +): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + ...args, + mutationKey, + mutationFn: (variables: TVariables) => { + return api.put(endpoint, variables, { timeout }); + }, + onError: (error: TError) => { + // extract error message from BE response + if (axios.isAxiosError(error) && error.response?.data) { + const { message, error: detailedError } = error.response.data; + toast.error(message || detailedError || "Something went wrong"); + } + }, + onSuccess: (data, details, context) => { + queryKeys.forEach((queryKey) => + queryClient.invalidateQueries({ queryKey }), + ); + if (args?.onSuccess) args.onSuccess(data, details, context); + }, + }); +}; From 2e33a8c24e7d3754133331428bd21a0b089146de Mon Sep 17 00:00:00 2001 From: Lok Yx Date: Thu, 30 Jan 2025 12:09:01 +0000 Subject: [PATCH 2/5] feat(test-management): View, Edit, Delete for School & Team - feat: add View, Edit, and Delete functionality in Test Management for School & Team pages - refactor: clean up code and ensure request/response data matches API structure --- client/package-lock.json | 75 ++++++++ client/package.json | 1 + client/src/components/sidebar.tsx | 29 +++- .../components/ui/Users/school-data-grid.tsx | 34 +++- .../ui/Users/school-data-table-form.tsx | 90 +++++++++- .../src/components/ui/Users/select-school.tsx | 36 +++- .../components/ui/Users/team-data-grid.tsx | 34 +++- .../ui/Users/team-data-table-form.tsx | 18 +- client/src/components/ui/checkbox.tsx | 28 +++ client/src/hooks/use-delete-data.ts | 88 ++++++++-- client/src/hooks/use-post-data.ts | 2 +- client/src/hooks/use-put-data.ts | 46 +++-- client/src/pages/api/test/competitions.ts | 26 +-- client/src/pages/api/users/index.ts | 8 +- client/src/pages/api/users/me.ts | 56 ------ client/src/pages/users/school/[id].tsx | 162 ++++++++++++++++++ .../{create_school.tsx => school/create.tsx} | 0 .../users/{school.tsx => school/index.tsx} | 2 +- client/src/pages/users/team/[id].tsx | 140 +++++++++++++++ .../{create_team.tsx => team/create.tsx} | 0 .../pages/users/{team.tsx => team/index.tsx} | 2 +- client/src/types/leaderboard.ts | 2 +- client/src/types/question.ts | 2 +- client/src/types/quiz.ts | 4 +- client/src/types/user.ts | 15 +- 25 files changed, 774 insertions(+), 126 deletions(-) create mode 100644 client/src/components/ui/checkbox.tsx delete mode 100644 client/src/pages/api/users/me.ts create mode 100644 client/src/pages/users/school/[id].tsx rename client/src/pages/users/{create_school.tsx => school/create.tsx} (100%) rename client/src/pages/users/{school.tsx => school/index.tsx} (97%) create mode 100644 client/src/pages/users/team/[id].tsx rename client/src/pages/users/{create_team.tsx => team/create.tsx} (100%) rename client/src/pages/users/{team.tsx => team/index.tsx} (96%) diff --git a/client/package-lock.json b/client/package-lock.json index 002fc6b0..b0e4868d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-label": "^2.1.0", @@ -938,6 +939,80 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz", + "integrity": "sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.2.tgz", diff --git a/client/package.json b/client/package.json index 1fc666b8..1b995c2c 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-label": "^2.1.0", diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index 00321dd5..706128b6 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -36,13 +36,28 @@ export default function Sidebar({ children, role }: LayoutProps) { const pathSegments = router.pathname.split("/").filter(Boolean); const breadcrumbItems = pathSegments.map((segment, index) => { - const formattedSegment = segment - .replace(/_/g, " ") // replace underscores with spaces - .replace(/\b\w/g, (char) => char.toUpperCase()); // capitalize first letter - return { - title: formattedSegment, - url: `/${pathSegments.slice(0, index + 1).join("/")}`, - }; + // handle dynamic segments + const isDynamic = /^\[.*\]$/.test(segment); + const actualSegment = isDynamic + ? router.query[segment.slice(1, -1)] || segment.slice(1, -1) + : segment; + + const title = actualSegment + .toString() + .replace(/_/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); + + const url = + "/" + + pathSegments + .slice(0, index + 1) + .map((seg) => { + const isParam = /^\[.*\]$/.test(seg); + return isParam ? router.query[seg.slice(1, -1)] || seg : seg; + }) + .join("/"); + + return { title, url }; }); return ( diff --git a/client/src/components/ui/Users/school-data-grid.tsx b/client/src/components/ui/Users/school-data-grid.tsx index ab2a1fb8..a86041d4 100644 --- a/client/src/components/ui/Users/school-data-grid.tsx +++ b/client/src/components/ui/Users/school-data-grid.tsx @@ -1,4 +1,6 @@ +import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Pagination } from "@/components/ui/pagination"; @@ -10,6 +12,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useDynamicDeleteMutation } from "@/hooks/use-delete-data"; import { cn } from "@/lib/utils"; import { DatagridProps } from "@/types/data-grid"; import { School } from "@/types/user"; @@ -29,11 +32,28 @@ export function SchoolDataGrid({ onDataChange, changePage, }: DatagridProps) { + const router = useRouter(); const [currentPage, setCurrentPage] = useState(1); const [paddedData, setPaddedData] = useState([]); const itemsPerPage = 5; const totalPages = Math.ceil(datacontext.length / itemsPerPage); + const { mutate: deleteSchool, isPending } = useDynamicDeleteMutation({ + baseUrl: "/users/schools", + mutationKey: ["school_delete"], + onSuccess: () => { + router.reload(); + toast.success("School has been deleted."); + }, + }); + + const onDelete = (id: number) => { + if (!window.confirm("Are you sure you want to delete this school?")) { + return; + } + deleteSchool(id); + }; + const handlePageChange = (page: number) => { if (page >= 1 && page <= totalPages) { setCurrentPage(page); @@ -97,8 +117,18 @@ export function SchoolDataGrid({
- - + +
diff --git a/client/src/components/ui/Users/school-data-table-form.tsx b/client/src/components/ui/Users/school-data-table-form.tsx index 809af60d..2002e45a 100644 --- a/client/src/components/ui/Users/school-data-table-form.tsx +++ b/client/src/components/ui/Users/school-data-table-form.tsx @@ -1,13 +1,17 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/router"; import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Form, FormControl, FormField, FormItem, + FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; @@ -19,6 +23,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { SelectSchoolType } from "@/components/ui/Users/select-school"; +import { usePostMutation } from "@/hooks/use-post-data"; import { cn } from "@/lib/utils"; import { createSchoolSchema } from "@/types/user"; @@ -38,6 +44,8 @@ type School = z.infer; export function SchoolDataTableForm() { const defaultSchool = { name: "", + is_country: true, + abbreviation: "", } as School; const createSchoolForm = useForm<{ @@ -52,8 +60,21 @@ export function SchoolDataTableForm() { name: "schools", }); + const router = useRouter(); + const { mutate: createSchool, isPending } = usePostMutation( + ["schools"], + "/users/schools/", + 1000, + { + onSuccess: () => { + toast.success("Schools created successfully!"); + router.push("/users/school/"); + }, + }, + ); + const onSubmit = (data: { schools: School[] }) => { - console.log("Submitted data:", data.schools); + createSchool({ ...data.schools }); }; const commonTableHeadClasses = "w-auto text-white text-nowrap"; @@ -74,6 +95,15 @@ export function SchoolDataTableForm() { No. Name* + Type* + + Is Country + + + Abbreviation* + @@ -102,6 +132,60 @@ export function SchoolDataTableForm() { )} /> + + ( + + + + + + + )} + /> + + + ( + +
+ + + + {field.value ? "Yes" : "No"} +
+ +
+ )} + /> +
+ + ( + + + + + + + )} + /> + - + diff --git a/client/src/components/ui/Users/select-school.tsx b/client/src/components/ui/Users/select-school.tsx index 4f152bff..d2269bcc 100644 --- a/client/src/components/ui/Users/select-school.tsx +++ b/client/src/components/ui/Users/select-school.tsx @@ -7,7 +7,7 @@ import { } from "@/components/ui/select"; import { useFetchData } from "@/hooks/use-fetch-data"; import { cn } from "@/lib/utils"; -import { School } from "@/types/user"; +import { School, SchoolTypeEnum } from "@/types/user"; type Props = { selectedId: number | undefined; @@ -82,3 +82,37 @@ export function SelectSchool({ selectedId, onChange, className }: Props) { ); } + +type SchoolTypeProps = { + selectedType: string | undefined; + onChange: (role: string) => void; + className?: string; +}; + +export function SelectSchoolType({ + selectedType, + onChange, + className, +}: SchoolTypeProps) { + const onValueChange = (value: string) => { + onChange(value); + }; + + return ( + + ); +} diff --git a/client/src/components/ui/Users/team-data-grid.tsx b/client/src/components/ui/Users/team-data-grid.tsx index e2d7b6e6..5de3298f 100644 --- a/client/src/components/ui/Users/team-data-grid.tsx +++ b/client/src/components/ui/Users/team-data-grid.tsx @@ -1,4 +1,6 @@ +import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Pagination } from "@/components/ui/pagination"; @@ -10,6 +12,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useDynamicDeleteMutation } from "@/hooks/use-delete-data"; import { cn } from "@/lib/utils"; import { DatagridProps } from "@/types/data-grid"; import { Team } from "@/types/team"; @@ -30,11 +33,28 @@ export function TeamDataGrid({ onDataChange, changePage, }: DatagridProps) { + const router = useRouter(); const [currentPage, setCurrentPage] = useState(1); const [paddedData, setPaddedData] = useState([]); const itemsPerPage = 5; const totalPages = Math.ceil(datacontext.length / itemsPerPage); + const { mutate: deleteTeam, isPending } = useDynamicDeleteMutation({ + baseUrl: "/team/teams", + mutationKey: ["team_delete"], + onSuccess: () => { + router.reload(); + toast.success("Team has been deleted."); + }, + }); + + const onDelete = (id: number) => { + if (!window.confirm("Are you sure you want to delete this team?")) { + return; + } + deleteTeam(id); + }; + const handlePageChange = (page: number) => { if (page >= 1 && page <= totalPages) { setCurrentPage(page); @@ -102,8 +122,18 @@ export function TeamDataGrid({
- - + +
diff --git a/client/src/components/ui/Users/team-data-table-form.tsx b/client/src/components/ui/Users/team-data-table-form.tsx index 34bb898c..253dfcc5 100644 --- a/client/src/components/ui/Users/team-data-table-form.tsx +++ b/client/src/components/ui/Users/team-data-table-form.tsx @@ -1,5 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/router"; import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -21,6 +23,7 @@ import { } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { SelectSchool } from "@/components/ui/Users/select-school"; +import { usePostMutation } from "@/hooks/use-post-data"; import { cn } from "@/lib/utils"; import { createTeamSchema } from "@/types/team"; @@ -55,8 +58,21 @@ export function TeamDataTableForm() { name: "teams", }); + const router = useRouter(); + const { mutate: createTeam, isPending } = usePostMutation( + ["teams"], + "/team/teams/", + 1000, + { + onSuccess: () => { + toast.success("Teams created successfully!"); + router.push("/users/team/"); + }, + }, + ); + const onSubmit = (data: { teams: Team[] }) => { - console.log("Submitted data:", data.teams); + createTeam({ ...data.teams }); }; const commonTableHeadClasses = "w-auto text-white text-nowrap"; diff --git a/client/src/components/ui/checkbox.tsx b/client/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..139cf2f4 --- /dev/null +++ b/client/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/client/src/hooks/use-delete-data.ts b/client/src/hooks/use-delete-data.ts index 8b322d5f..6fbe4246 100644 --- a/client/src/hooks/use-delete-data.ts +++ b/client/src/hooks/use-delete-data.ts @@ -9,6 +9,20 @@ import { toast } from "sonner"; import api from "@/lib/api"; +interface UseDeleteMutationOptions< + TData, + TVariables, + TError = AxiosError<{ error: string; message: string }>, +> extends Omit< + UseMutationOptions, + "mutationKey" | "mutationFn" + > { + mutationKey: string[]; + queryKeys?: Array[]; + endpoint: string; + timeout?: number; +} + /** * Custom hook for performing a DELETE request mutation. * @@ -48,16 +62,17 @@ export const useDeleteMutation = < TData, TError = AxiosError<{ error: string; message: string }>, TVariables = void, ->( - mutationKey: string[], - endpoint: string, - queryKeys: Array[] = [], - timeout: number = 10000, // Default timeout of 10 seconds - args?: Omit< - UseMutationOptions, - "mutationKey" | "mutationFn" - >, -): UseMutationResult => { +>({ + mutationKey, + queryKeys = [], + endpoint, + timeout = 10000, + ...args +}: UseDeleteMutationOptions): UseMutationResult< + TData, + TError, + TVariables +> => { const queryClient = useQueryClient(); return useMutation({ @@ -70,7 +85,58 @@ export const useDeleteMutation = < // extract error message from BE response if (axios.isAxiosError(error) && error.response?.data) { const { message, error: detailedError } = error.response.data; - toast.error(message || detailedError || "Something went wrong"); + toast.error(detailedError || message || "Something went wrong"); + } + }, + onSuccess: (data, variables, context) => { + queryKeys.forEach((queryKey) => { + queryClient.invalidateQueries({ queryKey }); + }); + if (args?.onSuccess) args.onSuccess(data, variables, context); + }, + }); +}; + +interface useDynamicDeleteMutationOptions + extends Omit< + UseMutationOptions, + "mutationKey" | "mutationFn" + > { + baseUrl: string; + mutationKey: string[]; + queryKeys?: Array[]; // Optional query keys to invalidate after success + timeout?: number; // Optional timeout for the request +} + +export const useDynamicDeleteMutation = < + TData, + TVariables = number, + TError = AxiosError<{ error: string; message: string }>, +>({ + baseUrl, + mutationKey, + queryKeys = [], + timeout = 10000, + ...args +}: useDynamicDeleteMutationOptions< + TData, + TVariables, + TError +>): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + ...args, + mutationKey, + mutationFn: (id: TVariables) => { + // dynamically set the endpoint for the DELETE request using the provided id + return api.delete(`${baseUrl}/${id}/`, { timeout }); + }, + onError: (error: TError) => { + // extract error message from BE response + if (axios.isAxiosError(error) && error.response?.data) { + const { message, error: detailedError } = error.response.data; + toast.error(detailedError || message || "Something went wrong"); } }, onSuccess: (data, variables, context) => { diff --git a/client/src/hooks/use-post-data.ts b/client/src/hooks/use-post-data.ts index 31af9ba7..964d0938 100644 --- a/client/src/hooks/use-post-data.ts +++ b/client/src/hooks/use-post-data.ts @@ -61,7 +61,7 @@ export const usePostMutation = < // extract error message from BE response if (axios.isAxiosError(error) && error.response?.data) { const { message, error: detailedError } = error.response.data; - toast.error(message || detailedError || "Something went wrong"); + toast.error(detailedError || message || "Something went wrong"); } }, onSuccess: (data, details, context) => { diff --git a/client/src/hooks/use-put-data.ts b/client/src/hooks/use-put-data.ts index 90899eef..c5095380 100644 --- a/client/src/hooks/use-put-data.ts +++ b/client/src/hooks/use-put-data.ts @@ -9,6 +9,20 @@ import { toast } from "sonner"; import api from "@/lib/api"; +interface UsePutMutationOptions< + TData, + TVariables, + TError = AxiosError<{ error: string; message: string }>, +> extends Omit< + UseMutationOptions, + "mutationKey" | "mutationFn" + > { + mutationKey: string[]; + queryKeys: Array[]; + endpoint: string; + timeout?: number; +} + /** * Custom hook for performing a `PUT` request mutation with automatic cache invalidation and error handling. * @@ -50,29 +64,26 @@ export const usePutMutation = < TData, TVariables = unknown, TError = AxiosError<{ error: string; message: string }>, ->( - mutationKey: string[], - queryKeys: Array[], - endpoint: string, - timeout: number = 10000, // Default timeout of 10 seconds - args?: Omit< - UseMutationOptions, - "mutationKey" | "mutationFn" - >, -): UseMutationResult => { +>({ + mutationKey, + queryKeys, + endpoint, + timeout = 10000, + ...args +}: UsePutMutationOptions): UseMutationResult< + TData, + TError, + TVariables +> => { const queryClient = useQueryClient(); - return useMutation({ - ...args, mutationKey, - mutationFn: (variables: TVariables) => { - return api.put(endpoint, variables, { timeout }); - }, + mutationFn: (variables: TVariables) => + api.put(endpoint, variables, { timeout }), onError: (error: TError) => { - // extract error message from BE response if (axios.isAxiosError(error) && error.response?.data) { const { message, error: detailedError } = error.response.data; - toast.error(message || detailedError || "Something went wrong"); + toast.error(detailedError || message || "Something went wrong"); } }, onSuccess: (data, details, context) => { @@ -81,5 +92,6 @@ export const usePutMutation = < ); if (args?.onSuccess) args.onSuccess(data, details, context); }, + ...args, // Spread additional mutation options }); }; diff --git a/client/src/pages/api/test/competitions.ts b/client/src/pages/api/test/competitions.ts index 112c6a11..838930ad 100644 --- a/client/src/pages/api/test/competitions.ts +++ b/client/src/pages/api/test/competitions.ts @@ -12,41 +12,41 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { Competition } from "@/types/competition"; +import { Competition, QuizStatus } from "@/types/quiz"; // Mock data representing competition entries const mockCompetitions: Partial[] = [ { id: 1, name: "Competition01_2024", - status: "Published", - competition_time: new Date(`2025-01-30T10:00:00Z`), + status: QuizStatus.NormalPractice, + open_time_date: new Date(`2025-01-30T10:00:00Z`), }, { id: 2, name: "Competition02_2024", - status: "Unpublished", - competition_time: new Date(`2024-01-01T02:00:00Z`), + status: QuizStatus.Finished, + open_time_date: new Date(`2024-01-01T02:00:00Z`), }, { name: "Competition03_2024", - status: "Unpublished", - competition_time: new Date(`2025-01-10T18:00:00Z`), + status: QuizStatus.Ongoing, + open_time_date: new Date(`2025-01-10T18:00:00Z`), }, { name: "Competition04_2024", - status: "Published", - competition_time: new Date(`2025-04-15T13:00:00Z`), + status: QuizStatus.Upcoming, + open_time_date: new Date(`2025-04-15T13:00:00Z`), }, { name: "Competition05_2024", - status: "Published", - competition_time: new Date(`2025-02-01T20:00:00Z`), + status: QuizStatus.Finished, + open_time_date: new Date(`2025-02-01T20:00:00Z`), }, { name: "Competition06_2024", - status: "Unpublished", - competition_time: new Date(`2025-01-01T12:00:00Z`), + status: QuizStatus.Upcoming, + open_time_date: new Date(`2025-01-01T12:00:00Z`), }, ]; diff --git a/client/src/pages/api/users/index.ts b/client/src/pages/api/users/index.ts index c7025364..a5ddf217 100644 --- a/client/src/pages/api/users/index.ts +++ b/client/src/pages/api/users/index.ts @@ -12,7 +12,7 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { User } from "@/types/user"; +import { School, User } from "@/types/user"; /** * Mock data representing user entries. @@ -26,19 +26,19 @@ for (let i = 0; i < 9; i++) { id: i * 3 + 1, username: `adminMaster${i + 1}`, role: "admin", - school: "Greenfield High", + school: { name: "Greenfield High" } as School, }, { id: i * 3 + 2, username: `mathPro${i + 1}`, role: "teacher", - school: "Westwood Academy", + school: { name: "Westwood Academy" } as School, }, { id: i * 3 + 3, username: `scienceGeek${i + 1}`, role: "student", - school: "Northside School", + school: { name: "Northside School" } as School, }, ); } diff --git a/client/src/pages/api/users/me.ts b/client/src/pages/api/users/me.ts deleted file mode 100644 index 2aa422f0..00000000 --- a/client/src/pages/api/users/me.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * API Route Handler for fetching user data. - * - * This handler serves mock user data for demonstration purposes. - * It is accessible at the `/api/users/me` endpoint. - * - * @fileoverview Provides an API route for retrieving mock user data. - * @module api/users/me - */ - -import { NextApiRequest, NextApiResponse } from "next"; - -import { User } from "@/types/user"; - -/** - * Mock user data for demonstration purposes. - * - * @constant {User} - */ -const mockUser: User = { - id: 1, - username: "johndoe", - email: "johndoe@example.com", - first_name: "John", - last_name: "Doe", - role: "admin", - school: "University of Western Australia", -}; - -/** - * API route handler to fetch user data. - * - * @function handler - * @param {NextApiRequest} _req - The incoming API request object. Ignored in this mock implementation. - * @param {NextApiResponse} res - The outgoing API response object containing the mock user data. - * - * @returns {void} Responds with a status code of 200 and the mock user data in JSON format. - * - * @example - * // Example response payload: - * // { - * // "id": 1, - * // "username": "johndoe", - * // "email": "johndoe@example.com", - * // "first_name": "John", - * // "last_name": "Doe", - * // "role": "admin", - * // "school": "University of Western Australia" - * // } - */ -export default function handler( - _req: NextApiRequest, - res: NextApiResponse, -): void { - res.status(200).json(mockUser); -} diff --git a/client/src/pages/users/school/[id].tsx b/client/src/pages/users/school/[id].tsx new file mode 100644 index 00000000..2a739aea --- /dev/null +++ b/client/src/pages/users/school/[id].tsx @@ -0,0 +1,162 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { LoaderCircleIcon } from "lucide-react"; +import { useRouter } from "next/router"; +import React from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { WaitingLoader } from "@/components/ui/loading"; +import { SelectSchoolType } from "@/components/ui/Users/select-school"; +import { useFetchData } from "@/hooks/use-fetch-data"; +import { usePutMutation } from "@/hooks/use-put-data"; +import { createSchoolSchema, School } from "@/types/user"; + +type UpdateSchool = z.infer; + +export default function Edit() { + const router = useRouter(); + const schoolId = parseInt(router.query.id as string); + + const { data, isLoading, isError, error } = useFetchData({ + queryKey: [`users.schools.${schoolId}`], + endpoint: `/users/schools/${schoolId}/`, + enabled: !isNaN(schoolId), + }); + + if (isLoading || !data) return ; + else if (isError) return
Error: {error?.message}
; + else return ; +} + +function EditSchoolForm({ school }: { school: School }) { + const router = useRouter(); + + const mutationKey = ["school_update", `${school.id}`]; + const { mutate: updateSchool, isPending } = usePutMutation({ + mutationKey: mutationKey, + queryKeys: [mutationKey, ["users.schools"]], + endpoint: `/users/schools/${school.id}/`, + onSuccess: () => { + router.reload(); + toast.success("School has been updated."); + }, + }); + + const updateForm = useForm({ + resolver: zodResolver(createSchoolSchema), + defaultValues: { + name: school.name, + type: school.type, + is_country: school.is_country, + abbreviation: school.abbreviation, + }, + }); + + const onSubmit = (data: UpdateSchool) => { + updateSchool({ + name: data.name, + type: data.type, + is_country: data.is_country, + abbreviation: data.abbreviation, + }); + }; + + const requiredStar = *; + return ( +
+ +

Update School

+ +
+ {/* Name */} + ( + + Name {requiredStar} + + + + + + )} + /> + {/* Type */} + ( + + Type {requiredStar} + + + + + + )} + /> + {/* Is Country */} + ( + + Is Country +
+ + + + {field.value ? "Yes" : "No"} +
+ +
+ )} + /> + {/* Abbreviation */} + ( + + Abbreviation {requiredStar} + + + + + + )} + /> + +
+ +
+
+
+ + ); +} diff --git a/client/src/pages/users/create_school.tsx b/client/src/pages/users/school/create.tsx similarity index 100% rename from client/src/pages/users/create_school.tsx rename to client/src/pages/users/school/create.tsx diff --git a/client/src/pages/users/school.tsx b/client/src/pages/users/school/index.tsx similarity index 97% rename from client/src/pages/users/school.tsx rename to client/src/pages/users/school/index.tsx index f500976b..871d660a 100644 --- a/client/src/pages/users/school.tsx +++ b/client/src/pages/users/school/index.tsx @@ -61,7 +61,7 @@ export default function SchoolList() { onSearch={handleFilterChange} /> diff --git a/client/src/pages/users/team/[id].tsx b/client/src/pages/users/team/[id].tsx new file mode 100644 index 00000000..c3ebbc7e --- /dev/null +++ b/client/src/pages/users/team/[id].tsx @@ -0,0 +1,140 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { LoaderCircleIcon } from "lucide-react"; +import { useRouter } from "next/router"; +import React from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { WaitingLoader } from "@/components/ui/loading"; +import { Textarea } from "@/components/ui/textarea"; +import { SelectSchool } from "@/components/ui/Users/select-school"; +import { useFetchData } from "@/hooks/use-fetch-data"; +import { usePutMutation } from "@/hooks/use-put-data"; +import { createTeamSchema, Team } from "@/types/team"; + +type UpdateTeam = z.infer; + +export default function Edit() { + const router = useRouter(); + const teamId = parseInt(router.query.id as string); + + const { data, isLoading, isError, error } = useFetchData({ + queryKey: [`team.teams.${teamId}`], + endpoint: `/team/teams/${teamId}/`, + enabled: !isNaN(teamId), + }); + + if (isLoading || !data) return ; + else if (isError) return
Error: {error?.message}
; + else return ; +} + +function EditTeamForm({ team }: { team: Team }) { + const router = useRouter(); + + const mutationKey = ["team_update", `${team.id}`]; + const { mutate: updateTeam, isPending } = usePutMutation({ + mutationKey: mutationKey, + queryKeys: [mutationKey, ["team.teams"]], + endpoint: `/team/teams/${team.id}/`, + onSuccess: () => { + router.reload(); + toast.success("Team has been updated."); + }, + }); + + const updateForm = useForm({ + resolver: zodResolver(createTeamSchema), + defaultValues: { + name: team.name, + school_id: team.school.id, + description: team.description, + }, + }); + + const onSubmit = (data: UpdateTeam) => { + updateTeam({ + name: data.name, + school_id: data.school_id, + description: data.description, + }); + }; + + const requiredStar = *; + return ( +
+ +

Update Team

+ +
+ {/* Name */} + ( + + Name {requiredStar} + + + + + + )} + /> + {/* School */} + ( + + School {requiredStar} + + + + + + )} + /> + {/* Description */} + ( + + Description {requiredStar} + +