diff --git a/.env b/.env new file mode 100644 index 0000000..af70243 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_CACHE_TTL=60 \ No newline at end of file diff --git a/src/pages/DetailPage.tsx b/src/pages/DetailPage.tsx index 6377e8f..27f204f 100644 --- a/src/pages/DetailPage.tsx +++ b/src/pages/DetailPage.tsx @@ -1,7 +1,5 @@ -import { useState, useEffect } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; -import { fetchPokemonDetail } from '../api/pokemonApi'; -import { PokemonDetail } from '../types'; +import { useGetPokemonDetailQuery } from '../store/apiSlice'; import Spinner from '../components/Spinner/Spinner'; function DetailPage() { @@ -10,30 +8,14 @@ function DetailPage() { const [searchParams] = useSearchParams(); const page = searchParams.get('page') ?? '1'; - const [pokemon, setPokemon] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!name) return; - setLoading(true); - fetchPokemonDetail(name) - .then((data) => { - setPokemon(data); - setLoading(false); - }) - .catch((err) => { - setError(err.message); - setLoading(false); - }); - }, [name]); + const { data: pokemon, isLoading, isError, refetch } = useGetPokemonDetailQuery(name ?? ''); const handleClose = () => { navigate(`/?page=${page}`); }; - if (loading) return ; - if (error) return

{error}

; + if (isLoading) return ; + if (isError) return

Xatolik yuz berdi. Pokemon topilmadi.

