Added Random Form test and hooks for UserTest api

This commit is contained in:
Stepan 2026-01-06 17:07:15 +01:00
parent 8ac1f6be34
commit dad0063f44
10 changed files with 248 additions and 14 deletions

View File

@ -1 +1,2 @@
EXPO_BACKEND_BASE_URL= EXPO_PUBLIC_BACKEND_BASE_URL=
EXPO_PUBLIC_FRONTEND_REGISTER_URL=

View File

@ -101,3 +101,27 @@ export const post_login = async (email: string, password: string) => {
return response.data; 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;
}

View File

@ -29,6 +29,9 @@ export const useLoginMutation = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["current-user"] queryKey: ["current-user"]
}); });
queryClient.invalidateQueries({
queryKey: ["user-tests"]
});
} }
}); });
} }

View File

@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { get_my_user_tests, get_user_test } from "./_client"; import { AxiosError } from "axios";
import { get_my_user_tests, get_user_test, post_start_random_test, post_start_test } from "./_client";
export const useMyTests = () => { export const useMyTests = () => {
return useQuery({ return useQuery({
@ -13,10 +14,50 @@ export const useMyTests = () => {
export const useUserTest = (id: number) => { export const useUserTest = (id: number) => {
return useQuery({ return useQuery({
queryKey: ['test', id], queryKey: ['user-test', id],
queryFn: async () => { queryFn: async () => {
const response = await get_user_test(id); const response = await get_user_test(id);
return response.data; return response.data;
} }
}) })
}
interface RandomUserTestAttrs {
min: number,
max: number,
category_id: number
}
export const useRandomUserTestMutation = () => {
const queryClient = useQueryClient();
return useMutation<any, AxiosError, RandomUserTestAttrs>({
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<any, AxiosError, StartTestAttrs>({
mutationFn: async ({ test_id }: StartTestAttrs) => {
const response = await post_start_test(test_id);
queryClient.invalidateQueries({
queryKey: ["user-tests"]
});
return response.data;
}
})
} }

View File

@ -2,23 +2,46 @@ import { Image } from "expo-image";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { useQuestions } from "@/api"; import { useQuestions } from "@/api";
import { useRandomUserTestMutation } from "@/api/userTests";
import { HelloWave } from "@/components/hello-wave"; import { HelloWave } from "@/components/hello-wave";
import ParallaxScrollView from "@/components/parallax-scroll-view"; import ParallaxScrollView from "@/components/parallax-scroll-view";
import useAuthContext from "@/components/providers/auth-provider/hook"; import useAuthContext from "@/components/providers/auth-provider/hook";
import Question from "@/components/question"; import Question from "@/components/question";
import { ThemedView } from "@/components/themed-view"; 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 { ThemedText } from "@/components/ui/themed-text";
import getErrorAxiosMessage from "@/utils/get-error-axios-message";
import { router } from "expo-router"; import { router } from "expo-router";
import { useToast } from "react-native-toast-notifications";
export default function HomeScreen() { export default function HomeScreen() {
const { data: questions, isLoading: isLoadingQuestions } = useQuestions(); const { data: questions, isLoading: isLoadingQuestions } = useQuestions();
const { user, isAuthorized } = useAuthContext(); const { user, isAuthorized } = useAuthContext();
const { mutate, isPending } = useRandomUserTestMutation();
const toast = useToast();
const username = isAuthorized ? user!.username : "Guest"; const username = isAuthorized ? user!.username : "Guest";
const questionsLoaded = const questionsLoaded =
!isLoadingQuestions && questions && questions.meta.total > 0; !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 ( return (
<ParallaxScrollView <ParallaxScrollView
headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }} headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }}
@ -34,6 +57,12 @@ export default function HomeScreen() {
<HelloWave /> <HelloWave />
</ThemedView> </ThemedView>
<Panel>
<ThemedText className="text-center mb-2" type="subtitle">Take a random test</ThemedText>
<RandomTestForm isLoading={isPending} onStartRandomTest={handleStartRandomTest} />
</Panel>
<ThemedText type="subtitle">Questions</ThemedText> <ThemedText type="subtitle">Questions</ThemedText>
{questionsLoaded && {questionsLoaded &&
questions.data.map((question) => ( questions.data.map((question) => (

View File

@ -1,10 +1,13 @@
import useAuthContext from "@/components/providers/auth-provider/hook"; import useAuthContext from "@/components/providers/auth-provider/hook";
import { Button, ButtonText } from "@/components/ui/button";
import Content from "@/components/ui/content"; import Content from "@/components/ui/content";
import { Divider } from "@/components/ui/divider";
import LoginForm, { LoginFormData } from "@/components/ui/login-form"; import LoginForm, { LoginFormData } from "@/components/ui/login-form";
import Panel from "@/components/ui/panel"; import Panel from "@/components/ui/panel";
import { ThemedText } from "@/components/ui/themed-text"; import { ThemedText } from "@/components/ui/themed-text";
import getErrorAxiosMessage from "@/utils/get-error-axios-message"; import getErrorAxiosMessage from "@/utils/get-error-axios-message";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Linking } from "react-native";
import { useToast } from "react-native-toast-notifications"; import { useToast } from "react-native-toast-notifications";
const LoginScreen = () => { const LoginScreen = () => {
@ -28,8 +31,12 @@ const LoginScreen = () => {
<Content> <Content>
<Panel> <Panel>
<ThemedText type="subtitle" className="text-center mb-2">Login</ThemedText> <ThemedText type="subtitle" className="text-center mb-2">Login</ThemedText>
<ThemedText className="text-center mb-2">Hello there</ThemedText> <ThemedText className="text-center mb-2">Welcome back! Please sign in to continue.</ThemedText>
<LoginForm onSubmit={onLoginSubmit} isLoading={isPendingLogin} /> <LoginForm onSubmit={onLoginSubmit} isLoading={isPendingLogin} />
<Divider className="mb-3 mt-3" />
<Button variant="link" onPress={() => Linking.openURL(process.env.EXPO_PUBLIC_FRONTEND_REGISTER_URL as string)}>
<ButtonText>Create a new account</ButtonText>
</Button>
</Panel> </Panel>
</Content> </Content>
</> </>

View File

@ -1,8 +1,11 @@
import { useTest } from "@/api/tests"; import { useTest } from "@/api/tests";
import { useStartTestMutation } from "@/api/userTests";
import Question from "@/components/question"; import Question from "@/components/question";
import Test from "@/components/test"; 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 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 { Stack, useLocalSearchParams } from "expo-router";
import { View } from "react-native"; import { View } from "react-native";
import { useToast } from "react-native-toast-notifications"; import { useToast } from "react-native-toast-notifications";
@ -11,6 +14,7 @@ const TestScreen = () => {
const { id: idParam } = useLocalSearchParams<{ id: string }>(); const { id: idParam } = useLocalSearchParams<{ id: string }>();
const id = +idParam; const id = +idParam;
const { data, isLoading } = useTest(id); const { data, isLoading } = useTest(id);
const { mutate, isPending } = useStartTestMutation();
const toast = useToast(); const toast = useToast();
if (!data) if (!data)
@ -22,7 +26,17 @@ const TestScreen = () => {
); );
const handleStartTest = () => { const handleStartTest = () => {
toast.show("Test started!", { type: "success" }); mutate(
{
test_id: data.id,
},
{
onSuccess: (data) => {},
onError: (error) => {
toast.show(getErrorAxiosMessage(error), { type: "danger" });
},
}
);
}; };
return ( return (
@ -33,9 +47,15 @@ const TestScreen = () => {
<Test test={data} /> <Test test={data} />
</View> </View>
{data.is_available && <Button className="mb-3" onPress={handleStartTest}> {data.is_available && (
<ButtonText>Start Test</ButtonText> <CustomButton
</Button>} className="mb-3"
onPress={handleStartTest}
isLoading={isPending}
>
<ButtonText>Start Test</ButtonText>
</CustomButton>
)}
<View className="gap-4"> <View className="gap-4">
{data.questions?.map((question) => ( {data.questions?.map((question) => (

View File

@ -0,0 +1,25 @@
import { ComponentProps } from "react";
import { Button, ButtonSpinner } from "./button";
type CustomButtonAttrs = {
isLoading?: boolean;
} & ComponentProps<typeof Button>;
const CustomButton = ({
isLoading = false,
children,
...rest
}: CustomButtonAttrs) => {
return (
<Button
className={isLoading ? "opacity-70" : ""}
disabled={isLoading}
{...rest}
>
{isLoading && <ButtonSpinner />}
<>{children}</>
</Button>
);
};
export default CustomButton;

View File

@ -2,7 +2,8 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { View } from "react-native"; import { View } from "react-native";
import { z } from 'zod'; import { z } from 'zod';
import { Button, ButtonSpinner, ButtonText } from "../button"; import { ButtonText } from "../button";
import CustomButton from '../custom-button';
import FormInput from "../form-input"; import FormInput from "../form-input";
export type LoginFormData = { export type LoginFormData = {
@ -54,9 +55,9 @@ const LoginForm = ({ onSubmit, isLoading=false }: LoginFormProps) => {
type: "password" type: "password"
}} }}
/> />
<Button className={isLoading ? "opacity-70" : ""} disabled={isLoading} onPress={handleSubmit(onSubmitHandler)}> <CustomButton isLoading={isLoading} onPress={handleSubmit(onSubmitHandler)}>
{isLoading && <ButtonSpinner />}<ButtonText>Sign In</ButtonText> <ButtonText>Sign In</ButtonText>
</Button> </CustomButton>
</View> </View>
); );
}; };

View File

@ -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<number|undefined>(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 (
<View>
<View className="flex-row gap-3 mb-3">
<View className="flex-1">
<ThemedText className="mb-1" type="defaultSemiBold">Min Difficulty</ThemedText>
<CustomSelect
options={minOptions}
selectedValue={String(minDifficulty)}
onValueChange={(value) => handleSelect(setMinDifficulty, value)}
/>
</View>
<View className="flex-1">
<ThemedText className="mb-1" type="defaultSemiBold">Max Difficulty</ThemedText>
<CustomSelect
options={maxOptions}
selectedValue={String(maxDifficulty)}
onValueChange={(value) => handleSelect(setMaxDifficulty, value)}
/>
</View>
</View>
<View className="mb-3">
<ThemedText className="mb-1" type="defaultSemiBold">Category</ThemedText>
<CustomSelect
options={catOptions}
placeholder="Select category"
onValueChange={(value) => handleSelect(setCategoryId, value)}
/>
</View>
<CustomButton isLoading={isLoading} onPress={() => handleStartRandomTest()}>
<ButtonText>Start test</ButtonText>
</CustomButton>
</View>
)
}
export default RandomTestForm;