From dad0063f443bf34054bbc3a95be89e8aede05da0 Mon Sep 17 00:00:00 2001 From: Stepan Date: Tue, 6 Jan 2026 17:07:15 +0100 Subject: [PATCH] Added Random Form test and hooks for UserTest api --- .env.example | 3 +- api/_client.ts | 24 +++++++ api/auth.ts | 3 + api/userTests.ts | 47 +++++++++++++- app/(tabs)/index.tsx | 29 +++++++++ app/login.tsx | 9 ++- app/tests/[id].tsx | 30 +++++++-- components/ui/custom-button.tsx | 25 +++++++ components/ui/login-form/index.tsx | 9 +-- components/ui/random-test-form/index.tsx | 83 ++++++++++++++++++++++++ 10 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 components/ui/custom-button.tsx create mode 100644 components/ui/random-test-form/index.tsx diff --git a/.env.example b/.env.example index 6bc369b..58e3661 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ -EXPO_BACKEND_BASE_URL= \ No newline at end of file +EXPO_PUBLIC_BACKEND_BASE_URL= +EXPO_PUBLIC_FRONTEND_REGISTER_URL= \ No newline at end of file diff --git a/api/_client.ts b/api/_client.ts index 69e176c..bfaad0c 100644 --- a/api/_client.ts +++ b/api/_client.ts @@ -101,3 +101,27 @@ export const post_login = async (email: string, password: string) => { return response.data; }; + +export const post_start_random_test = async (min_difficulty: number, max_difficulty: number, category_id: number) => { + const response = await axiosInstance.post<{data: UserTestResponse}>( + "/api/user-tests/", + { + category_id, + min_difficulty, + max_difficulty, + } + ); + + return response.data; +} + +export const post_start_test = async (test_id: number) => { + const response = await axiosInstance.post<{ data: UserTestResponse }>( + "/api/user-tests/by-test/", + { + test_id + } + ) + + return response.data; +} \ No newline at end of file diff --git a/api/auth.ts b/api/auth.ts index ea36bf2..57ad3c5 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -29,6 +29,9 @@ export const useLoginMutation = () => { queryClient.invalidateQueries({ queryKey: ["current-user"] }); + queryClient.invalidateQueries({ + queryKey: ["user-tests"] + }); } }); } \ No newline at end of file diff --git a/api/userTests.ts b/api/userTests.ts index 7f0e51a..85abf60 100644 --- a/api/userTests.ts +++ b/api/userTests.ts @@ -1,5 +1,6 @@ -import { useQuery } from "@tanstack/react-query"; -import { get_my_user_tests, get_user_test } from "./_client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { get_my_user_tests, get_user_test, post_start_random_test, post_start_test } from "./_client"; export const useMyTests = () => { return useQuery({ @@ -13,10 +14,50 @@ export const useMyTests = () => { export const useUserTest = (id: number) => { return useQuery({ - queryKey: ['test', id], + queryKey: ['user-test', id], queryFn: async () => { const response = await get_user_test(id); return response.data; } }) +} + +interface RandomUserTestAttrs { + min: number, + max: number, + category_id: number +} +export const useRandomUserTestMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ min, max, category_id }: RandomUserTestAttrs) => { + const response = await post_start_random_test(min, max, category_id); + + queryClient.invalidateQueries({ + queryKey: ["user-tests"] + }); + + return response.data; + } + }) +} + +interface StartTestAttrs { + test_id: number +} +export const useStartTestMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ test_id }: StartTestAttrs) => { + const response = await post_start_test(test_id); + + queryClient.invalidateQueries({ + queryKey: ["user-tests"] + }); + + return response.data; + } + }) } \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 37f55c8..c30e80e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -2,23 +2,46 @@ import { Image } from "expo-image"; import { StyleSheet } from "react-native"; import { useQuestions } from "@/api"; +import { useRandomUserTestMutation } from "@/api/userTests"; import { HelloWave } from "@/components/hello-wave"; import ParallaxScrollView from "@/components/parallax-scroll-view"; import useAuthContext from "@/components/providers/auth-provider/hook"; import Question from "@/components/question"; import { ThemedView } from "@/components/themed-view"; +import Panel from "@/components/ui/panel"; +import RandomTestForm from "@/components/ui/random-test-form"; import { ThemedText } from "@/components/ui/themed-text"; +import getErrorAxiosMessage from "@/utils/get-error-axios-message"; import { router } from "expo-router"; +import { useToast } from "react-native-toast-notifications"; export default function HomeScreen() { const { data: questions, isLoading: isLoadingQuestions } = useQuestions(); const { user, isAuthorized } = useAuthContext(); + const { mutate, isPending } = useRandomUserTestMutation(); + + const toast = useToast(); const username = isAuthorized ? user!.username : "Guest"; const questionsLoaded = !isLoadingQuestions && questions && questions.meta.total > 0; + const handleStartRandomTest = (min: number, max: number, catId: number) => { + mutate({ + min, + max, + category_id: catId + }, { + onSuccess: (data) => { + toast.show(`New user test created: ${data.id}` , { type: "success" }) + }, + onError: (error) => { + toast.show(getErrorAxiosMessage(error), { type: "danger" }) + } + }); + } + return ( + + Take a random test + + + + Questions {questionsLoaded && questions.data.map((question) => ( diff --git a/app/login.tsx b/app/login.tsx index 51572d8..2dbf7c8 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,10 +1,13 @@ import useAuthContext from "@/components/providers/auth-provider/hook"; +import { Button, ButtonText } from "@/components/ui/button"; import Content from "@/components/ui/content"; +import { Divider } from "@/components/ui/divider"; import LoginForm, { LoginFormData } from "@/components/ui/login-form"; import Panel from "@/components/ui/panel"; import { ThemedText } from "@/components/ui/themed-text"; import getErrorAxiosMessage from "@/utils/get-error-axios-message"; import { Stack } from "expo-router"; +import { Linking } from "react-native"; import { useToast } from "react-native-toast-notifications"; const LoginScreen = () => { @@ -28,8 +31,12 @@ const LoginScreen = () => { Login - Hello there + Welcome back! Please sign in to continue. + + diff --git a/app/tests/[id].tsx b/app/tests/[id].tsx index 9b35559..d06ee72 100644 --- a/app/tests/[id].tsx +++ b/app/tests/[id].tsx @@ -1,8 +1,11 @@ import { useTest } from "@/api/tests"; +import { useStartTestMutation } from "@/api/userTests"; import Question from "@/components/question"; import Test from "@/components/test"; -import { Button, ButtonText } from "@/components/ui/button"; +import { ButtonText } from "@/components/ui/button"; import Content from "@/components/ui/content"; +import CustomButton from "@/components/ui/custom-button"; +import getErrorAxiosMessage from "@/utils/get-error-axios-message"; import { Stack, useLocalSearchParams } from "expo-router"; import { View } from "react-native"; import { useToast } from "react-native-toast-notifications"; @@ -11,6 +14,7 @@ const TestScreen = () => { const { id: idParam } = useLocalSearchParams<{ id: string }>(); const id = +idParam; const { data, isLoading } = useTest(id); + const { mutate, isPending } = useStartTestMutation(); const toast = useToast(); if (!data) @@ -22,7 +26,17 @@ const TestScreen = () => { ); const handleStartTest = () => { - toast.show("Test started!", { type: "success" }); + mutate( + { + test_id: data.id, + }, + { + onSuccess: (data) => {}, + onError: (error) => { + toast.show(getErrorAxiosMessage(error), { type: "danger" }); + }, + } + ); }; return ( @@ -33,9 +47,15 @@ const TestScreen = () => { - {data.is_available && } + {data.is_available && ( + + Start Test + + )} {data.questions?.map((question) => ( diff --git a/components/ui/custom-button.tsx b/components/ui/custom-button.tsx new file mode 100644 index 0000000..e8544f9 --- /dev/null +++ b/components/ui/custom-button.tsx @@ -0,0 +1,25 @@ +import { ComponentProps } from "react"; +import { Button, ButtonSpinner } from "./button"; + +type CustomButtonAttrs = { + isLoading?: boolean; +} & ComponentProps; + +const CustomButton = ({ + isLoading = false, + children, + ...rest +}: CustomButtonAttrs) => { + return ( + + ); +}; + +export default CustomButton; diff --git a/components/ui/login-form/index.tsx b/components/ui/login-form/index.tsx index 26aa38d..0678a36 100644 --- a/components/ui/login-form/index.tsx +++ b/components/ui/login-form/index.tsx @@ -2,7 +2,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from "react-hook-form"; import { View } from "react-native"; import { z } from 'zod'; -import { Button, ButtonSpinner, ButtonText } from "../button"; +import { ButtonText } from "../button"; +import CustomButton from '../custom-button'; import FormInput from "../form-input"; export type LoginFormData = { @@ -54,9 +55,9 @@ const LoginForm = ({ onSubmit, isLoading=false }: LoginFormProps) => { type: "password" }} /> - + + Sign In + ); }; diff --git a/components/ui/random-test-form/index.tsx b/components/ui/random-test-form/index.tsx new file mode 100644 index 0000000..2075df9 --- /dev/null +++ b/components/ui/random-test-form/index.tsx @@ -0,0 +1,83 @@ +import useTaxonomyContext from "@/components/providers/taxonomy-provider/hook"; +import getCategoryOptions from "@/utils/get-category-options"; +import { useState } from "react"; +import { View } from "react-native"; +import { useToast } from "react-native-toast-notifications"; +import { ButtonText } from "../button"; +import CustomButton from "../custom-button"; +import CustomSelect from "../custom-select"; +import { ThemedText } from "../themed-text"; + +interface RandomTestFormProps { + onStartRandomTest?: (min: number, max: number, catId: number) => void; + isLoading?: boolean; +} + +const RandomTestForm = ({ onStartRandomTest, isLoading }: RandomTestFormProps) => { + const [minDifficulty, setMinDifficulty] = useState(0); + const [maxDifficulty, setMaxDifficulty] = useState(10); + const [categoryId, setCategoryId] = useState(undefined); + const { categories } = useTaxonomyContext(); + const toast = useToast(); + + const catOptions = getCategoryOptions(categories); + const minOptions = Array.from({ length: 9 }, (_, i) => { + return { label: String(i), value: String(i) }; + }); + const maxOptions = Array.from({ length: 10 }, (_, i) => { + return { label: String(i), value: String(i) }; + }); + + const handleStartRandomTest = () => { + if(minDifficulty >= maxDifficulty) { + toast.show("Minimum difficulty must be less than maximum difficulty.", { type: "danger" }); + return; + } + if(categoryId === undefined) { + toast.show("Category must be selected", { type: "danger" }); + return; + } + onStartRandomTest?.(minDifficulty, maxDifficulty, categoryId); + } + const handleSelect = (setStateFunction: (v: number) => void, value: string) => { + const valueNum = +value; + setStateFunction(valueNum); + } + + return ( + + + + Min Difficulty + handleSelect(setMinDifficulty, value)} + /> + + + Max Difficulty + handleSelect(setMaxDifficulty, value)} + /> + + + + Category + handleSelect(setCategoryId, value)} + + /> + + handleStartRandomTest()}> + Start test + + + ) +} + +export default RandomTestForm; \ No newline at end of file