From 59298e1581eb8aff707c4d715394735f780f57c9 Mon Sep 17 00:00:00 2001 From: Stepan Date: Mon, 5 Jan 2026 22:31:01 +0100 Subject: [PATCH] Tests page and test element --- api/_client.ts | 98 +++++++++++---- api/tests.ts | 24 ++++ api/types.ts | 53 +++++++++ api/userTests.ts | 22 ++++ app/(tabs)/index.tsx | 1 + app/(tabs)/questions.tsx | 11 +- app/(tabs)/tests.tsx | 55 ++++++++- app/questions/[id].tsx | 2 +- app/tests/[id].tsx | 46 ++++++++ components/question.tsx | 11 +- components/test.tsx | 61 ++++++++++ components/ui/badge/index.tsx | 216 ++++++++++++++++++++++++++++++++++ utils/format-iso-date.ts | 12 ++ utils/get-category-options.ts | 12 ++ 14 files changed, 590 insertions(+), 34 deletions(-) create mode 100644 api/tests.ts create mode 100644 api/userTests.ts create mode 100644 app/tests/[id].tsx create mode 100644 components/test.tsx create mode 100644 components/ui/badge/index.tsx create mode 100644 utils/format-iso-date.ts create mode 100644 utils/get-category-options.ts diff --git a/api/_client.ts b/api/_client.ts index 0d6667a..052e60e 100644 --- a/api/_client.ts +++ b/api/_client.ts @@ -1,47 +1,103 @@ 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) => { - const response = await axiosInstance.get>("/api/questions/", { - params: { - page, - test_id, - category_id +export const get_questions = async ( + page: number, + test_id?: number, + category_id?: number +) => { + const response = await axiosInstance.get>( + "/api/questions/", + { + params: { + page, + test_id, + category_id, + }, } - }) + ); return response.data; -} +}; export const get_question = async (id: number) => { const response = await axiosInstance.get<{ - data: QuestionResponse + data: QuestionResponse; }>(`/api/questions/${id}`); return response.data; -} +}; export const get_current_user = async () => { const response = await axiosInstance.get<{ - user: UserResponse + user: UserResponse; }>("/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>( + "/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; } -export const get_categories = async () => { +export const get_user_tests = async () => { const response = await axiosInstance.get<{ - data: CategoryResponse[] - }>("/api/categories/"); + data: UserTestResponse[]; + }>("/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; +} export const post_login = async (email: string, password: string) => { - const response = await axiosInstance.post("/api/auth/login/", { - email, - password - }); + const response = await axiosInstance.post( + "/api/auth/login/", + { + email, + password, + } + ); return response.data; -} \ No newline at end of file +}; diff --git a/api/tests.ts b/api/tests.ts new file mode 100644 index 0000000..492f9d6 --- /dev/null +++ b/api/tests.ts @@ -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; + } + }) +} \ No newline at end of file diff --git a/api/types.ts b/api/types.ts index ef7239b..4635304 100644 --- a/api/types.ts +++ b/api/types.ts @@ -50,6 +50,59 @@ export interface CategoryResponse { 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 { Admin = "admin", Creator = "creator", diff --git a/api/userTests.ts b/api/userTests.ts new file mode 100644 index 0000000..0c7ea70 --- /dev/null +++ b/api/userTests.ts @@ -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; + } + }) +} \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index fb18439..37f55c8 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -41,6 +41,7 @@ export default function HomeScreen() { key={question.id} question={question} onPress={() => router.push(`/questions/${question.id}`)} + withCategory={true} /> ))} diff --git a/app/(tabs)/questions.tsx b/app/(tabs)/questions.tsx index a2f12e1..e30516c 100644 --- a/app/(tabs)/questions.tsx +++ b/app/(tabs)/questions.tsx @@ -8,6 +8,7 @@ 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 { router } from "expo-router"; import { useRef, useState } from "react"; @@ -21,12 +22,8 @@ export default function QuestionsScreen() { category_id: category }); - const categoryOptions = categories.map((cat) => { - return { - label: cat.name, - value: String(cat.id), - }; - }); + const categoryOptions = getCategoryOptions(categories); + const handleChangeCategory = (value: string) => { setPage(1); if(value === "all") @@ -53,7 +50,7 @@ export default function QuestionsScreen() { pagination={questionsPagination} renderItem={(item) => ( - router.push(`/questions/${item.id}`)} /> + router.push(`/questions/${item.id}`)} withCategory={true} /> )} skeleton={} currentPage={page} diff --git a/app/(tabs)/tests.tsx b/app/(tabs)/tests.tsx index b410ce2..9ffce8e 100644 --- a/app/(tabs)/tests.tsx +++ b/app/(tabs)/tests.tsx @@ -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 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 { router } from 'expo-router'; +import { useRef, useState } from 'react'; +import { ScrollView, View } from 'react-native'; export default function TestsScreen() { + const { categories } = useTaxonomyContext(); + + const [category, setCategory] = useState(undefined); + const [page, setPage] = useState(1); + + const { data: testsPagination, isLoading } = useTests({ + page: page, + category_id: category + }); + + const scrollRef = useRef(null); + + const categoryOptions = getCategoryOptions(categories); + + const handleChangeCategory = (value: string) => { + setPage(1); + if(value === "all") + setCategory(undefined); + else + setCategory(+value); + } + return ( - + Tests + + + + + + + pagination={testsPagination} + isLoadingPage={isLoading} + renderItem={(item) => ( + router.push(`/tests/${item.id}`)} /> + )} + skeleton={} + currentPage={page} + setCurrentPage={setPage} + scrollView={scrollRef} + /> ); } diff --git a/app/questions/[id].tsx b/app/questions/[id].tsx index 211b043..409bfae 100644 --- a/app/questions/[id].tsx +++ b/app/questions/[id].tsx @@ -42,7 +42,7 @@ const QuestionScreen = () => { <> - + diff --git a/app/tests/[id].tsx b/app/tests/[id].tsx new file mode 100644 index 0000000..d45abbc --- /dev/null +++ b/app/tests/[id].tsx @@ -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 ( + <> + + {isLoading && } + + ); + + const handleStartTest = () => {}; + + return ( + <> + + + + + + + {data.is_available && } + + + {data.questions.map((question) => ( + + ))} + + + + ); +}; + +export default TestScreen; diff --git a/components/question.tsx b/components/question.tsx index 816ae3d..4611b72 100644 --- a/components/question.tsx +++ b/components/question.tsx @@ -11,17 +11,20 @@ interface QuestionProps { question?: QuestionResponse; onPress?: () => void; withMeta?: boolean; + withCategory?: boolean; } -const Question = ({ question, onPress, withMeta = false }: QuestionProps) => { +const Question = ({ question, onPress, withCategory = false, withMeta = false }: QuestionProps) => { if (!question) return ; return ( - - {question.category.name} - + {withCategory && ( + + {question.category.name} + + )} {question.title} {question.description} diff --git a/components/test.tsx b/components/test.tsx new file mode 100644 index 0000000..c1d59a4 --- /dev/null +++ b/components/test.tsx @@ -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 ; + + const availableText = test.is_available ? `Available: ${formatISODate(test.closed_at)}` : "Closed"; + + return ( + onPress?.(test)}> + + + {test.category.name} + + + + + {" "} + {availableText} + + + + {test.title} + {test.description} + + + + + + {test.author.username} + + {test.questions.length} Questions + + + + ); +}; + +const styles = StyleSheet.create({ + testTitle: { + fontSize: 18, + fontWeight: "600", + marginBottom: 10, + }, +}); + +export default Test; diff --git a/components/ui/badge/index.tsx b/components/ui/badge/index.tsx new file mode 100644 index 0000000..31c9700 --- /dev/null +++ b/components/ui/badge/index.tsx @@ -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 & + VariantProps; +function Badge({ + children, + action = 'muted', + variant = 'solid', + size = 'md', + className, + ...props +}: { className?: string } & IBadgeProps) { + + const contextValue = useMemo( + () => ({ action, variant, size }), + [action, variant, size] + ); + + return ( + + {children} + + ); +} + +type IBadgeTextProps = React.ComponentPropsWithoutRef & + VariantProps; + +const BadgeText = React.forwardRef< + React.ComponentRef, + IBadgeTextProps +>(function BadgeText({ children, className, size, ...props }, ref) { + const { size: parentSize, action: parentAction } = useStyleContext(SCOPE); + return ( + + {children} + + ); +}); + +type IBadgeIconProps = React.ComponentPropsWithoutRef & + VariantProps; + +const BadgeIcon = React.forwardRef< + React.ComponentRef, + IBadgeIconProps +>(function BadgeIcon({ className, size, ...props }, ref) { + const { size: parentSize, action: parentAction } = useStyleContext(SCOPE); + + if (typeof size === 'number') { + return ( + + ); + } else if ( + (props?.height !== undefined || props?.width !== undefined) && + size === undefined + ) { + return ( + + ); + } + return ( + + ); +}); + +Badge.displayName = 'Badge'; +BadgeText.displayName = 'BadgeText'; +BadgeIcon.displayName = 'BadgeIcon'; + +export { Badge, BadgeIcon, BadgeText }; \ No newline at end of file diff --git a/utils/format-iso-date.ts b/utils/format-iso-date.ts new file mode 100644 index 0000000..9a3f3e7 --- /dev/null +++ b/utils/format-iso-date.ts @@ -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; \ No newline at end of file diff --git a/utils/get-category-options.ts b/utils/get-category-options.ts new file mode 100644 index 0000000..5d8e14f --- /dev/null +++ b/utils/get-category-options.ts @@ -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; \ No newline at end of file