From 55d6526958b22d2d9ffaad5f7ab78449525edb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=87=D5=B8=D6=82=D5=B7=D5=A1=D5=B6=D5=AB=D5=AF=20=D4=B1r?= =?UTF-8?q?aakelyan?= Date: Sun, 31 May 2026 16:49:31 +0400 Subject: [PATCH 1/5] initial rtk setup --- src/__tests__/CardItem.test.tsx | 4 + src/__tests__/CardList.test.tsx | 79 ++++++++++++++++--- src/__tests__/DetailsPannel.test.tsx | 2 +- src/__tests__/Layout.test.tsx | 11 ++- src/api/api.tsx | 17 ++++ src/components/Flyout/Flyout.test.tsx | 18 ++++- src/components/Flyout/Flyout.tsx | 4 +- src/components/Layout/Layout.tsx | 44 +++++++++-- src/components/SearchBar/SearchBar.test.tsx | 10 ++- src/components/pagination/Pagination.test.tsx | 36 ++++++--- src/slices/pokemonDetailsSlice.test.ts | 21 ++--- src/slices/pokemonListSlice.test.ts | 21 +++-- src/store/store.ts | 2 + 13 files changed, 213 insertions(+), 56 deletions(-) create mode 100644 src/api/api.tsx diff --git a/src/__tests__/CardItem.test.tsx b/src/__tests__/CardItem.test.tsx index 41f9026..d311a5b 100644 --- a/src/__tests__/CardItem.test.tsx +++ b/src/__tests__/CardItem.test.tsx @@ -4,6 +4,8 @@ import CardItem from '../components/CardItem/CardItem'; const onCardClick = vi.fn(); +const onToggleSelect = vi.fn(); + const defaultProps = { id: 1, name: 'bulbasaur', @@ -11,7 +13,9 @@ const defaultProps = { weight: 69, ability: 'overgrow', image: 'https://example.com/bulbasaur.png', + isSelected: false, onCardClick, + onToggleSelect, }; describe('CardItem', () => { diff --git a/src/__tests__/CardList.test.tsx b/src/__tests__/CardList.test.tsx index e42c84b..d0d8d0f 100644 --- a/src/__tests__/CardList.test.tsx +++ b/src/__tests__/CardList.test.tsx @@ -7,35 +7,77 @@ const noop = () => {}; describe('CardList', () => { it('renders the correct number of items', () => { - render(); + render( + + ); const items = screen.getAllByRole('article'); expect(items).toHaveLength(mockItems.length); }); it('displays each pokemon name', () => { - render(); + render( + + ); expect(screen.getByText(/bulbasaur/i)).toBeInTheDocument(); expect(screen.getByText(/charmander/i)).toBeInTheDocument(); }); it('shows "No results found" when items array is empty', () => { - render(); + render( + + ); expect(screen.getByText('No results found')).toBeInTheDocument(); }); it('renders without crashing when items is an empty array', () => { - const { container } = render(); + const { container } = render( + + ); expect(container).toBeInTheDocument(); }); it('renders a single item correctly', () => { - render(); + render( + + ); expect(screen.getAllByRole('article')).toHaveLength(1); expect(screen.getByText(/bulbasaur/i)).toBeInTheDocument(); }); it('displays type for each item', () => { - render(); + render( + + ); expect(screen.getByText(/grass/i)).toBeInTheDocument(); expect(screen.getByText(/fire/i)).toBeInTheDocument(); }); @@ -49,18 +91,37 @@ describe('CardList', () => { abilities: [{ ability: { name: 'run-away' } }], sprites: { front_default: '' }, }; - render(); + render( + + ); expect(screen.getByText(/testmon/i)).toBeInTheDocument(); }); it('shows "No results found" when items is null', () => { - render(); + render( + + ); expect(screen.getByText('No results found')).toBeInTheDocument(); }); it('shows "No results found" when items is undefined', () => { render( - + ); expect(screen.getByText('No results found')).toBeInTheDocument(); }); diff --git a/src/__tests__/DetailsPannel.test.tsx b/src/__tests__/DetailsPannel.test.tsx index ec5b323..a170112 100644 --- a/src/__tests__/DetailsPannel.test.tsx +++ b/src/__tests__/DetailsPannel.test.tsx @@ -94,7 +94,7 @@ describe('DetailsPannel', () => { }); it('does not fetch when id is undefined', async () => { - mockUseParams.mockReturnValue({ id: undefined }); + mockUseParams.mockReturnValue({ id: undefined as unknown as string }); renderWithStore(); await act(async () => { await vi.runAllTimersAsync(); diff --git a/src/__tests__/Layout.test.tsx b/src/__tests__/Layout.test.tsx index e91e1ed..3069f3b 100644 --- a/src/__tests__/Layout.test.tsx +++ b/src/__tests__/Layout.test.tsx @@ -38,7 +38,14 @@ vi.mock('../ThemeContext/context', () => ({ })), })); -const defaultListState = { +const defaultListState: { + items: (typeof mockItems)[number][]; + loading: boolean; + error: string | null; + currentPage: number; + totalCount: number; + searchTerm: string; +} = { items: mockItems, loading: false, error: null, @@ -137,7 +144,7 @@ describe('Layout', () => { }); it('renders Outlet when a details route is matched', () => { - mockUseMatch.mockReturnValue({ params: { id: '1' } }); + mockUseMatch.mockReturnValue({ params: { id: '1' } } as unknown as null); renderWithStore(); expect(screen.getByTestId('outlet')).toBeInTheDocument(); }); diff --git a/src/api/api.tsx b/src/api/api.tsx new file mode 100644 index 0000000..d06cb26 --- /dev/null +++ b/src/api/api.tsx @@ -0,0 +1,17 @@ +import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react'; +import type { Item } from '../types'; + +export const pokemonApi = createApi({ + reducerPath: 'pokemonAPI', + baseQuery: fetchBaseQuery({baseUrl: 'https://pokeapi.co/api/v2'}), + endpoints: (build)=>({ + getSinglePokemon: build.query({ + query: (name) => `pokemon/${name}` + }), + getAllPokemons: build.query({ + query: ({offset, limit})=> `pokemon/?offset=${offset}&limit=${limit}` + }), + + }) +}); +export const {useGetSinglePokemonQuery, useGetAllPokemonsQuery} = pokemonApi \ No newline at end of file diff --git a/src/components/Flyout/Flyout.test.tsx b/src/components/Flyout/Flyout.test.tsx index 6a3e49e..17662be 100644 --- a/src/components/Flyout/Flyout.test.tsx +++ b/src/components/Flyout/Flyout.test.tsx @@ -35,7 +35,11 @@ describe('Flyout', () => { it('calls onUnselectAll when "Unselect all" is clicked', async () => { const onUnselectAll = vi.fn(); render( - + ); await userEvent.click(screen.getByText('Unselect all')); expect(onUnselectAll).toHaveBeenCalledTimes(1); @@ -44,7 +48,11 @@ describe('Flyout', () => { it('calls onDownload when "Download" is clicked', async () => { const onDownload = vi.fn(); render( - + ); await userEvent.click(screen.getByText('Download')); expect(onDownload).toHaveBeenCalledTimes(1); @@ -53,7 +61,11 @@ describe('Flyout', () => { it('does not call onDownload when "Unselect all" is clicked', async () => { const onDownload = vi.fn(); render( - + ); await userEvent.click(screen.getByText('Unselect all')); expect(onDownload).not.toHaveBeenCalled(); diff --git a/src/components/Flyout/Flyout.tsx b/src/components/Flyout/Flyout.tsx index 563c126..9141a73 100644 --- a/src/components/Flyout/Flyout.tsx +++ b/src/components/Flyout/Flyout.tsx @@ -11,7 +11,9 @@ const Flyout = ({ selectedCount, onUnselectAll, onDownload }: FlyoutProps) => { return (
- {selectedCount} item{selectedCount !== 1 ? 's' : ''} selected + + {selectedCount} item{selectedCount !== 1 ? 's' : ''} selected +
- {loading && ( + {isLoading && (
@@ -46,11 +32,11 @@ const DetailsPannel = () => { {error && (
-

Error: {error}

+

Error: {'message' in error ? error.message : 'Failed to load pokemon'}

)} - {details && !loading && !error && ( + {details && !isLoading && !error && (
state.pokemonList ); - const totalPages = useAppSelector(selectTotalPages); - const { selectedIds } = useAppSelector((state) => state.selectedItems); - const { theme, handleThemeChange } = useTheme(); + const isSearching = !!searchTerm.trim() + const { data: allData, isLoading: allLoading, isFetching: allFetching, error: allError } = useGetAllPokemonsQuery( + { offset: (currentPage - 1) * 20, limit: 20 }, + { skip: isSearching } + ); + const { data: singleData, isLoading: singleLoading, isFetching: singleFetching, error: singleError } = useGetSinglePokemonQuery( + searchTerm.trim(), + { skip: !isSearching } + ); + + const data = isSearching + ? singleData ? { results: [singleData], count: 1 } : undefined + : allData; + const isLoading = isSearching ? (singleLoading || singleFetching) : (allLoading || allFetching); + const error = isSearching ? singleError : allError; + +const items = data?.results ?? []; +const totalPages = data ? Math.ceil(data.count / 20) : 0; +const { selectedIds } = useAppSelector((state) => state.selectedItems); +const { theme, handleThemeChange } = useTheme(); - useEffect(() => { - setSearchParams({ page: String(currentPage) }, { replace: true }); - dispatch(fetchPokemonList({ searchTerm, page: currentPage })); - }, [searchTerm, currentPage]); +useEffect(() => { + setSearchParams({ page: String(currentPage) }, { replace: true }); +}, [currentPage]); - const handleCardClick = (pokemonId: number) => { - navigate(`/details/${pokemonId}`); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; +const handleCardClick = (pokemonId: number) => { + navigate(`/details/${pokemonId}`); + window.scrollTo({ top: 0, behavior: 'smooth' }); +}; - const handleToggleSelect = (id: number) => { - dispatch(toggleItem(id)); - }; +const handleToggleSelect = (id: number) => { + dispatch(toggleItem(id)); +}; - const handleUnselectAll = () => { - dispatch(clearAll()); - }; +const handleUnselectAll = () => { + dispatch(clearAll()); +}; - const handleDownload = async () => { - const cachedById = new Map(items.map((item) => [item.id, item])); +const handleDownload = async () => { + const cachedById = new Map(items.map((item) => [item.id, item])); - const resolved = await Promise.all( - selectedIds.map((id) => - cachedById.has(id) - ? Promise.resolve(cachedById.get(id)!) - : dispatch( pokemonApi.endpoints.getSinglePokemon.initiate(String(id))) - .then((result)=> result.data) - ) + const resolved = await Promise.all( + selectedIds.map((id) => + cachedById.has(id) + ? Promise.resolve(cachedById.get(id)!) + : dispatch( + pokemonApi.endpoints.getSinglePokemon.initiate(String(id)) + ).then((result) => result.data) ) - const pokemonData = resolved.filter((p): p is Item => p !== undefined); + ); + const pokemonData = resolved.filter((p): p is Item => p !== undefined); - const headers = [ - 'id', - 'name', - 'type', - 'weight', - 'height', - 'ability', - 'description', - 'details_url', - ]; + const headers = [ + 'id', + 'name', + 'type', + 'weight', + 'height', + 'ability', + 'description', + 'details_url', + ]; - const rows = pokemonData.map((p) => { - const type = p.types - .map((t: { type: { name: string } }) => t.type.name) - .join('/'); - const ability = p.abilities[0]?.ability.name ?? ''; - const description = p.stats - ? p.stats - .map( - (s: { stat: { name: string }; base_stat: number }) => - `${s.stat.name}: ${s.base_stat}` - ) - .join(' | ') - : ''; - const detailsUrl = `https://pokeapi.co/api/v2/pokemon/${p.id}`; - return [ - p.id, - p.name, - type, - p.weight, - p.height ?? '', - ability, - description, - detailsUrl, - ] - .map((v) => `"${String(v).replace(/"/g, '""')}"`) - .join(','); - }); + const rows = pokemonData.map((p) => { + const type = p.types + .map((t: { type: { name: string } }) => t.type.name) + .join('/'); + const ability = p.abilities[0]?.ability.name ?? ''; + const description = p.stats + ? p.stats + .map( + (s: { stat: { name: string }; base_stat: number }) => + `${s.stat.name}: ${s.base_stat}` + ) + .join(' | ') + : ''; + const detailsUrl = `https://pokeapi.co/api/v2/pokemon/${p.id}`; + return [ + p.id, + p.name, + type, + p.weight, + p.height ?? '', + ability, + description, + detailsUrl, + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(','); + }); - const csv = [headers.join(','), ...rows].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${selectedIds.length}_items.csv`; - a.click(); - URL.revokeObjectURL(url); - }; + const csv = [headers.join(','), ...rows].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${selectedIds.length}_items.csv`; + a.click(); + URL.revokeObjectURL(url); +}; - return ( -
- -
+return ( +
+ +
+
dispatch(setSearchTerm(term))} /> - {loading ? ( - - ) : error ? ( -

{error}

- ) : ( - - )} - {!loading && items.length > 0 && ( - dispatch(setCurrentPage(page))} - /> - )} - - +
- {detailsMatch && ( -
- -
+ {isLoading ? ( + + ) : error ? ( +

{'message' in error ? error.message : 'Failed to load pokemon'}

+ ) : ( + )} - + {!isLoading && items.length > 0 && ( + dispatch(setCurrentPage(page))} + /> + )} + +
- ); + {detailsMatch && ( +
+ +
+ )} + +
+); } diff --git a/src/components/Layout/style.module.css b/src/components/Layout/style.module.css index 2ad1d47..5d61a85 100644 --- a/src/components/Layout/style.module.css +++ b/src/components/Layout/style.module.css @@ -78,6 +78,13 @@ transform: scale(1.7); } +.searchRow { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; +} + .aboutBtn { padding: 0.5rem 1rem; background-color: #3d3de6; diff --git a/src/components/refreshBtn/refreshBtn.tsx b/src/components/refreshBtn/refreshBtn.tsx new file mode 100644 index 0000000..3af1114 --- /dev/null +++ b/src/components/refreshBtn/refreshBtn.tsx @@ -0,0 +1,24 @@ +import { pokemonApi } from "../../api/api"; +import { useAppDispatch } from "../../store/hooks"; +import styles from './style.module.css'; + +const RefreshBtn = () => { + const dispatch = useAppDispatch(); + + const handleRefresh = () => { + dispatch(pokemonApi.util.invalidateTags(['Pokemon'])); + }; + + return ( + + ); +}; + +export default RefreshBtn; diff --git a/src/components/refreshBtn/style.module.css b/src/components/refreshBtn/style.module.css new file mode 100644 index 0000000..47b4f14 --- /dev/null +++ b/src/components/refreshBtn/style.module.css @@ -0,0 +1,46 @@ +.refreshBtn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--bg-card); + border: none; + border-radius: 40px; + padding: 10px 20px; + cursor: pointer; + color: var(--text-muted); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.08em; + white-space: nowrap; + flex-shrink: 0; + box-shadow: + 8px 8px 16px var(--shadow-dark), + -8px -8px 16px var(--shadow-light); + transition: + box-shadow 0.2s, + background 0.3s ease; +} + +.refreshBtn:hover { + box-shadow: + 6px 6px 12px var(--shadow-dark), + -6px -6px 12px var(--shadow-light); + color: var(--text-primary); +} + +.refreshBtn:active { + box-shadow: + inset 4px 4px 8px var(--shadow-dark), + inset -4px -4px 8px var(--shadow-light); +} + +.icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts index 8521e95..73c91ce 100644 --- a/src/hooks/usePagination.ts +++ b/src/hooks/usePagination.ts @@ -1,7 +1,8 @@ import { useState, useEffect, useRef } from 'react'; -import { getAllData, getData } from '../services/api'; import type { UsePaginationReturn, Item } from '../types'; import { useSearchParams } from 'react-router'; +import { pokemonApi } from '../api/api'; +import { useAppDispatch } from '../store/hooks'; const ITEMS_PER_PAGE = 20; const LOADING_DELAY_MS = 500; @@ -32,6 +33,8 @@ export const usePagination = ( setCurrentPage(1); }, [searchTerm]); +const dispatch = useAppDispatch() + useEffect(() => { const loadItems = async () => { setLoading(true); @@ -43,14 +46,14 @@ export const usePagination = ( let results: Item[], count: number; if (searchTerm.trim()) { - const item = await getData(searchTerm.trim()); - results = [item]; + const item = await dispatch(pokemonApi.endpoints.getSinglePokemon.initiate(searchTerm.trim())); + results = [item.data]; count = 1; } else { const offset = (currentPage - 1) * ITEMS_PER_PAGE; - const fetchData = await getAllData(offset, ITEMS_PER_PAGE); - results = fetchData.results; - count = fetchData.count; + const fetchData = await dispatch(pokemonApi.endpoints.getAllPokemons.initiate({offset, limit: ITEMS_PER_PAGE})); + results = fetchData.data.results; + count = fetchData.data.count; } setItems(results); diff --git a/src/services/api.ts b/src/services/api.ts deleted file mode 100644 index 8509003..0000000 --- a/src/services/api.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { HTTP_STATUS } from '../constants'; - -const API_URL = 'https://pokeapi.co/api/v2'; - -const throwApiError = (response: Response): never => { - if (response.status === HTTP_STATUS.NotFound) { - throw new Error('Pokemon not found. Please check the name'); - } - if (response.status === HTTP_STATUS.BadRequest) { - throw new Error('Invalid request. Please check your input'); - } - if (response.status === HTTP_STATUS.InternalServerError) { - throw new Error('Server error. Please try again later'); - } - if (response.status === HTTP_STATUS.ServiceUnavailable) { - throw new Error('Service is temporarily unavailable'); - } - throw new Error('Something went wrong'); -}; - -export const getData = async (term: string) => { - const response = await fetch(`${API_URL}/pokemon/${term.toLowerCase()}`); - if (!response.ok) { - throwApiError(response); - } - return response.json(); -}; - -export const getAllData = async (offset: number, limit: number) => { - const response = await fetch( - `${API_URL}/pokemon?offset=${offset}&limit=${limit}` - ); - if (!response.ok) { - throwApiError(response); - } - const data = await response.json(); - const details = await Promise.all( - data.results.map((pokemon: { url: string }) => - fetch(pokemon.url).then((response) => response.json()) - ) - ); - return { - results: details, - count: data.count, - }; -}; diff --git a/src/slices/pokemonDetailsSlice.test.ts b/src/slices/pokemonDetailsSlice.test.ts deleted file mode 100644 index da58161..0000000 --- a/src/slices/pokemonDetailsSlice.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import reducer, { - clearDetails, - fetchPokemonDetails, -} from './pokemonDetailsSlice'; -import type { Item } from '../types'; - -const mockItem: Item = { - id: 1, - name: 'bulbasaur', - weight: 69, - types: [{ type: { name: 'grass' } }], - abilities: [{ ability: { name: 'overgrow' } }], - sprites: { front_default: 'https://example.com/bulbasaur.png' }, -}; - -describe('pokemonDetailsSlice', () => { - it('returns initial state', () => { - expect(reducer(undefined, { type: '' })).toEqual({ - details: null, - loading: false, - error: null, - }); - }); - - it('clearDetails sets details and error to null', () => { - const state = reducer( - { details: mockItem, loading: false, error: 'some error' }, - clearDetails() - ); - expect(state.details).toBeNull(); - expect(state.error).toBeNull(); - }); - - it('fetchPokemonDetails.pending sets loading true and clears state', () => { - const state = reducer( - { details: mockItem, loading: false, error: null }, - { type: fetchPokemonDetails.pending.type } - ); - expect(state.loading).toBe(true); - expect(state.details).toBeNull(); - expect(state.error).toBeNull(); - }); - - it('fetchPokemonDetails.fulfilled sets details and loading false', () => { - const state = reducer(undefined, { - type: fetchPokemonDetails.fulfilled.type, - payload: mockItem, - }); - expect(state.loading).toBe(false); - expect(state.details).toEqual(mockItem); - }); - - it('fetchPokemonDetails.rejected sets error and loading false', () => { - const state = reducer(undefined, { - type: fetchPokemonDetails.rejected.type, - payload: 'Pokemon not found', - }); - expect(state.loading).toBe(false); - expect(state.error).toBe('Pokemon not found'); - }); -}); diff --git a/src/slices/pokemonDetailsSlice.ts b/src/slices/pokemonDetailsSlice.ts deleted file mode 100644 index e3b6aba..0000000 --- a/src/slices/pokemonDetailsSlice.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; -import { getData } from '../services/api'; -import type { Item } from '../types'; - -interface PokemonDetailsState { - details: Item | null; - loading: boolean; - error: string | null; -} - -const initialState: PokemonDetailsState = { - details: null, - loading: false, - error: null, -}; - -export const fetchPokemonDetails = createAsyncThunk( - 'pokemonDetails/fetch', - async (id: string, { rejectWithValue }) => { - await new Promise((resolve) => setTimeout(resolve, 200)); - try { - return await getData(id); - } catch (error) { - return rejectWithValue((error as Error).message); - } - } -); - -const pokemonDetailsSlice = createSlice({ - name: 'pokemonDetails', - initialState, - reducers: { - clearDetails(state) { - state.details = null; - state.error = null; - }, - }, - extraReducers: (builder) => { - builder - .addCase(fetchPokemonDetails.pending, (state) => { - state.loading = true; - state.error = null; - state.details = null; - }) - .addCase(fetchPokemonDetails.fulfilled, (state, action) => { - state.loading = false; - state.details = action.payload; - }) - .addCase(fetchPokemonDetails.rejected, (state, action) => { - state.loading = false; - state.error = action.payload as string; - }); - }, -}); - -export const { clearDetails } = pokemonDetailsSlice.actions; -export default pokemonDetailsSlice.reducer; diff --git a/src/slices/pokemonListSlice.ts b/src/slices/pokemonListSlice.ts index 8f53adf..e5e5651 100644 --- a/src/slices/pokemonListSlice.ts +++ b/src/slices/pokemonListSlice.ts @@ -1,7 +1,7 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; -import { getData, getAllData } from '../services/api'; import type { Item } from '../types'; +import { pokemonApi } from '../api/api'; const ITEMS_PER_PAGE = 20; @@ -29,16 +29,23 @@ export const fetchPokemonList = createAsyncThunk( 'pokemonList/fetch', async ( { searchTerm, page }: { searchTerm: string; page: number }, - { rejectWithValue } + { rejectWithValue, dispatch } ) => { await new Promise((resolve) => setTimeout(resolve, 500)); try { if (searchTerm.trim()) { - const item = await getData(searchTerm.trim()); - return { results: [item], count: 1 }; + const result = await dispatch( + pokemonApi.endpoints.getSinglePokemon.initiate(searchTerm.trim()) + ); + if (!result.data) return rejectWithValue('Pokemon not found'); + return { results: [result.data], count: 1 }; } const offset = (page - 1) * ITEMS_PER_PAGE; - return await getAllData(offset, ITEMS_PER_PAGE); + const result = await dispatch( + pokemonApi.endpoints.getAllPokemons.initiate({ offset, limit: ITEMS_PER_PAGE }) + ); + if (!result.data) return rejectWithValue('Failed to fetch pokemon list'); + return result.data; } catch (error) { return rejectWithValue((error as Error).message); } diff --git a/src/store/store.ts b/src/store/store.ts index 8bd9788..a1f5278 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,6 +1,5 @@ import { configureStore } from '@reduxjs/toolkit'; import pokemonListReducer from '../slices/pokemonListSlice'; -import pokemonDetailsReducer from '../slices/pokemonDetailsSlice'; import selectedItemsReducer from '../slices/selectedItemsSlice'; import { pokemonApi } from '../api/api'; @@ -8,9 +7,9 @@ const store = configureStore({ reducer: { [pokemonApi.reducerPath]: pokemonApi.reducer, pokemonList: pokemonListReducer, - pokemonDetails: pokemonDetailsReducer, selectedItems: selectedItemsReducer, }, + middleware: (getDefaultMiddleware)=> getDefaultMiddleware().concat(pokemonApi.middleware) }); export type RootState = ReturnType; diff --git a/vite.config.ts b/vite.config.ts index 67e3d5c..726f283 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -// https://vite.dev/config/ export default defineConfig(({ command }) => ({ plugins: [react()], base: command === 'build' ? '/react-class-components_RSschool/' : '/', From 9e59800d611a9e7d58b88bd837278298210133b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=87=D5=B8=D6=82=D5=B7=D5=A1=D5=B6=D5=AB=D5=AF=20=D4=B1r?= =?UTF-8?q?aakelyan?= Date: Mon, 1 Jun 2026 14:18:31 +0400 Subject: [PATCH 3/5] chore: provide test coverage --- src/__tests__/DetailsPannel.test.tsx | 18 +- src/__tests__/Layout.test.tsx | 73 ++++- src/__tests__/pokemonListSlice.test.ts | 33 +- src/__tests__/refreshBtn.test.tsx | 11 +- src/__tests__/usePagination.test.tsx | 32 +- src/api/api.test.tsx | 58 +++- src/api/api.tsx | 81 ++--- .../DetailsPannel/DetailsPannel.tsx | 13 +- src/components/Layout/Layout.tsx | 284 +++++++++--------- src/components/refreshBtn/refreshBtn.tsx | 32 +- src/hooks/usePagination.ts | 13 +- src/slices/pokemonListSlice.ts | 5 +- src/store/store.ts | 3 +- 13 files changed, 397 insertions(+), 259 deletions(-) diff --git a/src/__tests__/DetailsPannel.test.tsx b/src/__tests__/DetailsPannel.test.tsx index 434a204..1325279 100644 --- a/src/__tests__/DetailsPannel.test.tsx +++ b/src/__tests__/DetailsPannel.test.tsx @@ -97,7 +97,11 @@ describe('DetailsPannel', () => { it('shows error message when the query fails with a message', () => { vi.mocked(useGetSinglePokemonQuery).mockReturnValue( - mockQuery({ ...defaultQueryResult, isError: true, error: { message: 'Pokemon not found. Please check the name' } }) + mockQuery({ + ...defaultQueryResult, + isError: true, + error: { message: 'Pokemon not found. Please check the name' }, + }) ); renderWithStore(); expect(screen.getByText(/pokemon not found/i)).toBeInTheDocument(); @@ -120,7 +124,11 @@ describe('DetailsPannel', () => { it('renders stats and height when the data includes them', () => { vi.mocked(useGetSinglePokemonQuery).mockReturnValue( - mockQuery({ ...defaultQueryResult, data: mockItemWithStats, isSuccess: true }) + mockQuery({ + ...defaultQueryResult, + data: mockItemWithStats, + isSuccess: true, + }) ); renderWithStore(); expect(screen.getByText('hp:')).toBeInTheDocument(); @@ -130,7 +138,11 @@ describe('DetailsPannel', () => { it('shows fallback error text when error has no message property', () => { vi.mocked(useGetSinglePokemonQuery).mockReturnValue( - mockQuery({ ...defaultQueryResult, isError: true, error: { status: 503 } }) + mockQuery({ + ...defaultQueryResult, + isError: true, + error: { status: 503 }, + }) ); renderWithStore(); expect(screen.getByText(/Failed to load pokemon/i)).toBeInTheDocument(); diff --git a/src/__tests__/Layout.test.tsx b/src/__tests__/Layout.test.tsx index 4424b1c..efd8236 100644 --- a/src/__tests__/Layout.test.tsx +++ b/src/__tests__/Layout.test.tsx @@ -45,9 +45,7 @@ vi.mock('../api/api', () => ({ reducerPath: 'pokemonAPI', reducer: (state: unknown = {}) => state, middleware: - (_api: unknown) => - (next: Function) => - (action: unknown) => + () => (next: (action: unknown) => unknown) => (action: unknown) => next(action), endpoints: { getSinglePokemon: { initiate: vi.fn(() => ({ type: 'noop' })) }, @@ -62,14 +60,22 @@ type AllQueryResult = ReturnType; type SingleQueryResult = ReturnType; const mockAll = ( - overrides: Partial<{ data: AllQueryResult['data']; isLoading: boolean; isFetching: boolean; error: unknown }> -) => - overrides as unknown as AllQueryResult; + overrides: Partial<{ + data: AllQueryResult['data']; + isLoading: boolean; + isFetching: boolean; + error: unknown; + }> +) => overrides as unknown as AllQueryResult; const mockSingle = ( - overrides: Partial<{ data: SingleQueryResult['data']; isLoading: boolean; isFetching: boolean; error: unknown }> -) => - overrides as unknown as SingleQueryResult; + overrides: Partial<{ + data: SingleQueryResult['data']; + isLoading: boolean; + isFetching: boolean; + error: unknown; + }> +) => overrides as unknown as SingleQueryResult; const createTestStore = () => configureStore({ @@ -137,10 +143,20 @@ describe('Layout', () => { window.scrollTo = vi.fn() as typeof window.scrollTo; vi.mocked(useGetAllPokemonsQuery).mockReturnValue( - mockAll({ data: { results: mockItems, count: 40 }, isLoading: false, isFetching: false, error: undefined }) + mockAll({ + data: { results: mockItems, count: 40 }, + isLoading: false, + isFetching: false, + error: undefined, + }) ); vi.mocked(useGetSinglePokemonQuery).mockReturnValue( - mockSingle({ data: undefined, isLoading: false, isFetching: false, error: undefined }) + mockSingle({ + data: undefined, + isLoading: false, + isFetching: false, + error: undefined, + }) ); }); @@ -151,7 +167,12 @@ describe('Layout', () => { it('shows loading spinner while loading', () => { vi.mocked(useGetAllPokemonsQuery).mockReturnValue( - mockAll({ data: undefined, isLoading: true, isFetching: false, error: undefined }) + mockAll({ + data: undefined, + isLoading: true, + isFetching: false, + error: undefined, + }) ); renderWithStore(); expect(screen.getByRole('status')).toBeInTheDocument(); @@ -159,7 +180,12 @@ describe('Layout', () => { it('shows error message when there is an error', () => { vi.mocked(useGetAllPokemonsQuery).mockReturnValue( - mockAll({ data: undefined, isLoading: false, isFetching: false, error: { message: 'Something went wrong' } }) + mockAll({ + data: undefined, + isLoading: false, + isFetching: false, + error: { message: 'Something went wrong' }, + }) ); renderWithStore(); expect(screen.getByText('Something went wrong')).toBeInTheDocument(); @@ -180,7 +206,12 @@ describe('Layout', () => { it('does not render pagination while loading', () => { vi.mocked(useGetAllPokemonsQuery).mockReturnValue( - mockAll({ data: undefined, isLoading: true, isFetching: false, error: undefined }) + mockAll({ + data: undefined, + isLoading: true, + isFetching: false, + error: undefined, + }) ); renderWithStore(); expect( @@ -190,7 +221,12 @@ describe('Layout', () => { it('does not render pagination when items list is empty', () => { vi.mocked(useGetAllPokemonsQuery).mockReturnValue( - mockAll({ data: { results: [], count: 0 }, isLoading: false, isFetching: false, error: undefined }) + mockAll({ + data: { results: [], count: 0 }, + isLoading: false, + isFetching: false, + error: undefined, + }) ); renderWithStore(); expect( @@ -223,7 +259,12 @@ describe('Layout', () => { it('shows fallback message when error has no message field', () => { vi.mocked(useGetAllPokemonsQuery).mockReturnValue( - mockAll({ data: undefined, isLoading: false, isFetching: false, error: { status: 404 } }) + mockAll({ + data: undefined, + isLoading: false, + isFetching: false, + error: { status: 404 }, + }) ); renderWithStore(); expect(screen.getByText('Failed to load pokemon')).toBeInTheDocument(); diff --git a/src/__tests__/pokemonListSlice.test.ts b/src/__tests__/pokemonListSlice.test.ts index 7bf876f..4178281 100644 --- a/src/__tests__/pokemonListSlice.test.ts +++ b/src/__tests__/pokemonListSlice.test.ts @@ -13,11 +13,8 @@ vi.mock('../api/api', () => ({ pokemonApi: { reducerPath: 'pokemonAPI', reducer: (state: unknown = {}) => state, - middleware: - (_api: unknown) => - (next: (a: unknown) => unknown) => - (action: unknown) => - next(action), + middleware: () => (next: (a: unknown) => unknown) => (action: unknown) => + next(action), endpoints: { getSinglePokemon: { initiate: vi.fn() }, getAllPokemons: { initiate: vi.fn() }, @@ -74,9 +71,12 @@ describe('pokemonListSlice', () => { it('fetchPokemonList sets loading to true while pending', async () => { const store = createTestStore(); vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( - (() => Promise.resolve({ data: { results: mockItems, count: 40 } })) as never + (() => + Promise.resolve({ data: { results: mockItems, count: 40 } })) as never + ); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: '', page: 1 }) ); - const promise = store.dispatch(fetchPokemonList({ searchTerm: '', page: 1 })); expect(store.getState().pokemonList.loading).toBe(true); await vi.runAllTimersAsync(); await promise; @@ -85,9 +85,12 @@ describe('pokemonListSlice', () => { it('fetchPokemonList fetches all pokemons when searchTerm is empty', async () => { const store = createTestStore(); vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( - (() => Promise.resolve({ data: { results: mockItems, count: 40 } })) as never + (() => + Promise.resolve({ data: { results: mockItems, count: 40 } })) as never + ); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: '', page: 1 }) ); - const promise = store.dispatch(fetchPokemonList({ searchTerm: '', page: 1 })); await vi.runAllTimersAsync(); await promise; expect(pokemonApi.endpoints.getAllPokemons.initiate).toHaveBeenCalledWith({ @@ -135,10 +138,14 @@ describe('pokemonListSlice', () => { vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( (() => Promise.resolve({ data: null })) as never ); - const promise = store.dispatch(fetchPokemonList({ searchTerm: '', page: 1 })); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: '', page: 1 }) + ); await vi.runAllTimersAsync(); await promise; - expect(store.getState().pokemonList.error).toBe('Failed to fetch pokemon list'); + expect(store.getState().pokemonList.error).toBe( + 'Failed to fetch pokemon list' + ); expect(store.getState().pokemonList.loading).toBe(false); }); @@ -147,7 +154,9 @@ describe('pokemonListSlice', () => { vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( (() => Promise.reject(new Error('Network error'))) as never ); - const promise = store.dispatch(fetchPokemonList({ searchTerm: '', page: 1 })); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: '', page: 1 }) + ); await vi.runAllTimersAsync(); await promise; expect(store.getState().pokemonList.error).toBe('Network error'); diff --git a/src/__tests__/refreshBtn.test.tsx b/src/__tests__/refreshBtn.test.tsx index 5e66641..3b7716e 100644 --- a/src/__tests__/refreshBtn.test.tsx +++ b/src/__tests__/refreshBtn.test.tsx @@ -11,11 +11,8 @@ vi.mock('../api/api', () => ({ pokemonApi: { reducerPath: 'pokemonAPI', reducer: (state: unknown = {}) => state, - middleware: - (_api: unknown) => - (next: (a: unknown) => unknown) => - (action: unknown) => - next(action), + middleware: () => (next: (a: unknown) => unknown) => (action: unknown) => + next(action), util: { invalidateTags: mockInvalidateTags, }, @@ -46,7 +43,9 @@ describe('RefreshBtn', () => { it('renders the refresh button', () => { renderRefreshBtn(); - expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /refresh/i }) + ).toBeInTheDocument(); }); it('calls invalidateTags with Pokemon tag when clicked', () => { diff --git a/src/__tests__/usePagination.test.tsx b/src/__tests__/usePagination.test.tsx index 4a4a55d..0355414 100644 --- a/src/__tests__/usePagination.test.tsx +++ b/src/__tests__/usePagination.test.tsx @@ -29,12 +29,14 @@ describe('usePagination', () => { vi.useFakeTimers(); mockDispatch.mockImplementation(async (action: unknown) => { - if (typeof action === 'function') return (action as Function)(mockDispatch); + if (typeof action === 'function') + return (action as (dispatch: unknown) => unknown)(mockDispatch); return action; }); vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( - (() => Promise.resolve({ data: { results: mockItems, count: 40 } })) as never + (() => + Promise.resolve({ data: { results: mockItems, count: 40 } })) as never ); vi.mocked(pokemonApi.endpoints.getSinglePokemon.initiate).mockReturnValue( (() => Promise.resolve({ data: mockItem })) as never @@ -67,18 +69,25 @@ describe('usePagination', () => { }); it('fetches single item when searchTerm is provided', async () => { - const { result } = renderHook(() => usePagination('bulbasaur'), { wrapper }); + const { result } = renderHook(() => usePagination('bulbasaur'), { + wrapper, + }); await act(async () => { await vi.runAllTimersAsync(); }); - expect(pokemonApi.endpoints.getSinglePokemon.initiate).toHaveBeenCalledWith('bulbasaur'); + expect(pokemonApi.endpoints.getSinglePokemon.initiate).toHaveBeenCalledWith( + 'bulbasaur' + ); expect(result.current.items).toEqual([mockItem]); expect(result.current.loading).toBe(false); }); it('sets error state when API throws', async () => { vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( - (() => Promise.reject(new Error('Server error. Please try again later'))) as never + (() => + Promise.reject( + new Error('Server error. Please try again later') + )) as never ); const { result } = renderHook(() => usePagination(''), { wrapper }); await act(async () => { @@ -156,9 +165,12 @@ describe('usePagination', () => { }); it('clears error on new fetch', async () => { - vi.mocked(pokemonApi.endpoints.getSinglePokemon.initiate).mockReturnValueOnce( - (() => Promise.reject(new Error('Pokemon not found. Please check the name'))) as never - ); + vi.mocked( + pokemonApi.endpoints.getSinglePokemon.initiate + ).mockReturnValueOnce((() => + Promise.reject( + new Error('Pokemon not found. Please check the name') + )) as never); const { result, rerender } = renderHook( ({ term }: { term: string }) => usePagination(term), { wrapper, initialProps: { term: 'badterm' } } @@ -166,7 +178,9 @@ describe('usePagination', () => { await act(async () => { await vi.runAllTimersAsync(); }); - expect(result.current.error).toBe('Pokemon not found. Please check the name'); + expect(result.current.error).toBe( + 'Pokemon not found. Please check the name' + ); rerender({ term: 'bulbasaur' }); await act(async () => { diff --git a/src/api/api.test.tsx b/src/api/api.test.tsx index bf77b7f..db9b247 100644 --- a/src/api/api.test.tsx +++ b/src/api/api.test.tsx @@ -3,7 +3,11 @@ import { setupListeners } from '@reduxjs/toolkit/query'; import { renderHook, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import type { ReactNode } from 'react'; -import { pokemonApi, useGetSinglePokemonQuery, useGetAllPokemonsQuery } from './api'; +import { + pokemonApi, + useGetSinglePokemonQuery, + useGetAllPokemonsQuery, +} from './api'; import { mockItem } from '../__tests__/mocks/mockData'; const createStore = () => { @@ -17,10 +21,12 @@ const createStore = () => { type TestStore = ReturnType; -const makeWrapper = (store: TestStore) => - ({ children }: { children: ReactNode }) => ( - {children} - ); +const makeWrapper = (store: TestStore) => { + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + return Wrapper; +}; const makeJsonResponse = (data: unknown, status = 200) => new Response(JSON.stringify(data), { @@ -46,21 +52,27 @@ describe('pokemonApi / useGetSinglePokemonQuery', () => { it('is in loading state while the request is in flight', () => { fetchMock.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { wrapper }); + const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { + wrapper, + }); expect(result.current.isLoading).toBe(true); expect(result.current.data).toBeUndefined(); }); it('returns pokemon data on a successful fetch', async () => { fetchMock.mockResolvedValue(makeJsonResponse(mockItem)); - const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { wrapper }); + const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { + wrapper, + }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual(mockItem); }); it('transforms a 404 response into the correct error message', async () => { fetchMock.mockResolvedValue(makeJsonResponse({}, 404)); - const { result } = renderHook(() => useGetSinglePokemonQuery('missingno'), { wrapper }); + const { result } = renderHook(() => useGetSinglePokemonQuery('missingno'), { + wrapper, + }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toEqual({ message: 'Pokemon not found. Please check the name', @@ -69,7 +81,9 @@ describe('pokemonApi / useGetSinglePokemonQuery', () => { it('transforms a 400 response into the correct error message', async () => { fetchMock.mockResolvedValue(makeJsonResponse({}, 400)); - const { result } = renderHook(() => useGetSinglePokemonQuery('???'), { wrapper }); + const { result } = renderHook(() => useGetSinglePokemonQuery('???'), { + wrapper, + }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toEqual({ message: 'Invalid request. Please check your input', @@ -78,7 +92,9 @@ describe('pokemonApi / useGetSinglePokemonQuery', () => { it('transforms a 503 response into the correct error message', async () => { fetchMock.mockResolvedValue(makeJsonResponse({}, 503)); - const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { wrapper }); + const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { + wrapper, + }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toEqual({ message: 'Service is temporarily unavailable', @@ -87,7 +103,9 @@ describe('pokemonApi / useGetSinglePokemonQuery', () => { it('falls back to a generic error message for unknown status codes', async () => { fetchMock.mockResolvedValue(makeJsonResponse({}, 500)); - const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { wrapper }); + const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { + wrapper, + }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toEqual({ message: 'Something went wrong' }); }); @@ -124,7 +142,9 @@ describe('pokemonApi / useGetSinglePokemonQuery', () => { it('makes a new fetch for different query arguments', async () => { fetchMock .mockResolvedValueOnce(makeJsonResponse(mockItem)) - .mockResolvedValueOnce(makeJsonResponse({ ...mockItem, id: 4, name: 'charmander' })); + .mockResolvedValueOnce( + makeJsonResponse({ ...mockItem, id: 4, name: 'charmander' }) + ); const { result: r1 } = renderHook( () => useGetSinglePokemonQuery('bulbasaur'), @@ -251,13 +271,21 @@ describe('pokemonApi / useGetAllPokemonsQuery', () => { it('fetches fresh data for a different offset value', async () => { fetchMock .mockResolvedValueOnce( - makeJsonResponse({ results: [{ url: 'https://pokeapi.co/api/v2/pokemon/1/' }], count: 100 }) + makeJsonResponse({ + results: [{ url: 'https://pokeapi.co/api/v2/pokemon/1/' }], + count: 100, + }) ) .mockResolvedValueOnce(makeJsonResponse(mockItem)) .mockResolvedValueOnce( - makeJsonResponse({ results: [{ url: 'https://pokeapi.co/api/v2/pokemon/4/' }], count: 100 }) + makeJsonResponse({ + results: [{ url: 'https://pokeapi.co/api/v2/pokemon/4/' }], + count: 100, + }) ) - .mockResolvedValueOnce(makeJsonResponse({ ...mockItem, id: 4, name: 'charmander' })); + .mockResolvedValueOnce( + makeJsonResponse({ ...mockItem, id: 4, name: 'charmander' }) + ); const { result: r1 } = renderHook( () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), diff --git a/src/api/api.tsx b/src/api/api.tsx index ed6b5e5..9411835 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -1,46 +1,51 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { Item } from '../types'; -const cachTime = Number(import.meta.env.VITE_CACH_TTL) +const cachTime = Number(import.meta.env.VITE_CACH_TTL); export const pokemonApi = createApi({ - reducerPath: 'pokemonAPI', - baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2' }), - tagTypes: ['Pokemon'], - endpoints: (build) => ({ - getSinglePokemon: build.query({ - query: (name) => `pokemon/${name}`, - transformErrorResponse: (response: FetchBaseQueryError) => { - if (response.status === 404) return { message: 'Pokemon not found. Please check the name' }; - if (response.status === 400) return { message: 'Invalid request. Please check your input' }; - if (response.status === 503) return { message: 'Service is temporarily unavailable' }; - return { message: 'Something went wrong' }; - }, - providesTags: ['Pokemon'], - keepUnusedDataFor: cachTime - }), - getAllPokemons: build.query< - { results: Item[]; count: number }, - { offset: number; limit: number } - >({ - query: ({ offset, limit }) => `pokemon/?offset=${offset}&limit=${limit}`, - transformErrorResponse: (response: FetchBaseQueryError) => { - if (response.status === 503) return { message: 'Service is temporarily unavailable' }; - if (response.status === 404) return { message: 'Pokemon list not found' }; - return { message: 'Failed to load pokemon list. Please try again' }; - }, - providesTags: ['Pokemon'], - keepUnusedDataFor: cachTime, - transformResponse: async (base: { - results: { url: string }[]; - count: number; - }) => { - const details = await Promise.all( - base.results.map((p) => fetch(p.url).then((r) => r.json())) - ); - return { results: details as Item[], count: base.count }; - }, - }), + reducerPath: 'pokemonAPI', + baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2' }), + tagTypes: ['Pokemon'], + endpoints: (build) => ({ + getSinglePokemon: build.query({ + query: (name) => `pokemon/${name}`, + transformErrorResponse: (response: FetchBaseQueryError) => { + if (response.status === 404) + return { message: 'Pokemon not found. Please check the name' }; + if (response.status === 400) + return { message: 'Invalid request. Please check your input' }; + if (response.status === 503) + return { message: 'Service is temporarily unavailable' }; + return { message: 'Something went wrong' }; + }, + providesTags: ['Pokemon'], + keepUnusedDataFor: cachTime, }), + getAllPokemons: build.query< + { results: Item[]; count: number }, + { offset: number; limit: number } + >({ + query: ({ offset, limit }) => `pokemon/?offset=${offset}&limit=${limit}`, + transformErrorResponse: (response: FetchBaseQueryError) => { + if (response.status === 503) + return { message: 'Service is temporarily unavailable' }; + if (response.status === 404) + return { message: 'Pokemon list not found' }; + return { message: 'Failed to load pokemon list. Please try again' }; + }, + providesTags: ['Pokemon'], + keepUnusedDataFor: cachTime, + transformResponse: async (base: { + results: { url: string }[]; + count: number; + }) => { + const details = await Promise.all( + base.results.map((p) => fetch(p.url).then((r) => r.json())) + ); + return { results: details as Item[], count: base.count }; + }, + }), + }), }); export const { useGetSinglePokemonQuery, useGetAllPokemonsQuery } = pokemonApi; diff --git a/src/components/DetailsPannel/DetailsPannel.tsx b/src/components/DetailsPannel/DetailsPannel.tsx index ea67ac0..f397d70 100644 --- a/src/components/DetailsPannel/DetailsPannel.tsx +++ b/src/components/DetailsPannel/DetailsPannel.tsx @@ -6,8 +6,12 @@ import { useGetSinglePokemonQuery } from '../../api/api'; const DetailsPannel = () => { const { id } = useParams<{ id: string }>(); - const { data: details, isLoading, error } = useGetSinglePokemonQuery(id ?? '', { - skip: !id + const { + data: details, + isLoading, + error, + } = useGetSinglePokemonQuery(id ?? '', { + skip: !id, }); const navigate = useNavigate(); @@ -32,7 +36,10 @@ const DetailsPannel = () => { {error && (
-

Error: {'message' in error ? error.message : 'Failed to load pokemon'}

+

+ Error:{' '} + {'message' in error ? error.message : 'Failed to load pokemon'} +

)} diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 2df90f1..fad7750 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -8,16 +8,17 @@ import CardList from '../CardList/CardList'; import TestButton from '../testButton/testButton'; import { useTheme } from '../../ThemeContext/context'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { - setSearchTerm, - setCurrentPage, -} from '../../slices/pokemonListSlice'; +import { setSearchTerm, setCurrentPage } from '../../slices/pokemonListSlice'; import { toggleItem, clearAll } from '../../slices/selectedItemsSlice'; import type { Item } from '../../types'; import Flyout from '../Flyout/Flyout'; import pikachu from '../../assets/apika.png'; import gengar from '../../assets/gengar.png'; -import { pokemonApi, useGetAllPokemonsQuery, useGetSinglePokemonQuery } from '../../api/api'; +import { + pokemonApi, + useGetAllPokemonsQuery, + useGetSinglePokemonQuery, +} from '../../api/api'; import RefreshBtn from '../refreshBtn/refreshBtn'; export default function Layout() { @@ -29,157 +30,168 @@ export default function Layout() { const { currentPage, searchTerm } = useAppSelector( (state) => state.pokemonList ); - const isSearching = !!searchTerm.trim() - const { data: allData, isLoading: allLoading, isFetching: allFetching, error: allError } = useGetAllPokemonsQuery( + const isSearching = !!searchTerm.trim(); + const { + data: allData, + isLoading: allLoading, + isFetching: allFetching, + error: allError, + } = useGetAllPokemonsQuery( { offset: (currentPage - 1) * 20, limit: 20 }, { skip: isSearching } ); - const { data: singleData, isLoading: singleLoading, isFetching: singleFetching, error: singleError } = useGetSinglePokemonQuery( - searchTerm.trim(), - { skip: !isSearching } - ); + const { + data: singleData, + isLoading: singleLoading, + isFetching: singleFetching, + error: singleError, + } = useGetSinglePokemonQuery(searchTerm.trim(), { skip: !isSearching }); const data = isSearching - ? singleData ? { results: [singleData], count: 1 } : undefined + ? singleData + ? { results: [singleData], count: 1 } + : undefined : allData; - const isLoading = isSearching ? (singleLoading || singleFetching) : (allLoading || allFetching); + const isLoading = isSearching + ? singleLoading || singleFetching + : allLoading || allFetching; const error = isSearching ? singleError : allError; -const items = data?.results ?? []; -const totalPages = data ? Math.ceil(data.count / 20) : 0; -const { selectedIds } = useAppSelector((state) => state.selectedItems); -const { theme, handleThemeChange } = useTheme(); + const items = data?.results ?? []; + const totalPages = data ? Math.ceil(data.count / 20) : 0; + const { selectedIds } = useAppSelector((state) => state.selectedItems); + const { theme, handleThemeChange } = useTheme(); -useEffect(() => { - setSearchParams({ page: String(currentPage) }, { replace: true }); -}, [currentPage]); + useEffect(() => { + setSearchParams({ page: String(currentPage) }, { replace: true }); + }, [currentPage]); -const handleCardClick = (pokemonId: number) => { - navigate(`/details/${pokemonId}`); - window.scrollTo({ top: 0, behavior: 'smooth' }); -}; + const handleCardClick = (pokemonId: number) => { + navigate(`/details/${pokemonId}`); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; -const handleToggleSelect = (id: number) => { - dispatch(toggleItem(id)); -}; + const handleToggleSelect = (id: number) => { + dispatch(toggleItem(id)); + }; -const handleUnselectAll = () => { - dispatch(clearAll()); -}; + const handleUnselectAll = () => { + dispatch(clearAll()); + }; -const handleDownload = async () => { - const cachedById = new Map(items.map((item) => [item.id, item])); + const handleDownload = async () => { + const cachedById = new Map(items.map((item) => [item.id, item])); - const resolved = await Promise.all( - selectedIds.map((id) => - cachedById.has(id) - ? Promise.resolve(cachedById.get(id)!) - : dispatch( - pokemonApi.endpoints.getSinglePokemon.initiate(String(id)) - ).then((result) => result.data) - ) - ); - const pokemonData = resolved.filter((p): p is Item => p !== undefined); + const resolved = await Promise.all( + selectedIds.map((id) => + cachedById.has(id) + ? Promise.resolve(cachedById.get(id)!) + : dispatch( + pokemonApi.endpoints.getSinglePokemon.initiate(String(id)) + ).then((result) => result.data) + ) + ); + const pokemonData = resolved.filter((p): p is Item => p !== undefined); - const headers = [ - 'id', - 'name', - 'type', - 'weight', - 'height', - 'ability', - 'description', - 'details_url', - ]; + const headers = [ + 'id', + 'name', + 'type', + 'weight', + 'height', + 'ability', + 'description', + 'details_url', + ]; - const rows = pokemonData.map((p) => { - const type = p.types - .map((t: { type: { name: string } }) => t.type.name) - .join('/'); - const ability = p.abilities[0]?.ability.name ?? ''; - const description = p.stats - ? p.stats - .map( - (s: { stat: { name: string }; base_stat: number }) => - `${s.stat.name}: ${s.base_stat}` - ) - .join(' | ') - : ''; - const detailsUrl = `https://pokeapi.co/api/v2/pokemon/${p.id}`; - return [ - p.id, - p.name, - type, - p.weight, - p.height ?? '', - ability, - description, - detailsUrl, - ] - .map((v) => `"${String(v).replace(/"/g, '""')}"`) - .join(','); - }); + const rows = pokemonData.map((p) => { + const type = p.types + .map((t: { type: { name: string } }) => t.type.name) + .join('/'); + const ability = p.abilities[0]?.ability.name ?? ''; + const description = p.stats + ? p.stats + .map( + (s: { stat: { name: string }; base_stat: number }) => + `${s.stat.name}: ${s.base_stat}` + ) + .join(' | ') + : ''; + const detailsUrl = `https://pokeapi.co/api/v2/pokemon/${p.id}`; + return [ + p.id, + p.name, + type, + p.weight, + p.height ?? '', + ability, + description, + detailsUrl, + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(','); + }); - const csv = [headers.join(','), ...rows].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${selectedIds.length}_items.csv`; - a.click(); - URL.revokeObjectURL(url); -}; + const csv = [headers.join(','), ...rows].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${selectedIds.length}_items.csv`; + a.click(); + URL.revokeObjectURL(url); + }; -return ( -
- -
-
- dispatch(setSearchTerm(term))} + return ( +
+ +
+
+ dispatch(setSearchTerm(term))} + /> + +
+ {isLoading ? ( + + ) : error ? ( +

{'message' in error ? error.message : 'Failed to load pokemon'}

+ ) : ( + + )} + {!isLoading && items.length > 0 && ( + dispatch(setCurrentPage(page))} + /> + )} + +
- {isLoading ? ( - - ) : error ? ( -

{'message' in error ? error.message : 'Failed to load pokemon'}

- ) : ( - + {detailsMatch && ( +
+ +
)} - {!isLoading && items.length > 0 && ( - dispatch(setCurrentPage(page))} - /> - )} - - +
- {detailsMatch && ( -
- -
- )} - -
-); + ); } diff --git a/src/components/refreshBtn/refreshBtn.tsx b/src/components/refreshBtn/refreshBtn.tsx index 3af1114..b0ecadf 100644 --- a/src/components/refreshBtn/refreshBtn.tsx +++ b/src/components/refreshBtn/refreshBtn.tsx @@ -1,24 +1,24 @@ -import { pokemonApi } from "../../api/api"; -import { useAppDispatch } from "../../store/hooks"; +import { pokemonApi } from '../../api/api'; +import { useAppDispatch } from '../../store/hooks'; import styles from './style.module.css'; const RefreshBtn = () => { - const dispatch = useAppDispatch(); + const dispatch = useAppDispatch(); - const handleRefresh = () => { - dispatch(pokemonApi.util.invalidateTags(['Pokemon'])); - }; + const handleRefresh = () => { + dispatch(pokemonApi.util.invalidateTags(['Pokemon'])); + }; - return ( - - ); + return ( + + ); }; export default RefreshBtn; diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts index 73c91ce..a9ae978 100644 --- a/src/hooks/usePagination.ts +++ b/src/hooks/usePagination.ts @@ -33,7 +33,7 @@ export const usePagination = ( setCurrentPage(1); }, [searchTerm]); -const dispatch = useAppDispatch() + const dispatch = useAppDispatch(); useEffect(() => { const loadItems = async () => { @@ -46,12 +46,19 @@ const dispatch = useAppDispatch() let results: Item[], count: number; if (searchTerm.trim()) { - const item = await dispatch(pokemonApi.endpoints.getSinglePokemon.initiate(searchTerm.trim())); + const item = await dispatch( + pokemonApi.endpoints.getSinglePokemon.initiate(searchTerm.trim()) + ); results = [item.data]; count = 1; } else { const offset = (currentPage - 1) * ITEMS_PER_PAGE; - const fetchData = await dispatch(pokemonApi.endpoints.getAllPokemons.initiate({offset, limit: ITEMS_PER_PAGE})); + const fetchData = await dispatch( + pokemonApi.endpoints.getAllPokemons.initiate({ + offset, + limit: ITEMS_PER_PAGE, + }) + ); results = fetchData.data.results; count = fetchData.data.count; } diff --git a/src/slices/pokemonListSlice.ts b/src/slices/pokemonListSlice.ts index e5e5651..c6a7c96 100644 --- a/src/slices/pokemonListSlice.ts +++ b/src/slices/pokemonListSlice.ts @@ -42,7 +42,10 @@ export const fetchPokemonList = createAsyncThunk( } const offset = (page - 1) * ITEMS_PER_PAGE; const result = await dispatch( - pokemonApi.endpoints.getAllPokemons.initiate({ offset, limit: ITEMS_PER_PAGE }) + pokemonApi.endpoints.getAllPokemons.initiate({ + offset, + limit: ITEMS_PER_PAGE, + }) ); if (!result.data) return rejectWithValue('Failed to fetch pokemon list'); return result.data; diff --git a/src/store/store.ts b/src/store/store.ts index a1f5278..6be0f7f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,7 +9,8 @@ const store = configureStore({ pokemonList: pokemonListReducer, selectedItems: selectedItemsReducer, }, - middleware: (getDefaultMiddleware)=> getDefaultMiddleware().concat(pokemonApi.middleware) + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(pokemonApi.middleware), }); export type RootState = ReturnType; From dcb495121c8aab3d5e2005defa10ed4b42476297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=87=D5=B8=D6=82=D5=B7=D5=A1=D5=B6=D5=AB=D5=AF=20=D4=B1r?= =?UTF-8?q?aakelyan?= Date: Mon, 1 Jun 2026 14:44:29 +0400 Subject: [PATCH 4/5] chore: initial changes --- src/__tests__/DetailsPannel.test.tsx | 2 +- src/api/api.test.tsx | 2 +- src/api/api.tsx | 54 +++++++++++++++++------- src/components/refreshBtn/refreshBtn.tsx | 2 +- src/hooks/usePagination.ts | 2 + 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/__tests__/DetailsPannel.test.tsx b/src/__tests__/DetailsPannel.test.tsx index 1325279..ed30a2c 100644 --- a/src/__tests__/DetailsPannel.test.tsx +++ b/src/__tests__/DetailsPannel.test.tsx @@ -15,7 +15,7 @@ vi.mock('../api/api', () => ({ })); const mockNavigate = vi.hoisted(() => vi.fn()); -const mockUseParams = vi.hoisted(() => vi.fn(() => ({ id: '1' }))); +const mockUseParams = vi.hoisted(() => vi.fn(() => ({ id: '1' as string | undefined }))); vi.mock('react-router', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/api/api.test.tsx b/src/api/api.test.tsx index db9b247..c311d32 100644 --- a/src/api/api.test.tsx +++ b/src/api/api.test.tsx @@ -188,7 +188,7 @@ describe('pokemonApi / useGetAllPokemonsQuery', () => { expect(result.current.isLoading).toBe(true); }); - it('returns full pokemon data after transformResponse fetches details', async () => { + it('fetches individual pokemon details via getSinglePokemon and returns combined data', async () => { fetchMock .mockResolvedValueOnce( makeJsonResponse({ diff --git a/src/api/api.tsx b/src/api/api.tsx index 9411835..6e6fbb7 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -26,25 +26,47 @@ export const pokemonApi = createApi({ { results: Item[]; count: number }, { offset: number; limit: number } >({ - query: ({ offset, limit }) => `pokemon/?offset=${offset}&limit=${limit}`, - transformErrorResponse: (response: FetchBaseQueryError) => { - if (response.status === 503) - return { message: 'Service is temporarily unavailable' }; - if (response.status === 404) - return { message: 'Pokemon list not found' }; - return { message: 'Failed to load pokemon list. Please try again' }; + queryFn: async ({ offset, limit }, { dispatch }) => { + const toError = (message: string) => + ({ message } as unknown as FetchBaseQueryError); + try { + const response = await fetch( + `https://pokeapi.co/api/v2/pokemon/?offset=${offset}&limit=${limit}` + ); + if (!response.ok) { + if (response.status === 503) + return { error: toError('Service is temporarily unavailable') }; + if (response.status === 404) + return { error: toError('Pokemon list not found') }; + return { + error: toError('Failed to load pokemon list. Please try again'), + }; + } + const base: { results: { url: string }[]; count: number } = + await response.json(); + + const details: Array = await Promise.all( + base.results.map(async (p) => { + const id = p.url.split('/').filter(Boolean).pop()!; + const result = (await dispatch( + pokemonApi.endpoints.getSinglePokemon.initiate(id) + )) as unknown as { data?: Item }; + return result.data; + }) + ); + + return { + data: { + results: details.filter((d): d is Item => d !== undefined), + count: base.count, + }, + }; + } catch (err) { + return { error: toError((err as Error).message) }; + } }, providesTags: ['Pokemon'], keepUnusedDataFor: cachTime, - transformResponse: async (base: { - results: { url: string }[]; - count: number; - }) => { - const details = await Promise.all( - base.results.map((p) => fetch(p.url).then((r) => r.json())) - ); - return { results: details as Item[], count: base.count }; - }, }), }), }); diff --git a/src/components/refreshBtn/refreshBtn.tsx b/src/components/refreshBtn/refreshBtn.tsx index b0ecadf..c976ff6 100644 --- a/src/components/refreshBtn/refreshBtn.tsx +++ b/src/components/refreshBtn/refreshBtn.tsx @@ -16,7 +16,7 @@ const RefreshBtn = () => { - Refresh + Refresh items ); }; diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts index a9ae978..db31ae6 100644 --- a/src/hooks/usePagination.ts +++ b/src/hooks/usePagination.ts @@ -49,6 +49,7 @@ export const usePagination = ( const item = await dispatch( pokemonApi.endpoints.getSinglePokemon.initiate(searchTerm.trim()) ); + if (!item.data) throw new Error('Pokemon not found'); results = [item.data]; count = 1; } else { @@ -59,6 +60,7 @@ export const usePagination = ( limit: ITEMS_PER_PAGE, }) ); + if (!fetchData.data) throw new Error('Failed to fetch pokemon list'); results = fetchData.data.results; count = fetchData.data.count; } From 152c9adb54083afe5cc49ca9171293166bd2bb94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=87=D5=B8=D6=82=D5=B7=D5=A1=D5=B6=D5=AB=D5=AF=20=D4=B1r?= =?UTF-8?q?aakelyan?= Date: Mon, 1 Jun 2026 15:27:14 +0400 Subject: [PATCH 5/5] fix: fix portfolio link --- package.json | 2 +- src/__tests__/DetailsPannel.test.tsx | 4 +++- src/api/api.tsx | 2 +- src/components/SearchBar/SearchBar.module.css | 5 +++-- src/pages/AboutPage/About.tsx | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 252e4b3..595ffbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-class-components", - "homepage": "https://shushanik01.github.io/react-class-components", + "homepage": "https://shushanik01.github.io/react-class-components_RSschool", "private": true, "version": "0.0.0", "type": "module", diff --git a/src/__tests__/DetailsPannel.test.tsx b/src/__tests__/DetailsPannel.test.tsx index ed30a2c..8605cc6 100644 --- a/src/__tests__/DetailsPannel.test.tsx +++ b/src/__tests__/DetailsPannel.test.tsx @@ -15,7 +15,9 @@ vi.mock('../api/api', () => ({ })); const mockNavigate = vi.hoisted(() => vi.fn()); -const mockUseParams = vi.hoisted(() => vi.fn(() => ({ id: '1' as string | undefined }))); +const mockUseParams = vi.hoisted(() => + vi.fn(() => ({ id: '1' as string | undefined })) +); vi.mock('react-router', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/api/api.tsx b/src/api/api.tsx index 6e6fbb7..8bfb3ea 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -28,7 +28,7 @@ export const pokemonApi = createApi({ >({ queryFn: async ({ offset, limit }, { dispatch }) => { const toError = (message: string) => - ({ message } as unknown as FetchBaseQueryError); + ({ message }) as unknown as FetchBaseQueryError; try { const response = await fetch( `https://pokeapi.co/api/v2/pokemon/?offset=${offset}&limit=${limit}` diff --git a/src/components/SearchBar/SearchBar.module.css b/src/components/SearchBar/SearchBar.module.css index 6f1195f..24859ca 100644 --- a/src/components/SearchBar/SearchBar.module.css +++ b/src/components/SearchBar/SearchBar.module.css @@ -7,8 +7,9 @@ box-shadow: 8px 8px 16px var(--shadow-dark), -8px -8px 16px var(--shadow-light); - width: 750px; - max-width: 90vw; + flex: 1; + min-width: 0; + max-width: 750px; gap: 12px; transition: background 0.3s ease; } diff --git a/src/pages/AboutPage/About.tsx b/src/pages/AboutPage/About.tsx index 3028164..15a50a1 100644 --- a/src/pages/AboutPage/About.tsx +++ b/src/pages/AboutPage/About.tsx @@ -39,7 +39,7 @@ const About = () => {

🌟 Portfolio: