Added UserTest single and list

This commit is contained in:
Stepan 2026-01-06 15:56:53 +01:00
parent 59298e1581
commit 8ac1f6be34
16 changed files with 370 additions and 28 deletions

View File

@ -74,10 +74,10 @@ export const get_test = async (id: number) => {
return response.data; return response.data;
} }
export const get_user_tests = async () => { export const get_my_user_tests = async () => {
const response = await axiosInstance.get<{ const response = await axiosInstance.get<{
data: UserTestResponse[]; data: UserTestResponse[];
}>("/api/user-tests/"); }>("/api/user-tests/me/");
return response.data; return response.data;
} }

View File

@ -29,7 +29,7 @@ export interface QuestionResponse {
type: QuestionTypes; type: QuestionTypes;
difficulty: number; difficulty: number;
variants?: QuestionVariant[]; variants: QuestionVariant[];
correct_answers: number[]|string[]; correct_answers: number[]|string[];
category_id: number; category_id: number;
@ -63,7 +63,8 @@ export interface TestResponse {
author_id: number; author_id: number;
author: UserResponse; author: UserResponse;
questions: QuestionResponse[]; questions?: QuestionResponse[];
questions_count: number;
closed_at: string; closed_at: string;
@ -95,10 +96,9 @@ export interface UserTestResponse {
is_completed: boolean; is_completed: boolean;
score: number; score: number;
is_available: boolean; is_available: boolean;
answers: AnswerResponse[]; answers: AnswerResponse[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@ -1,17 +1,17 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { get_user_test, get_user_tests } from "./_client"; import { get_my_user_tests, get_user_test } from "./_client";
export const useTests = () => { export const useMyTests = () => {
return useQuery({ return useQuery({
queryKey: ['user-tests'], queryKey: ['user-tests'],
queryFn: async () => { queryFn: async () => {
const response = await get_user_tests(); const response = await get_my_user_tests();
return response.data; return response.data;
} }
}) })
} }
export const useTest = (id: number) => { export const useUserTest = (id: number) => {
return useQuery({ return useQuery({
queryKey: ['test', id], queryKey: ['test', id],
queryFn: async () => { queryFn: async () => {

View File

@ -1,9 +1,10 @@
import useAuthContext from "@/components/providers/auth-provider/hook";
import useAuthContext from '@/components/providers/auth-provider/hook'; import { ButtonText } from "@/components/ui/button";
import { Button, ButtonText } from '@/components/ui/button'; import ButtonSection from "@/components/ui/button-section";
import Content from '@/components/ui/content'; import Content from "@/components/ui/content";
import { Divider } from '@/components/ui/divider'; import { Divider } from "@/components/ui/divider";
import UserHeader from '@/components/user-header'; import UserHeader from "@/components/user-header";
import { router } from "expo-router";
export default function MeScreen() { export default function MeScreen() {
const { user, logout, isAuthorized } = useAuthContext(); const { user, logout, isAuthorized } = useAuthContext();
@ -12,9 +13,25 @@ export default function MeScreen() {
<Content> <Content>
<UserHeader user={user} /> <UserHeader user={user} />
<Divider className='mt-3 mb-3' /> <Divider className="mt-3 mb-3" />
{isAuthorized && <Button action="negative" onPress={() => logout()}><ButtonText>Logout</ButtonText></Button>} {isAuthorized && (
<>
<ButtonSection onPress={() => router.push('/me/user-tests')}>
<ButtonText>My Tests</ButtonText>
</ButtonSection>
<Divider className="mt-3 mb-3" />
<ButtonSection
action="negative"
onPress={() => logout()}
variant="solid"
>
<ButtonText>Logout</ButtonText>
</ButtonSection>
</>
)}
</Content> </Content>
); );
} }

View File

@ -3,11 +3,13 @@ import { useTests } from '@/api/tests';
import { TestResponse } from '@/api/types'; import { TestResponse } from '@/api/types';
import useTaxonomyContext from '@/components/providers/taxonomy-provider/hook'; import useTaxonomyContext from '@/components/providers/taxonomy-provider/hook';
import Test from '@/components/test'; import Test from '@/components/test';
import { Button, ButtonText } from '@/components/ui/button';
import Content from '@/components/ui/content'; import Content from '@/components/ui/content';
import CustomSelect from '@/components/ui/custom-select'; import CustomSelect from '@/components/ui/custom-select';
import PaginationList from '@/components/ui/pagination-list'; import PaginationList from '@/components/ui/pagination-list';
import { ThemedText } from '@/components/ui/themed-text'; import { ThemedText } from '@/components/ui/themed-text';
import getCategoryOptions from '@/utils/get-category-options'; import getCategoryOptions from '@/utils/get-category-options';
import { FontAwesome5 } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { ScrollView, View } from 'react-native'; import { ScrollView, View } from 'react-native';
@ -39,13 +41,17 @@ export default function TestsScreen() {
<Content ref={scrollRef}> <Content ref={scrollRef}>
<ThemedText type="title" className='mb-3'>Tests</ThemedText> <ThemedText type="title" className='mb-3'>Tests</ThemedText>
<View className="mb-4"> <View className="mb-4 flex-row">
<CustomSelect <CustomSelect
className="mr-2 flex-1"
options={categoryOptions} options={categoryOptions}
noneOption={{ label: "All categories", value: "all" }} noneOption={{ label: "All categories", value: "all" }}
selectedValue={category ? `${category}` : undefined} selectedValue={category ? `${category}` : undefined}
onValueChange={handleChangeCategory} onValueChange={handleChangeCategory}
placeholder="Select category" /> placeholder="Select category" />
<Button>
<ButtonText><FontAwesome5 name="qrcode" size={18} /></ButtonText>
</Button>
</View> </View>
<PaginationList<TestResponse> <PaginationList<TestResponse>

61
app/me/user-tests.tsx Normal file
View File

@ -0,0 +1,61 @@
import { useMyTests } from "@/api/userTests";
import Content from "@/components/ui/content";
import CustomSelect from "@/components/ui/custom-select";
import UserTest from "@/components/user-test";
import { getUserTestStatus, TestStatus } from "@/utils/get-user-test-status";
import { router, Stack } from "expo-router";
import { useMemo, useState } from "react";
import { View } from "react-native";
const filterSelectOptions = [
{ label: TestStatus.Completed, value: TestStatus.Completed },
{ label: TestStatus.InProgress, value: TestStatus.InProgress },
{ label: TestStatus.Expired, value: TestStatus.Expired },
];
const MeUserTestsScreen = () => {
const { data: userTests } = useMyTests();
const [selectedStatus, setStatus] = useState<string>("all");
const filteredUserTests = useMemo(() => {
if(!userTests) return [];
if(selectedStatus !== "all") {
return userTests.filter((userTest) => getUserTestStatus(userTest) === selectedStatus);
}
return userTests;
}, [userTests, selectedStatus]);
if(!userTests)
return (
<>
<Stack.Screen options={{ title: `My tests` }} />
<Content>
<UserTest />
</Content>
</>
);
return (
<>
<Stack.Screen options={{ title: `My tests` }} />
<Content>
<View className="mb-4">
<CustomSelect
options={filterSelectOptions}
noneOption={{ label: "All tests", value: "all" }}
placeholder="Filter by status"
selectedValue={selectedStatus}
onValueChange={(value) => setStatus(value)}
/>
</View>
{filteredUserTests.map((userTest) => (
<View key={userTest.id} className="mb-3">
<UserTest userTest={userTest} onPress={() => router.push(`/user-tests/${userTest.id}`)} />
</View>
))}
</Content>
</>
)
}
export default MeUserTestsScreen;

View File

@ -5,11 +5,13 @@ import { Button, ButtonText } from "@/components/ui/button";
import Content from "@/components/ui/content"; import Content from "@/components/ui/content";
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";
const TestScreen = () => { 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 toast = useToast();
if (!data) if (!data)
return ( return (
@ -19,7 +21,9 @@ const TestScreen = () => {
</> </>
); );
const handleStartTest = () => {}; const handleStartTest = () => {
toast.show("Test started!", { type: "success" });
};
return ( return (
<> <>
@ -34,7 +38,7 @@ const TestScreen = () => {
</Button>} </Button>}
<View className="gap-4"> <View className="gap-4">
{data.questions.map((question) => ( {data.questions?.map((question) => (
<Question key={question.id} question={question} /> <Question key={question.id} question={question} />
))} ))}
</View> </View>

95
app/user-tests/[id].tsx Normal file
View File

@ -0,0 +1,95 @@
import { AnswerResponse, QuestionVariant } from "@/api/types";
import { useUserTest } from "@/api/userTests";
import Question from "@/components/question";
import Answer from "@/components/ui/answer";
import { Button, ButtonText } from "@/components/ui/button";
import Content from "@/components/ui/content";
import { Divider } from "@/components/ui/divider";
import UserTest from "@/components/user-test";
import { getUserTestStatus, TestStatus } from "@/utils/get-user-test-status";
import getUserTestTitle from "@/utils/get-user-test-title";
import { Stack, useLocalSearchParams } from "expo-router";
import { View } from "react-native";
const UserTestScreen = () => {
const { id: idParam } = useLocalSearchParams<{ id: string }>();
const id = +idParam;
const { data: userTest } = useUserTest(id);
if (!userTest)
return (
<>
<Stack.Screen options={{ title: `Test #${id}` }} />
<Content><UserTest /></Content>
</>
);
const status = getUserTestStatus(userTest);
const displayCorrectAnswers = status !== TestStatus.InProgress;
const handleContinueTest = () => {
}
const isCorrectVariation = (answer: AnswerResponse, variant: QuestionVariant) => {
if(answer.is_correct) return true;
const isCorrectVariant = (answer.question.correct_answers as number[]).includes(variant.id);
const isUserAnsweredVariant = answer.answer ? (answer.answer as number[]).includes(variant.id) : false;
return isCorrectVariant === isUserAnsweredVariant;
}
return (
<>
<Stack.Screen options={{ title: getUserTestTitle(userTest) }} />
<Content>
<View className="mb-3">
<UserTest userTest={userTest} />
</View>
{status === TestStatus.InProgress && <Button className="mb-3" onPress={handleContinueTest}>
<ButtonText>Continue</ButtonText>
</Button>}
<Divider className="mb-4" />
<View className="gap-3">
{userTest.answers.map((answer) => (
<View key={answer.id}>
<Question question={answer.question} withCategory={false} />
<View className="mt-2 pl-5">
{answer.question.type !== 'text' && answer.question.variants.map((variant) => (
<View key={variant.id} className="mb-2">
<Answer
answer={variant}
isTextType={false}
userAnswers={answer.answer}
correctAnswers={answer.question.correct_answers}
isCorrectUserAnswer={isCorrectVariation(answer, variant)}
displayUserAnswer={true}
displayCorrect={displayCorrectAnswers}
/>
</View>
))}
{answer.question.type === 'text' && (
<View className="mb-2">
<Answer
answer={{ id: 0, text: answer.answer ? answer.answer[0] as string : '-' }}
isTextType={true}
isCorrectUserAnswer={answer.is_correct}
displayUserAnswer={true}
displayCorrect={displayCorrectAnswers}
/>
</View>
)}
</View>
</View>
))}
</View>
</Content>
</>
)
}
export default UserTestScreen;

View File

@ -43,7 +43,7 @@ const Test = ({ test, onPress }: TestProps) => {
<ThemedText type="meta"> <ThemedText type="meta">
<FontAwesome5 name="user" /> {test.author.username} <FontAwesome5 name="user" /> {test.author.username}
</ThemedText> </ThemedText>
<ThemedText type="meta">{test.questions.length} Questions</ThemedText> <ThemedText type="meta">{test.questions_count} Questions</ThemedText>
</View> </View>
</Panel> </Panel>
</Pressable> </Pressable>

View File

@ -91,7 +91,7 @@ const Answer = ({
return style; return style;
}, [displayCorrect, displayUserAnswer, isAnsweredByUser, isCorrectAnswer, isTextType, userAnsweredCorrectly]); }, [displayCorrect, displayUserAnswer, isAnsweredByUser, isCorrectAnswer, isTextType, userAnsweredCorrectly]);
const textColor = useMemo(() => { const textColor = useMemo(() => {
if(stylePanel) if(stylePanel === styles.correctAnswer || stylePanel === styles.incorrectAnswer)
return "#ffffff"; return "#ffffff";
else else
return undefined; return undefined;

View File

@ -0,0 +1,18 @@
import { ComponentProps } from "react";
import { Button } from "./button";
import Panel from "./panel";
type ButtonSectionProps = ComponentProps<typeof Button>;
const ButtonSection = ({children, ...rest }: ButtonSectionProps) => {
return (
<Panel withPadding={false}>
<Button variant="outline" action="primary" {...rest}>
{children}
</Button>
</Panel>
)
}
export default ButtonSection;

View File

@ -14,6 +14,7 @@ interface CustomSelectProps {
defaultValue?: string; defaultValue?: string;
selectedValue?:string; selectedValue?:string;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
className?: string;
} }
const CustomSelect = ({ const CustomSelect = ({
@ -22,6 +23,7 @@ const CustomSelect = ({
defaultValue, defaultValue,
selectedValue, selectedValue,
noneOption, noneOption,
className,
placeholder = "Select option" placeholder = "Select option"
}: CustomSelectProps) => { }: CustomSelectProps) => {
const optionsComputed = useMemo(() => { const optionsComputed = useMemo(() => {
@ -33,7 +35,7 @@ const CustomSelect = ({
const backgroundColor = useThemeColor({ }, "background"); const backgroundColor = useThemeColor({ }, "background");
return ( return (
<Select onValueChange={onValueChange} selectedValue={selectedValue} defaultValue={defaultValue}> <Select className={className} onValueChange={onValueChange} selectedValue={selectedValue} defaultValue={defaultValue}>
<SelectTrigger variant="outline" size="md" style={{ backgroundColor }}> <SelectTrigger variant="outline" size="md" style={{ backgroundColor }}>
<SelectInput placeholder={placeholder} /> <SelectInput placeholder={placeholder} />
<FontAwesome5 className="mr-3" name="chevron-down" /> <FontAwesome5 className="mr-3" name="chevron-down" />

View File

@ -6,13 +6,14 @@ interface PanelProps {
children?: ReactNode; children?: ReactNode;
className?: string; className?: string;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
withPadding?: boolean;
} }
const Panel = ({children, className, style}: PanelProps) => { const Panel = ({children, className, style, withPadding = true}: PanelProps) => {
const backgroundColor = useThemeColor({ }, 'background'); const backgroundColor = useThemeColor({ }, 'background');
return ( return (
<View style={[styles.panel, { backgroundColor }, style]} className={className}> <View style={[styles.panel, withPadding ? styles.panelPadding : null, { backgroundColor }, style]} className={className}>
{children} {children}
</View> </View>
) )
@ -20,10 +21,12 @@ const Panel = ({children, className, style}: PanelProps) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
panel: { panel: {
padding: 15,
borderRadius: 5, borderRadius: 5,
boxShadow: '0px 4px 5px 0px rgba(0,0,0,0.18)' boxShadow: '0px 4px 5px 0px rgba(0,0,0,0.18)'
}, },
panelPadding: {
padding: 15,
}
}); });

107
components/user-test.tsx Normal file
View File

@ -0,0 +1,107 @@
import { UserTestResponse } from "@/api/types";
import formatISODate from "@/utils/format-iso-date";
import { getUserTestStatus } from "@/utils/get-user-test-status";
import getUserTestTitle from "@/utils/get-user-test-title";
import { FontAwesome5 } from "@expo/vector-icons";
import { Pressable, StyleSheet, View } from "react-native";
import { Badge, BadgeText } from "./ui/badge";
import { Divider } from "./ui/divider";
import Panel from "./ui/panel";
import { Skeleton } from "./ui/skeleton";
import { ThemedText } from "./ui/themed-text";
type BadgeType = "muted" | "success" | "info" | "warning" | "error";
enum TestStatus {
Completed = "Completed",
Expired = "Expired",
InProgress = "In Progress",
}
interface UserTestProps {
userTest?: UserTestResponse;
onPress?: (userTest: UserTestResponse) => void;
}
const UserTest = ({ userTest, onPress }: UserTestProps) => {
if (!userTest) return <Skeleton className="h-20" />;
const title = getUserTestTitle(userTest);
const testStatus = getUserTestStatus(userTest);
let scoreType: BadgeType = "muted";
if (userTest.is_completed || !userTest.is_available) {
if (userTest.score >= 80) scoreType = "success";
else if (userTest.score >= 60) scoreType = "info";
else if (userTest.score >= 40) scoreType = "warning";
else if (userTest.score >= 0) scoreType = "error";
}
const successAnswers = userTest.answers.filter(answer => answer.is_correct).length;
let testStatusType: BadgeType = "warning";
if (testStatus === TestStatus.Completed) testStatusType = "success";
else if (testStatus === TestStatus.Expired) testStatusType = "error";
return (
<Pressable onPress={() => onPress?.(userTest)}>
<Panel>
<View className="justify-between flex-row items-center">
<Badge action={testStatusType}>
<BadgeText>
<FontAwesome5 name="info-circle" /> {testStatus}
</BadgeText>
</Badge>
<Badge action={scoreType}>
<BadgeText>
<FontAwesome5 name="star" /> {userTest.score}
</BadgeText>
</Badge>
</View>
<Divider className="mt-2 mb-2" />
<ThemedText style={styles.testTitle}>{title}</ThemedText>
<View className="flex-row flex-wrap">
<View className="mr-6">
<View>
<ThemedText type="meta">Started at:</ThemedText>
</View>
<View>
<ThemedText>{formatISODate(userTest.created_at)}</ThemedText>
</View>
</View>
<View>
<View>
<ThemedText type="meta">Closed at:</ThemedText>
</View>
<View>
<ThemedText>{formatISODate(userTest.closed_at)}</ThemedText>
</View>
</View>
</View>
<Divider className="mt-2 mb-2" />
<View className="justify-between flex-row items-center">
<ThemedText type="meta">
{userTest.answers.length} Questions
</ThemedText>
<ThemedText type="meta">
{successAnswers} / {userTest.answers.length} Correct
</ThemedText>
</View>
</Panel>
</Pressable>
);
};
const styles = StyleSheet.create({
testTitle: {
fontSize: 18,
fontWeight: "600",
marginBottom: 10,
},
});
export default UserTest;

View File

@ -0,0 +1,20 @@
import { UserTestResponse } from "@/api/types";
export enum TestStatus {
Completed = "Completed",
Expired = "Expired",
InProgress = "In Progress"
}
export const getUserTestStatus = (userTest: UserTestResponse): TestStatus => {
let status: TestStatus = TestStatus.Completed;
if(!userTest.is_available && !userTest.is_completed)
return TestStatus.Expired;
if(!userTest.is_completed)
return TestStatus.InProgress;
return status
}

View File

@ -0,0 +1,9 @@
import { UserTestResponse } from "@/api/types";
const getUserTestTitle = (userTest: UserTestResponse): string => {
return userTest.test_id !== null
? `Test #${userTest.id}`
: `Random Test #${userTest.id}`;
}
export default getUserTestTitle;