Tests page and test element

This commit is contained in:
Stepan 2026-01-05 22:31:01 +01:00
parent a5533e914d
commit 59298e1581
14 changed files with 590 additions and 34 deletions

View File

@ -1,47 +1,103 @@
import axiosInstance from "./_axiosInstance"; import axiosInstance from "./_axiosInstance";
import { AuthLoginResponse, CategoryResponse, Pagination, QuestionResponse, UserResponse } from "./types"; import {
AuthLoginResponse,
CategoryResponse,
Pagination,
QuestionResponse,
TestResponse,
UserResponse,
UserTestResponse,
} from "./types";
export const get_questions = async (page: number, test_id?: number, category_id?: number) => { export const get_questions = async (
const response = await axiosInstance.get<Pagination<QuestionResponse>>("/api/questions/", { page: number,
test_id?: number,
category_id?: number
) => {
const response = await axiosInstance.get<Pagination<QuestionResponse>>(
"/api/questions/",
{
params: { params: {
page, page,
test_id, test_id,
category_id category_id,
},
} }
}) );
return response.data; return response.data;
} };
export const get_question = async (id: number) => { export const get_question = async (id: number) => {
const response = await axiosInstance.get<{ const response = await axiosInstance.get<{
data: QuestionResponse data: QuestionResponse;
}>(`/api/questions/${id}`); }>(`/api/questions/${id}`);
return response.data; return response.data;
} };
export const get_current_user = async () => { export const get_current_user = async () => {
const response = await axiosInstance.get<{ const response = await axiosInstance.get<{
user: UserResponse user: UserResponse;
}>("/api/auth/me/"); }>("/api/auth/me/");
return response.data;
};
export const get_categories = async () => {
const response = await axiosInstance.get<{
data: CategoryResponse[];
}>("/api/categories/");
return response.data;
};
export const get_tests = async (page: number, category_id?: number) => {
const response = await axiosInstance.get<Pagination<TestResponse>>(
"/api/tests/",
{
params: {
page,
category_id,
},
}
);
return response.data;
};
export const get_test = async (id: number) => {
const response = await axiosInstance.get<{
data: TestResponse;
}>(`/api/tests/${id}`);
return response.data; return response.data;
} }
export const get_categories = async () => { export const get_user_tests = async () => {
const response = await axiosInstance.get<{ const response = await axiosInstance.get<{
data: CategoryResponse[] data: UserTestResponse[];
}>("/api/categories/"); }>("/api/user-tests/");
return response.data;
}
export const get_user_test = async (id: number) => {
const response = await axiosInstance.get<{
data: UserTestResponse;
}>(`/api/user-tests/${id}`);
return response.data; return response.data;
} }
export const post_login = async (email: string, password: string) => { export const post_login = async (email: string, password: string) => {
const response = await axiosInstance.post<AuthLoginResponse>("/api/auth/login/", { const response = await axiosInstance.post<AuthLoginResponse>(
"/api/auth/login/",
{
email, email,
password password,
}); }
);
return response.data; return response.data;
} };

24
api/tests.ts Normal file
View File

@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { get_test, get_tests } from "./_client";
interface useTestsAttr {
page?: number;
category_id?: number;
}
export const useTests = ({ page = 1, category_id }: useTestsAttr = { }) => {
return useQuery({
queryKey: ['tests', page, category_id],
queryFn: () => get_tests(page, category_id)
})
}
export const useTest = (id: number) => {
return useQuery({
queryKey: ['test', id],
queryFn: async () => {
const response = await get_test(id);
return response.data;
}
})
}

View File

@ -50,6 +50,59 @@ export interface CategoryResponse {
created_at: string; created_at: string;
} }
export interface TestResponse {
id: number;
title: string;
description: string;
category_id: number;
category: CategoryResponse;
is_available: boolean;
author_id: number;
author: UserResponse;
questions: QuestionResponse[];
closed_at: string;
created_at: string;
updated_at: string;
}
export interface AnswerResponse {
id: number;
question_id: number;
user_test_id: number;
question: QuestionResponse;
answer?: number[] | string[];
user_id: number;
is_correct: boolean;
created_at: string;
updated_at: string;
}
export interface UserTestResponse {
id: number;
test_id: number;
user_id: number;
user: UserResponse;
closed_at: string;
is_completed: boolean;
score: number;
is_available: boolean;
answers: AnswerResponse[];
created_at: string;
updated_at: string;
}
export enum UserTypes { export enum UserTypes {
Admin = "admin", Admin = "admin",
Creator = "creator", Creator = "creator",

22
api/userTests.ts Normal file
View File

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

View File

@ -41,6 +41,7 @@ export default function HomeScreen() {
key={question.id} key={question.id}
question={question} question={question}
onPress={() => router.push(`/questions/${question.id}`)} onPress={() => router.push(`/questions/${question.id}`)}
withCategory={true}
/> />
))} ))}
</ParallaxScrollView> </ParallaxScrollView>