; if (!pokemon) return null; const types = pokemon.types.map((t) => t.type.name).join(', '); @@ -41,6 +23,7 @@ function DetailPage() { return (
+ {pokemon.name}

#{String(pokemon.id).padStart(3, '0')} {pokemon.name}

Type: {types}

diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 9954280..6174c84 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,7 +1,5 @@ -import { useState, useEffect } from 'react'; import { useSearchParams, useNavigate, Outlet } from 'react-router-dom'; -import { fetchPokemons } from '../api/pokemonApi'; -import { PokemonDetail } from '../types'; +import { useGetPokemonsQuery } from '../store/apiSlice'; import { useLocalStorage } from '../hooks/useLocalStorage'; import Search from '../components/Search/Search'; import CardList from '../components/CardList/CardList'; @@ -9,6 +7,7 @@ import Spinner from '../components/Spinner/Spinner'; import ErrorBoundary from '../components/ErrorBoundary/ErrorBoundary'; import Flyout from '../components/Flyout/Flyout'; import ThemeToggle from '../components/ThemeToggle/ThemeToggle'; +import { useState } from 'react'; function MainPage() { const [searchParams, setSearchParams] = useSearchParams(); @@ -16,38 +15,14 @@ function MainPage() { const { getValue, setValue } = useLocalStorage(); const page = parseInt(searchParams.get('page') ?? '1'); - - const [pokemons, setPokemons] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [query, setQuery] = useState(getValue()); - const LIMIT = 20; - - const loadPokemons = async (searchQuery: string, currentPage: number) => { - setLoading(true); - setError(null); - try { - await new Promise((r) => setTimeout(r, 300)); - const results = await fetchPokemons(searchQuery, currentPage, LIMIT); - setPokemons(results); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - setPokemons([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadPokemons(query, page); - }, [page]); + const { data: pokemons = [], isLoading, isError, refetch } = useGetPokemonsQuery({ query, page }); const handleSearch = (newQuery: string) => { setValue(newQuery); setQuery(newQuery); setSearchParams({ page: '1' }); - loadPokemons(newQuery, 1); }; const handleCardClick = (name: string) => { @@ -72,7 +47,7 @@ function MainPage() {
@@ -85,26 +60,36 @@ function MainPage() {
- {loading ? ( + {isLoading ? ( + ) : isError ? ( +
+ Xatolik yuz berdi. Iltimos, qayta urinib ko'ring. +
) : ( )}
- {!loading && pokemons.length > 0 && ( + {!isLoading && pokemons.length > 0 && (
Page {page}
)} + +
+ +
diff --git a/src/store/apiSlice.test.ts b/src/store/apiSlice.test.ts new file mode 100644 index 0000000..224bcd2 --- /dev/null +++ b/src/store/apiSlice.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import { pokemonApi } from './apiSlice'; + +const mockPokemon = { + id: 1, + name: 'bulbasaur', + base_experience: 64, + height: 7, + weight: 69, + types: [{ slot: 1, type: { name: 'grass', url: '' } }], + sprites: { front_default: 'https://example.com/1.png' }, +}; + +const mockListResponse = { + results: [{ name: 'bulbasaur', url: '' }], +}; + +function makeMockResponse(data: unknown, ok = true) { + const body = JSON.stringify(data); + return new Response(body, { + status: ok ? 200 : 404, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeStore() { + return configureStore({ + reducer: { + [pokemonApi.reducerPath]: pokemonApi.reducer, + }, + middleware: (getDefault) => getDefault().concat(pokemonApi.middleware), + }); +} + +describe('pokemonApi', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should fetch pokemon list successfully', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce(makeMockResponse(mockListResponse)) + .mockResolvedValueOnce(makeMockResponse(mockPokemon)); + + const store = makeStore(); + const result = await store.dispatch( + pokemonApi.endpoints.getPokemons.initiate({ query: '', page: 1 }) + ); + + expect(result.data).toBeDefined(); + expect(Array.isArray(result.data)).toBe(true); + }); + + it('should fetch single pokemon by name', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce(makeMockResponse(mockPokemon)); + + const store = makeStore(); + const result = await store.dispatch( + pokemonApi.endpoints.getPokemons.initiate({ query: 'bulbasaur', page: 1 }) + ); + + expect(result.data).toBeDefined(); + expect(result.data?.[0].name).toBe('bulbasaur'); + }); + + it('should fetch pokemon detail', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce(makeMockResponse(mockPokemon)); + + const store = makeStore(); + const result = await store.dispatch( + pokemonApi.endpoints.getPokemonDetail.initiate('bulbasaur') + ); + + expect(result.data).toBeDefined(); + expect(result.data?.name).toBe('bulbasaur'); + }); + + it('should handle loading state', () => { + const store = makeStore(); + const state = store.getState(); + expect(state[pokemonApi.reducerPath]).toBeDefined(); + }); + + it('should handle error state', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce(makeMockResponse({}, false)); + + const store = makeStore(); + const result = await store.dispatch( + pokemonApi.endpoints.getPokemonDetail.initiate('unknown-pokemon') + ); + + expect(result.error).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts new file mode 100644 index 0000000..4ba3148 --- /dev/null +++ b/src/store/apiSlice.ts @@ -0,0 +1,45 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { PokemonDetail, ApiResponse } from '../types'; + +const TTL = Number(import.meta.env.VITE_CACHE_TTL) || 60; + +export const pokemonApi = createApi({ + reducerPath: 'pokemonApi', + baseQuery: fetchBaseQuery({ + baseUrl: 'https://pokeapi.co/api/v2', + }), + keepUnusedDataFor: TTL, + endpoints: (builder) => ({ + getPokemons: builder.query({ + async queryFn({ query, page }, _api, _extra, baseQuery) { + if (query.trim() === '') { + const limit = 20; + const offset = (page - 1) * limit; + const listResult = await baseQuery( + `/pokemon?limit=${limit}&offset=${offset}` + ); + if (listResult.error) return { error: listResult.error }; + const data = listResult.data as ApiResponse; + const details = await Promise.all( + data.results.map(async (p) => { + const res = await baseQuery(`/pokemon/${p.name}`); + return res.data as PokemonDetail; + }) + ); + return { data: details }; + } else { + const result = await baseQuery( + `/pokemon/${query.trim().toLowerCase()}` + ); + if (result.error) return { error: result.error }; + return { data: [result.data as PokemonDetail] }; + } + }, + }), + getPokemonDetail: builder.query({ + query: (name) => `/pokemon/${name}`, + }), + }), +}); + +export const { useGetPokemonsQuery, useGetPokemonDetailQuery } = pokemonApi; \ No newline at end of file diff --git a/src/store/store.ts b/src/store/store.ts index e8a2c88..981d456 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,10 +1,14 @@ import { configureStore } from '@reduxjs/toolkit'; import selectedReducer from './selectedSlice'; +import { pokemonApi } from './apiSlice'; export const store = configureStore({ reducer: { selected: selectedReducer, + [pokemonApi.reducerPath]: pokemonApi.reducer, }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(pokemonApi.middleware), }); export type RootState = ReturnType;