Added UserTest single and list
This commit is contained in:
parent
59298e1581
commit
8ac1f6be34
@ -74,10 +74,10 @@ export const get_test = async (id: number) => {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const get_user_tests = async () => {
|
||||
export const get_my_user_tests = async () => {
|
||||
const response = await axiosInstance.get<{
|
||||
data: UserTestResponse[];
|
||||
}>("/api/user-tests/");
|
||||
}>("/api/user-tests/me/");
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ export interface QuestionResponse {
|
||||
type: QuestionTypes;
|
||||
difficulty: number;
|
||||
|
||||
variants?: QuestionVariant[];
|
||||
variants: QuestionVariant[];
|
||||
correct_answers: number[]|string[];
|
||||
|
||||
category_id: number;
|
||||
@ -63,7 +63,8 @@ export interface TestResponse {
|
||||
|
||||
author_id: number;
|
||||
author: UserResponse;
|
||||
questions: QuestionResponse[];
|
||||
questions?: QuestionResponse[];
|
||||
questions_count: number;
|
||||
|
||||
closed_at: string;
|
||||
|
||||
@ -95,10 +96,9 @@ export interface UserTestResponse {
|
||||
is_completed: boolean;
|
||||
score: number;
|
||||
is_available: boolean;
|
||||
|
||||
|
||||
answers: AnswerResponse[];
|
||||
|
||||
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
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({
|
||||
queryKey: ['user-tests'],
|
||||
queryFn: async () => {
|
||||
const response = await get_user_tests();
|
||||
const response = await get_my_user_tests();
|
||||
return response.data;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const useTest = (id: number) => {
|
||||
export const useUserTest = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['test', id],
|
||||
queryFn: async () => {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
|
||||
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 UserHeader from '@/components/user-header';
|
||||
import useAuthContext from "@/components/providers/auth-provider/hook";
|
||||
import { ButtonText } from "@/components/ui/button";
|
||||
import ButtonSection from "@/components/ui/button-section";
|
||||
import Content from "@/components/ui/content";
|
||||
import { Divider } from "@/components/ui/divider";
|
||||
import UserHeader from "@/components/user-header";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export default function MeScreen() {
|
||||
const { user, logout, isAuthorized } = useAuthContext();
|
||||
@ -12,9 +13,25 @@ export default function MeScreen() {
|
||||
<Content>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,13 @@ import { useTests } from '@/api/tests';
|
||||
import { TestResponse } from '@/api/types';
|
||||
import useTaxonomyContext from '@/components/providers/taxonomy-provider/hook';
|
||||
import Test from '@/components/test';
|
||||
import { Button, ButtonText } from '@/components/ui/button';
|
||||
import Content from '@/components/ui/content';
|
||||
import CustomSelect from '@/components/ui/custom-select';
|
||||
import PaginationList from '@/components/ui/pagination-list';
|
||||
import { ThemedText } from '@/components/ui/themed-text';
|
||||
import getCategoryOptions from '@/utils/get-category-options';
|
||||
import { FontAwesome5 } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ScrollView, View } from 'react-native';
|
||||
@ -39,13 +41,17 @@ export default function TestsScreen() {
|
||||
<Content ref={scrollRef}>
|
||||
<ThemedText type="title" className='mb-3'>Tests</ThemedText>
|
||||
|
||||
<View className="mb-4">
|
||||
<View className="mb-4 flex-row">
|
||||
<CustomSelect
|
||||
className="mr-2 flex-1"
|
||||
options={categoryOptions}
|
||||
noneOption={{ label: "All categories", value: "all" }}
|
||||
selectedValue={category ? `${category}` : undefined}
|
||||
onValueChange={handleChangeCategory}
|
||||
placeholder="Select category" />
|
||||
<Button>
|
||||
<ButtonText><FontAwesome5 name="qrcode" size={18} /></ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<PaginationList<TestResponse>
|
||||
|
||||
61
app/me/user-tests.tsx
Normal file
61
app/me/user-tests.tsx
Normal 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;
|
||||
@ -5,11 +5,13 @@ import { Button, ButtonText } from "@/components/ui/button";
|
||||
import Content from "@/components/ui/content";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { useToast } from "react-native-toast-notifications";
|
||||
|
||||
const TestScreen = () => {
|
||||
const { id: idParam } = useLocalSearchParams<{ id: string }>();
|
||||
const id = +idParam;
|
||||
const { data, isLoading } = useTest(id);
|
||||
const toast = useToast();
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
@ -19,7 +21,9 @@ const TestScreen = () => {
|
||||
</>
|
||||
);
|
||||
|
||||
const handleStartTest = () => {};
|
||||
const handleStartTest = () => {
|
||||
toast.show("Test started!", { type: "success" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -34,7 +38,7 @@ const TestScreen = () => {
|
||||
</Button>}
|
||||
|
||||
<View className="gap-4">
|
||||
{data.questions.map((question) => (
|
||||
{data.questions?.map((question) => (
|
||||
<Question key={question.id} question={question} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
95
app/user-tests/[id].tsx
Normal file
95
app/user-tests/[id].tsx
Normal 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;
|
||||
@ -43,7 +43,7 @@ const Test = ({ test, onPress }: TestProps) => {
|
||||
<ThemedText type="meta">
|
||||
<FontAwesome5 name="user" /> {test.author.username}
|
||||
</ThemedText>
|
||||
<ThemedText type="meta">{test.questions.length} Questions</ThemedText>
|
||||
<ThemedText type="meta">{test.questions_count} Questions</ThemedText>
|
||||
</View>
|
||||
</Panel>
|
||||
</Pressable>
|
||||
|
||||
@ -91,7 +91,7 @@ const Answer = ({
|
||||
return style;
|
||||
}, [displayCorrect, displayUserAnswer, isAnsweredByUser, isCorrectAnswer, isTextType, userAnsweredCorrectly]);
|
||||
const textColor = useMemo(() => {
|
||||
if(stylePanel)
|
||||
if(stylePanel === styles.correctAnswer || stylePanel === styles.incorrectAnswer)
|
||||
return "#ffffff";
|
||||
else
|
||||
return undefined;
|
||||
|
||||
18
components/ui/button-section.tsx
Normal file
18
components/ui/button-section.tsx
Normal 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;
|
||||
@ -14,6 +14,7 @@ interface CustomSelectProps {
|
||||
defaultValue?: string;
|
||||
selectedValue?:string;
|
||||
onValueChange?: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CustomSelect = ({
|
||||
@ -22,6 +23,7 @@ const CustomSelect = ({
|
||||
defaultValue,
|
||||
selectedValue,
|
||||
noneOption,
|
||||
className,
|
||||
placeholder = "Select option"
|
||||
}: CustomSelectProps) => {
|
||||
const optionsComputed = useMemo(() => {
|
||||
@ -33,7 +35,7 @@ const CustomSelect = ({
|
||||
const backgroundColor = useThemeColor({ }, "background");
|
||||
|
||||
return (
|
||||
<Select onValueChange={onValueChange} selectedValue={selectedValue} defaultValue={defaultValue}>
|
||||
<Select className={className} onValueChange={onValueChange} selectedValue={selectedValue} defaultValue={defaultValue}>
|
||||
<SelectTrigger variant="outline" size="md" style={{ backgroundColor }}>
|
||||
<SelectInput placeholder={placeholder} />
|
||||
<FontAwesome5 className="mr-3" name="chevron-down" />
|
||||
|
||||
@ -6,13 +6,14 @@ interface PanelProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
withPadding?: boolean;
|
||||
}
|
||||
|
||||
const Panel = ({children, className, style}: PanelProps) => {
|
||||
const Panel = ({children, className, style, withPadding = true}: PanelProps) => {
|
||||
const backgroundColor = useThemeColor({ }, 'background');
|
||||
|
||||
return (
|
||||
<View style={[styles.panel, { backgroundColor }, style]} className={className}>
|
||||
<View style={[styles.panel, withPadding ? styles.panelPadding : null, { backgroundColor }, style]} className={className}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@ -20,10 +21,12 @@ const Panel = ({children, className, style}: PanelProps) => {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
panel: {
|
||||
padding: 15,
|
||||
borderRadius: 5,
|
||||
boxShadow: '0px 4px 5px 0px rgba(0,0,0,0.18)'
|
||||
},
|
||||
panelPadding: {
|
||||
padding: 15,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
107
components/user-test.tsx
Normal file
107
components/user-test.tsx
Normal 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;
|
||||
20
utils/get-user-test-status.ts
Normal file
20
utils/get-user-test-status.ts
Normal 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
|
||||
}
|
||||
9
utils/get-user-test-title.ts
Normal file
9
utils/get-user-test-title.ts
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user