View File

@ -8,6 +8,7 @@ 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 { router } from "expo-router"; import { router } from "expo-router";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@ -21,12 +22,8 @@ export default function QuestionsScreen() {
category_id: category category_id: category
}); });
const categoryOptions = categories.map((cat) => { const categoryOptions = getCategoryOptions(categories);
return {
label: cat.name,
value: String(cat.id),
};
});
const handleChangeCategory = (value: string) => { const handleChangeCategory = (value: string) => {
setPage(1); setPage(1);
if(value === "all") if(value === "all")
@ -53,7 +50,7 @@ export default function QuestionsScreen() {
<PaginationList<QuestionResponse> <PaginationList<QuestionResponse>
pagination={questionsPagination} pagination={questionsPagination}
renderItem={(item) => ( renderItem={(item) => (
<Question question={item} onPress={() => router.push(`/questions/${item.id}`)} /> <Question question={item} onPress={() => router.push(`/questions/${item.id}`)} withCategory={true} />
)} )}
skeleton={<Question />} skeleton={<Question />}
currentPage={page} currentPage={page}

View File

@ -1,11 +1,64 @@
import { useTests } from '@/api/tests';
import { TestResponse } from '@/api/types';
import useTaxonomyContext from '@/components/providers/taxonomy-provider/hook';
import Test from '@/components/test';
import Content from '@/components/ui/content'; 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 { ThemedText } from '@/components/ui/themed-text';
import getCategoryOptions from '@/utils/get-category-options';
import { router } from 'expo-router';
import { useRef, useState } from 'react';
import { ScrollView, View } from 'react-native';
export default function TestsScreen() { export default function TestsScreen() {
const { categories } = useTaxonomyContext();
const [category, setCategory] = useState<undefined|number>(undefined);
const [page, setPage] = useState<number>(1);
const { data: testsPagination, isLoading } = useTests({
page: page,
category_id: category
});
const scrollRef = useRef<ScrollView>(null);
const categoryOptions = getCategoryOptions(categories);
const handleChangeCategory = (value: string) => {
setPage(1);
if(value === "all")
setCategory(undefined);
else
setCategory(+value);
}
return ( return (
<Content> <Content ref={scrollRef}>
<ThemedText type="title" className='mb-3'>Tests</ThemedText> <ThemedText type="title" className='mb-3'>Tests</ThemedText>
<View className="mb-4">
<CustomSelect
options={categoryOptions}
noneOption={{ label: "All categories", value: "all" }}
selectedValue={category ? `${category}` : undefined}
onValueChange={handleChangeCategory}
placeholder="Select category" />
</View>
<PaginationList<TestResponse>
pagination={testsPagination}
isLoadingPage={isLoading}
renderItem={(item) => (
<Test test={item} onPress={() => router.push(`/tests/${item.id}`)} />
)}
skeleton={<Test />}
currentPage={page}
setCurrentPage={setPage}
scrollView={scrollRef}
/>
</Content> </Content>
); );
} }

View File

@ -42,7 +42,7 @@ const QuestionScreen = () => {
<> <>
<Stack.Screen options={{ title: `Question #${id}` }} /> <Stack.Screen options={{ title: `Question #${id}` }} />
<Content> <Content>
<Question question={data} withMeta={true} /> <Question question={data} withMeta={true} withCategory={true} />
<Divider className="mt-5 mb-5" /> <Divider className="mt-5 mb-5" />

46
app/tests/[id].tsx Normal file
View File

@ -0,0 +1,46 @@
import { useTest } from "@/api/tests";
import Question from "@/components/question";
import Test from "@/components/test";
import { Button, ButtonText } from "@/components/ui/button";
import Content from "@/components/ui/content";
import { Stack, useLocalSearchParams } from "expo-router";
import { View } from "react-native";
const TestScreen = () => {
const { id: idParam } = useLocalSearchParams<{ id: string }>();
const id = +idParam;
const { data, isLoading } = useTest(id);
if (!data)
return (
<>
<Stack.Screen options={{ title: `Test #${id}` }} />
<Content>{isLoading && <Test />}</Content>
</>
);
const handleStartTest = () => {};
return (
<>
<Stack.Screen options={{ title: `Test #${id}` }} />
<Content>
<View className="mb-3">
<Test test={data} />
</View>
{data.is_available && <Button className="mb-3" onPress={handleStartTest}>
<ButtonText>Start Test</ButtonText>
</Button>}
<View className="gap-4">
{data.questions.map((question) => (
<Question key={question.id} question={question} />
))}
</View>
</Content>
</>
);
};
export default TestScreen;

View File

@ -11,17 +11,20 @@ interface QuestionProps {
question?: QuestionResponse; question?: QuestionResponse;
onPress?: () => void; onPress?: () => void;
withMeta?: boolean; withMeta?: boolean;
withCategory?: boolean;
} }
const Question = ({ question, onPress, withMeta = false }: QuestionProps) => { const Question = ({ question, onPress, withCategory = false, withMeta = false }: QuestionProps) => {
if (!question) return <Skeleton className="w-full h-20" />; if (!question) return <Skeleton className="w-full h-20" />;
return ( return (
<Pressable onPress={onPress}> <Pressable onPress={onPress}>
<Panel> <Panel>
{withCategory && (
<ThemedText type="meta" className="mb-1"> <ThemedText type="meta" className="mb-1">
{question.category.name} {question.category.name}
</ThemedText> </ThemedText>
)}
<ThemedText style={styles.questionTitle}>{question.title}</ThemedText> <ThemedText style={styles.questionTitle}>{question.title}</ThemedText>
<ThemedText>{question.description}</ThemedText> <ThemedText>{question.description}</ThemedText>

61
components/test.tsx Normal file
View File

@ -0,0 +1,61 @@
import { TestResponse } from "@/api/types";
import formatISODate from "@/utils/format-iso-date";
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";
interface TestProps {
test?: TestResponse;
onPress?: (test: TestResponse) => void;
}
const Test = ({ test, onPress }: TestProps) => {
if (!test) return <Skeleton className="w-full h-24" />;
const availableText = test.is_available ? `Available: ${formatISODate(test.closed_at)}` : "Closed";
return (
<Pressable onPress={() => onPress?.(test)}>
<Panel>
<ThemedText type="meta" className="mb-1">
{test.category.name}
</ThemedText>
<View className="mb-1 flex-row">
<Badge action={test.is_available ? "success" : "error"}>
<BadgeText>
<FontAwesome5
name={test.is_available ? "check-circle" : "times-circle"}
/>{" "}
{availableText}
</BadgeText>
</Badge>
</View>
<ThemedText style={styles.testTitle}>{test.title}</ThemedText>
<ThemedText>{test.description}</ThemedText>
<Divider className="mt-2 mb-2" />
<View className="justify-between flex-row">
<ThemedText type="meta">
<FontAwesome5 name="user" /> {test.author.username}
</ThemedText>
<ThemedText type="meta">{test.questions.length} Questions</ThemedText>
</View>
</Panel>
</Pressable>
);
};
const styles = StyleSheet.create({
testTitle: {
fontSize: 18,
fontWeight: "600",
marginBottom: 10,
},
});
export default Test;

View File

@ -0,0 +1,216 @@
'use client';
import React, { useMemo } from 'react';
import { Text, View } from 'react-native';
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import {
withStyleContext,
useStyleContext,
} from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import { Svg } from 'react-native-svg';
const SCOPE = 'BADGE';
const badgeStyle = tva({
base: 'flex-row items-center rounded-sm data-[disabled=true]:opacity-50 px-2 py-1',
variants: {
action: {
error: 'bg-background-error border-error-300',
warning: 'bg-background-warning border-warning-300',
success: 'bg-background-success border-success-300',
info: 'bg-background-info border-info-300',
muted: 'bg-background-muted border-background-300',
},
variant: {
solid: '',
outline: 'border',
},
size: {
sm: '',
md: '',
lg: '',
},
},
});
const badgeTextStyle = tva({
base: 'text-typography-700 font-body font-normal tracking-normal uppercase',
parentVariants: {
action: {
error: 'text-error-600',
warning: 'text-warning-600',
success: 'text-success-600',
info: 'text-info-600',
muted: 'text-background-800',
},
size: {
sm: 'text-2xs',
md: 'text-xs',
lg: 'text-sm',
},
},
variants: {
isTruncated: {
true: 'web:truncate',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
sub: {
true: 'text-xs',
},
italic: {
true: 'italic',
},
highlight: {
true: 'bg-yellow-500',
},
},
});
const badgeIconStyle = tva({
base: 'fill-none',
parentVariants: {
action: {
error: 'text-error-600',
warning: 'text-warning-600',
success: 'text-success-600',
info: 'text-info-600',
muted: 'text-background-800',
},
size: {
sm: 'h-3 w-3',
md: 'h-3.5 w-3.5',
lg: 'h-4 w-4',
},
},
});
const ContextView = withStyleContext(View, SCOPE);
cssInterop(PrimitiveIcon, {
className: {
target: 'style',
nativeStyleToProp: {
height: true,
width: true,
fill: true,
color: 'classNameColor',
stroke: true,
},
},
});
type IBadgeProps = React.ComponentPropsWithoutRef<typeof ContextView> &
VariantProps<typeof badgeStyle>;
function Badge({
children,
action = 'muted',
variant = 'solid',
size = 'md',
className,
...props
}: { className?: string } & IBadgeProps) {
const contextValue = useMemo(
() => ({ action, variant, size }),
[action, variant, size]
);
return (
<ContextView
className={badgeStyle({ action, variant, class: className })}
{...props}
context={contextValue}
>
{children}
</ContextView>
);
}
type IBadgeTextProps = React.ComponentPropsWithoutRef<typeof Text> &
VariantProps<typeof badgeTextStyle>;
const BadgeText = React.forwardRef<
React.ComponentRef<typeof Text>,
IBadgeTextProps
>(function BadgeText({ children, className, size, ...props }, ref) {
const { size: parentSize, action: parentAction } = useStyleContext(SCOPE);
return (
<Text
ref={ref}
className={badgeTextStyle({
parentVariants: {
size: parentSize,
action: parentAction,
},
size,
class: className,
})}
{...props}
>
{children}
</Text>
);
});
type IBadgeIconProps = React.ComponentPropsWithoutRef<typeof PrimitiveIcon> &
VariantProps<typeof badgeIconStyle>;
const BadgeIcon = React.forwardRef<
React.ComponentRef<typeof Svg>,
IBadgeIconProps
>(function BadgeIcon({ className, size, ...props }, ref) {
const { size: parentSize, action: parentAction } = useStyleContext(SCOPE);
if (typeof size === 'number') {
return (
<UIIcon
ref={ref}
{...props}
className={badgeIconStyle({ class: className })}
size={size}
/>
);
} else if (
(props?.height !== undefined || props?.width !== undefined) &&
size === undefined
) {
return (
<UIIcon
ref={ref}
{...props}
className={badgeIconStyle({ class: className })}
/>
);
}
return (
<UIIcon
className={badgeIconStyle({
parentVariants: {
size: parentSize,
action: parentAction,
},
size,
class: className,
})}
{...props}
ref={ref}
/>
);
});
Badge.displayName = 'Badge';
BadgeText.displayName = 'BadgeText';
BadgeIcon.displayName = 'BadgeIcon';
export { Badge, BadgeIcon, BadgeText };

12
utils/format-iso-date.ts Normal file
View File

@ -0,0 +1,12 @@
const formatISODate = (isoDateString: string): string => {
const date = new Date(isoDateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}.${month}.${day} ${hours}:${minutes}`;
}
export default formatISODate;

View File

@ -0,0 +1,12 @@
import { CategoryResponse } from "@/api/types";
const getCategoryOptions = (categories: CategoryResponse[]) => {
return categories.map((cat) => {
return {
label: cat.name,
value: String(cat.id),
};
});
}
export default getCategoryOptions;