diff --git a/.env b/.env new file mode 100644 index 0000000..8e97cc0 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_CACH_TTL=120 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f55cfde..8b57181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@reduxjs/toolkit": "^2.12.0", + "dotenv": "^17.4.2", "react": "^19.2.5", "react-dom": "^19.2.5", "react-redux": "^9.3.0", @@ -2649,6 +2650,18 @@ "license": "MIT", "peer": true }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 4a682d0..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", @@ -20,6 +20,7 @@ }, "dependencies": { "@reduxjs/toolkit": "^2.12.0", + "dotenv": "^17.4.2", "react": "^19.2.5", "react-dom": "^19.2.5", "react-redux": "^9.3.0", 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..8605cc6 100644 --- a/src/__tests__/DetailsPannel.test.tsx +++ b/src/__tests__/DetailsPannel.test.tsx @@ -1,18 +1,23 @@ -import { render, screen, act } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { fireEvent } from '@testing-library/dom'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import DetailsPannel from '../components/DetailsPannel/DetailsPannel'; import pokemonListReducer from '../slices/pokemonListSlice'; -import pokemonDetailsReducer from '../slices/pokemonDetailsSlice'; -import * as api from '../services/api'; +import selectedItemsReducer from '../slices/selectedItemsSlice'; +import { useGetSinglePokemonQuery } from '../api/api'; import { mockItem } from './mocks/mockData'; import type { Item } from '../types'; -vi.mock('../services/api'); +vi.mock('../api/api', () => ({ + useGetSinglePokemonQuery: vi.fn(), + pokemonApi: {}, +})); 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(); @@ -23,6 +28,21 @@ vi.mock('react-router', async (importOriginal) => { }; }); +type SingleQueryResult = ReturnType; + +const mockQuery = (overrides: object) => + overrides as unknown as SingleQueryResult; + +const defaultQueryResult = { + data: undefined, + isLoading: false, + isFetching: false, + isSuccess: false, + isError: false, + isUninitialized: false, + error: undefined, +}; + const mockItemWithStats: Item = { ...mockItem, height: 7, @@ -36,7 +56,7 @@ const createTestStore = () => configureStore({ reducer: { pokemonList: pokemonListReducer, - pokemonDetails: pokemonDetailsReducer, + selectedItems: selectedItemsReducer, }, }); @@ -47,14 +67,11 @@ const renderWithStore = (ui: React.ReactElement) => { describe('DetailsPannel', () => { beforeEach(() => { - vi.useFakeTimers(); vi.clearAllMocks(); mockUseParams.mockReturnValue({ id: '1' }); - vi.mocked(api.getData).mockResolvedValue(mockItem); - }); - - afterEach(() => { - vi.useRealTimers(); + vi.mocked(useGetSinglePokemonQuery).mockReturnValue( + mockQuery({ ...defaultQueryResult, data: mockItem, isSuccess: true }) + ); }); it('renders the Pokémon Details header', () => { @@ -67,49 +84,69 @@ describe('DetailsPannel', () => { expect(screen.getByRole('button', { name: '✕' })).toBeInTheDocument(); }); - it('shows pokemon details after fetch completes', async () => { + it('shows loading spinner while the query is in flight', () => { + vi.mocked(useGetSinglePokemonQuery).mockReturnValue( + mockQuery({ ...defaultQueryResult, isLoading: true }) + ); + renderWithStore(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('shows pokemon name and data after the query succeeds', () => { renderWithStore(); - await act(async () => { - await vi.runAllTimersAsync(); - }); expect(screen.getByText('bulbasaur')).toBeInTheDocument(); - expect(api.getData).toHaveBeenCalledWith('1'); }); - it('shows error message when fetch fails', async () => { - vi.mocked(api.getData).mockRejectedValue( - new Error('Pokemon not found. Please check the name') + 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' }, + }) ); renderWithStore(); - await act(async () => { - await vi.runAllTimersAsync(); - }); expect(screen.getByText(/pokemon not found/i)).toBeInTheDocument(); }); - it('navigates to "/" when close button is clicked', () => { + it('navigates to "/" when the close button is clicked', () => { renderWithStore(); fireEvent.click(screen.getByRole('button', { name: '✕' })); expect(mockNavigate).toHaveBeenCalledWith('/'); }); - it('does not fetch when id is undefined', async () => { + it('passes skip: true to the query when id is undefined', () => { mockUseParams.mockReturnValue({ id: undefined }); + vi.mocked(useGetSinglePokemonQuery).mockReturnValue( + mockQuery({ ...defaultQueryResult, isUninitialized: true }) + ); renderWithStore(); - await act(async () => { - await vi.runAllTimersAsync(); - }); - expect(api.getData).not.toHaveBeenCalled(); + expect(useGetSinglePokemonQuery).toHaveBeenCalledWith('', { skip: true }); }); - it('renders stats and height when details include them', async () => { - vi.mocked(api.getData).mockResolvedValue(mockItemWithStats); + it('renders stats and height when the data includes them', () => { + vi.mocked(useGetSinglePokemonQuery).mockReturnValue( + mockQuery({ + ...defaultQueryResult, + data: mockItemWithStats, + isSuccess: true, + }) + ); renderWithStore(); - await act(async () => { - await vi.runAllTimersAsync(); - }); expect(screen.getByText('hp:')).toBeInTheDocument(); expect(screen.getByText('attack:')).toBeInTheDocument(); expect(screen.getByText('0.7 m')).toBeInTheDocument(); }); + + it('shows fallback error text when error has no message property', () => { + vi.mocked(useGetSinglePokemonQuery).mockReturnValue( + 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 e91e1ed..efd8236 100644 --- a/src/__tests__/Layout.test.tsx +++ b/src/__tests__/Layout.test.tsx @@ -1,10 +1,10 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import Layout from '../components/Layout/Layout'; import pokemonListReducer from '../slices/pokemonListSlice'; -import pokemonDetailsReducer from '../slices/pokemonDetailsSlice'; import selectedItemsReducer from '../slices/selectedItemsSlice'; +import { useGetAllPokemonsQuery, useGetSinglePokemonQuery } from '../api/api'; import { mockItems } from './mocks/mockData'; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -38,43 +38,126 @@ vi.mock('../ThemeContext/context', () => ({ })), })); -const defaultListState = { - items: mockItems, - loading: false, - error: null, - currentPage: 1, - totalCount: 40, - searchTerm: '', -}; +vi.mock('../api/api', () => ({ + useGetAllPokemonsQuery: vi.fn(), + useGetSinglePokemonQuery: vi.fn(), + pokemonApi: { + reducerPath: 'pokemonAPI', + reducer: (state: unknown = {}) => state, + middleware: + () => (next: (action: unknown) => unknown) => (action: unknown) => + next(action), + endpoints: { + getSinglePokemon: { initiate: vi.fn(() => ({ type: 'noop' })) }, + }, + util: { + invalidateTags: vi.fn(() => ({ type: 'noop' })), + }, + }, +})); + +type AllQueryResult = ReturnType; +type SingleQueryResult = ReturnType; + +const mockAll = ( + overrides: Partial<{ + data: AllQueryResult['data']; + isLoading: boolean; + isFetching: boolean; + error: unknown; + }> +) => overrides as unknown as AllQueryResult; -const createTestStore = (listState = defaultListState) => +const mockSingle = ( + overrides: Partial<{ + data: SingleQueryResult['data']; + isLoading: boolean; + isFetching: boolean; + error: unknown; + }> +) => overrides as unknown as SingleQueryResult; + +const createTestStore = () => configureStore({ reducer: { pokemonList: pokemonListReducer, - pokemonDetails: pokemonDetailsReducer, selectedItems: selectedItemsReducer, }, preloadedState: { - pokemonList: listState, - pokemonDetails: { details: null, loading: false, error: null }, + pokemonList: { + items: [], + loading: false, + error: null, + currentPage: 1, + totalCount: 40, + searchTerm: '', + }, selectedItems: { selectedIds: [] }, }, }); -const renderWithStore = (listState = defaultListState) => { - const store = createTestStore(listState); - return render( +const renderWithStore = () => { + const store = createTestStore(); + const result = render( ); + return { ...result, store }; +}; + +const createSelectedStore = () => + configureStore({ + reducer: { + pokemonList: pokemonListReducer, + selectedItems: selectedItemsReducer, + }, + preloadedState: { + pokemonList: { + items: [], + loading: false, + error: null, + currentPage: 1, + totalCount: 40, + searchTerm: '', + }, + selectedItems: { selectedIds: [1] }, + }, + }); + +const renderWithSelectedStore = () => { + const store = createSelectedStore(); + const result = render( + + + + ); + return { ...result, store }; }; describe('Layout', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); mockUseMatch.mockReturnValue(null); window.scrollTo = vi.fn() as typeof window.scrollTo; + + vi.mocked(useGetAllPokemonsQuery).mockReturnValue( + 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, + }) + ); }); it('renders the search bar', () => { @@ -83,17 +166,28 @@ describe('Layout', () => { }); it('shows loading spinner while loading', () => { - renderWithStore({ ...defaultListState, loading: true, items: [] }); + vi.mocked(useGetAllPokemonsQuery).mockReturnValue( + mockAll({ + data: undefined, + isLoading: true, + isFetching: false, + error: undefined, + }) + ); + renderWithStore(); expect(screen.getByRole('status')).toBeInTheDocument(); }); it('shows error message when there is an error', () => { - renderWithStore({ - ...defaultListState, - loading: false, - error: 'Something went wrong', - items: [], - }); + vi.mocked(useGetAllPokemonsQuery).mockReturnValue( + mockAll({ + data: undefined, + isLoading: false, + isFetching: false, + error: { message: 'Something went wrong' }, + }) + ); + renderWithStore(); expect(screen.getByText('Something went wrong')).toBeInTheDocument(); }); @@ -111,14 +205,30 @@ describe('Layout', () => { }); it('does not render pagination while loading', () => { - renderWithStore({ ...defaultListState, loading: true, items: [] }); + vi.mocked(useGetAllPokemonsQuery).mockReturnValue( + mockAll({ + data: undefined, + isLoading: true, + isFetching: false, + error: undefined, + }) + ); + renderWithStore(); expect( screen.queryByRole('button', { name: 'Next' }) ).not.toBeInTheDocument(); }); it('does not render pagination when items list is empty', () => { - renderWithStore({ ...defaultListState, items: [], loading: false }); + vi.mocked(useGetAllPokemonsQuery).mockReturnValue( + mockAll({ + data: { results: [], count: 0 }, + isLoading: false, + isFetching: false, + error: undefined, + }) + ); + renderWithStore(); expect( screen.queryByRole('button', { name: 'Next' }) ).not.toBeInTheDocument(); @@ -137,7 +247,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(); }); @@ -146,4 +256,69 @@ describe('Layout', () => { renderWithStore(); expect(screen.queryByTestId('outlet')).not.toBeInTheDocument(); }); + + it('shows fallback message when error has no message field', () => { + vi.mocked(useGetAllPokemonsQuery).mockReturnValue( + mockAll({ + data: undefined, + isLoading: false, + isFetching: false, + error: { status: 404 }, + }) + ); + renderWithStore(); + expect(screen.getByText('Failed to load pokemon')).toBeInTheDocument(); + }); + + it('dispatches setSearchTerm when Search button is clicked', () => { + const { store } = renderWithStore(); + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value: 'bulbasaur' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + expect(store.getState().pokemonList.searchTerm).toBe('bulbasaur'); + }); + + it('dispatches setCurrentPage when Next button is clicked', () => { + const { store } = renderWithStore(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(store.getState().pokemonList.currentPage).toBe(2); + }); + + it('toggles item selection when checkbox is clicked', () => { + const { store } = renderWithStore(); + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + expect(store.getState().selectedItems.selectedIds).toContain(1); + }); + + it('clears selection when Unselect all is clicked', () => { + const { store } = renderWithSelectedStore(); + fireEvent.click(screen.getByRole('button', { name: 'Unselect all' })); + expect(store.getState().selectedItems.selectedIds).toEqual([]); + }); + + it('downloads CSV when Download button is clicked', async () => { + URL.createObjectURL = vi.fn(() => 'blob:url'); + URL.revokeObjectURL = vi.fn(); + const anchor = { href: '', download: '', click: vi.fn() }; + const origCreate = document.createElement.bind(document); + const createSpy = vi + .spyOn(document, 'createElement') + .mockImplementation((tag: string) => { + if (tag === 'a') return anchor as unknown as HTMLElement; + return origCreate(tag); + }); + + renderWithSelectedStore(); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + + await waitFor(() => { + expect(anchor.click).toHaveBeenCalled(); + }); + + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url'); + createSpy.mockRestore(); + }); }); diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts deleted file mode 100644 index 27d993e..0000000 --- a/src/__tests__/api.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { getData, getAllData } from '../services/api'; -import { mockItem, mockItems } from './mocks/mockData'; - -const createResponse = (data: unknown, ok = true, status = 200): Response => - ({ - ok, - status, - json: () => Promise.resolve(data), - }) as Response; - -describe('API service', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - describe('getData', () => { - it('returns parsed pokemon data on success', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(mockItem)); - const result = await getData('bulbasaur'); - expect(result).toEqual(mockItem); - expect(fetch).toHaveBeenCalledWith( - 'https://pokeapi.co/api/v2/pokemon/bulbasaur' - ); - }); - - it('lowercases the search term', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(mockItem)); - await getData('BULBASAUR'); - expect(fetch).toHaveBeenCalledWith( - 'https://pokeapi.co/api/v2/pokemon/bulbasaur' - ); - }); - - it('throws "Pokemon not found" on 404', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(null, false, 404)); - await expect(getData('unknown')).rejects.toThrow( - 'Pokemon not found. Please check the name' - ); - }); - - it('throws "Invalid request" on 400', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(null, false, 400)); - await expect(getData('')).rejects.toThrow( - 'Invalid request. Please check your input' - ); - }); - - it('throws "Server error" on 500', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(null, false, 500)); - await expect(getData('bulbasaur')).rejects.toThrow( - 'Server error. Please try again later' - ); - }); - - it('throws "Service unavailable" on 503', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(null, false, 503)); - await expect(getData('bulbasaur')).rejects.toThrow( - 'Service is temporarily unavailable' - ); - }); - - it('throws generic error for other status codes', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(null, false, 418)); - await expect(getData('bulbasaur')).rejects.toThrow( - 'Something went wrong' - ); - }); - }); - - describe('getAllData', () => { - it('fetches list then fetches details for each pokemon', async () => { - const listResponse = { - count: 2, - results: [ - { url: 'https://pokeapi.co/api/v2/pokemon/1/' }, - { url: 'https://pokeapi.co/api/v2/pokemon/4/' }, - ], - }; - - vi.mocked(fetch) - .mockResolvedValueOnce(createResponse(listResponse)) - .mockResolvedValueOnce(createResponse(mockItems[0])) - .mockResolvedValueOnce(createResponse(mockItems[1])); - - const result = await getAllData(0, 20); - expect(result).toEqual({ - results: [mockItems[0], mockItems[1]], - count: 2, - }); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it('throws on failed list fetch', async () => { - vi.mocked(fetch).mockResolvedValue(createResponse(null, false, 500)); - await expect(getAllData(0, 20)).rejects.toThrow( - 'Server error. Please try again later' - ); - }); - }); -}); diff --git a/src/__tests__/context.test.ts b/src/__tests__/context.test.ts new file mode 100644 index 0000000..6c27797 --- /dev/null +++ b/src/__tests__/context.test.ts @@ -0,0 +1,10 @@ +import { renderHook } from '@testing-library/react'; +import { useTheme } from '../ThemeContext/context'; + +describe('useTheme', () => { + it('throws when used outside a ThemeProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useTheme())).toThrow('useTheme error'); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/pokemonListSlice.test.ts b/src/__tests__/pokemonListSlice.test.ts new file mode 100644 index 0000000..4178281 --- /dev/null +++ b/src/__tests__/pokemonListSlice.test.ts @@ -0,0 +1,166 @@ +import { configureStore } from '@reduxjs/toolkit'; +import pokemonListReducer, { + fetchPokemonList, + setSearchTerm, + setCurrentPage, + selectTotalPages, +} from '../slices/pokemonListSlice'; +import selectedItemsReducer from '../slices/selectedItemsSlice'; +import { pokemonApi } from '../api/api'; +import { mockItem, mockItems } from './mocks/mockData'; + +vi.mock('../api/api', () => ({ + pokemonApi: { + reducerPath: 'pokemonAPI', + reducer: (state: unknown = {}) => state, + middleware: () => (next: (a: unknown) => unknown) => (action: unknown) => + next(action), + endpoints: { + getSinglePokemon: { initiate: vi.fn() }, + getAllPokemons: { initiate: vi.fn() }, + }, + }, +})); + +const createTestStore = () => + configureStore({ + reducer: { + pokemonList: pokemonListReducer, + selectedItems: selectedItemsReducer, + }, + }); + +describe('pokemonListSlice', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('setSearchTerm updates searchTerm and resets currentPage to 1', () => { + const store = createTestStore(); + store.dispatch(setCurrentPage(3)); + store.dispatch(setSearchTerm('pikachu')); + expect(store.getState().pokemonList.searchTerm).toBe('pikachu'); + expect(store.getState().pokemonList.currentPage).toBe(1); + }); + + it('setCurrentPage updates currentPage', () => { + const store = createTestStore(); + store.dispatch(setCurrentPage(5)); + expect(store.getState().pokemonList.currentPage).toBe(5); + }); + + it('selectTotalPages computes correct number of pages', () => { + const state = { + pokemonList: { + totalCount: 60, + items: [], + loading: false, + error: null, + currentPage: 1, + searchTerm: '', + }, + }; + expect(selectTotalPages(state)).toBe(3); + }); + + 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 + ); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: '', page: 1 }) + ); + expect(store.getState().pokemonList.loading).toBe(true); + await vi.runAllTimersAsync(); + await promise; + }); + + 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 + ); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: '', page: 1 }) + ); + await vi.runAllTimersAsync(); + await promise; + expect(pokemonApi.endpoints.getAllPokemons.initiate).toHaveBeenCalledWith({ + offset: 0, + limit: 20, + }); + expect(store.getState().pokemonList.items).toEqual(mockItems); + expect(store.getState().pokemonList.totalCount).toBe(40); + expect(store.getState().pokemonList.loading).toBe(false); + }); + + it('fetchPokemonList fetches single pokemon when searchTerm is provided', async () => { + const store = createTestStore(); + vi.mocked(pokemonApi.endpoints.getSinglePokemon.initiate).mockReturnValue( + (() => Promise.resolve({ data: mockItem })) as never + ); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: 'bulbasaur', page: 1 }) + ); + await vi.runAllTimersAsync(); + await promise; + expect(pokemonApi.endpoints.getSinglePokemon.initiate).toHaveBeenCalledWith( + 'bulbasaur' + ); + expect(store.getState().pokemonList.items).toEqual([mockItem]); + expect(store.getState().pokemonList.totalCount).toBe(1); + }); + + it('fetchPokemonList rejects when single pokemon result has no data', async () => { + const store = createTestStore(); + vi.mocked(pokemonApi.endpoints.getSinglePokemon.initiate).mockReturnValue( + (() => Promise.resolve({ data: null })) as never + ); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: 'unknown', page: 1 }) + ); + await vi.runAllTimersAsync(); + await promise; + expect(store.getState().pokemonList.error).toBe('Pokemon not found'); + expect(store.getState().pokemonList.loading).toBe(false); + }); + + it('fetchPokemonList rejects when all pokemons result has no data', async () => { + const store = createTestStore(); + vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( + (() => Promise.resolve({ data: null })) as never + ); + 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.loading).toBe(false); + }); + + it('fetchPokemonList sets error when API throws', async () => { + const store = createTestStore(); + vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( + (() => Promise.reject(new Error('Network error'))) as never + ); + const promise = store.dispatch( + fetchPokemonList({ searchTerm: '', page: 1 }) + ); + await vi.runAllTimersAsync(); + await promise; + expect(store.getState().pokemonList.error).toBe('Network error'); + expect(store.getState().pokemonList.items).toEqual([]); + expect(store.getState().pokemonList.loading).toBe(false); + }); +}); diff --git a/src/__tests__/refreshBtn.test.tsx b/src/__tests__/refreshBtn.test.tsx new file mode 100644 index 0000000..3b7716e --- /dev/null +++ b/src/__tests__/refreshBtn.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import RefreshBtn from '../components/refreshBtn/refreshBtn'; +import pokemonListReducer from '../slices/pokemonListSlice'; +import selectedItemsReducer from '../slices/selectedItemsSlice'; + +const mockInvalidateTags = vi.hoisted(() => vi.fn(() => ({ type: 'noop' }))); + +vi.mock('../api/api', () => ({ + pokemonApi: { + reducerPath: 'pokemonAPI', + reducer: (state: unknown = {}) => state, + middleware: () => (next: (a: unknown) => unknown) => (action: unknown) => + next(action), + util: { + invalidateTags: mockInvalidateTags, + }, + }, +})); + +const createTestStore = () => + configureStore({ + reducer: { + pokemonList: pokemonListReducer, + selectedItems: selectedItemsReducer, + }, + }); + +const renderRefreshBtn = () => { + const store = createTestStore(); + render( + + + + ); +}; + +describe('RefreshBtn', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the refresh button', () => { + renderRefreshBtn(); + expect( + screen.getByRole('button', { name: /refresh/i }) + ).toBeInTheDocument(); + }); + + it('calls invalidateTags with Pokemon tag when clicked', () => { + renderRefreshBtn(); + fireEvent.click(screen.getByRole('button', { name: /refresh/i })); + expect(mockInvalidateTags).toHaveBeenCalledWith(['Pokemon']); + }); +}); diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts new file mode 100644 index 0000000..e6f8e97 --- /dev/null +++ b/src/__tests__/store.test.ts @@ -0,0 +1,22 @@ +import store from '../store/store'; + +describe('store', () => { + it('creates the store with the correct slice keys', () => { + const state = store.getState(); + expect(state).toHaveProperty('pokemonAPI'); + expect(state).toHaveProperty('pokemonList'); + expect(state).toHaveProperty('selectedItems'); + }); + + it('initializes pokemonList with default values', () => { + const { pokemonList } = store.getState(); + expect(pokemonList.loading).toBe(false); + expect(pokemonList.items).toEqual([]); + expect(pokemonList.error).toBeNull(); + }); + + it('initializes selectedItems with empty ids', () => { + const { selectedItems } = store.getState(); + expect(selectedItems.selectedIds).toEqual([]); + }); +}); diff --git a/src/__tests__/useLocalStorage.test.ts b/src/__tests__/useLocalStorage.test.ts index a49e2fb..61ff9aa 100644 --- a/src/__tests__/useLocalStorage.test.ts +++ b/src/__tests__/useLocalStorage.test.ts @@ -51,4 +51,28 @@ describe('useLocalStorage', () => { expect(result.current[0]).toBe('charmander'); expect(localStorage.getItem('testKey')).toBe('charmander'); }); + + it('returns empty string when localStorage.getItem throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => { + throw new Error('Storage error'); + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLocalStorage('testKey')); + expect(result.current[0]).toBe(''); + consoleSpy.mockRestore(); + }); + + it('logs error and keeps state when localStorage.setItem throws', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new Error('Storage full'); + }); + const { result } = renderHook(() => useLocalStorage('testKey')); + act(() => { + result.current[1]('squirtle'); + }); + expect(consoleSpy).toHaveBeenCalled(); + expect(result.current[0]).toBe('squirtle'); + consoleSpy.mockRestore(); + }); }); diff --git a/src/__tests__/usePagination.test.tsx b/src/__tests__/usePagination.test.tsx index f833b7e..0355414 100644 --- a/src/__tests__/usePagination.test.tsx +++ b/src/__tests__/usePagination.test.tsx @@ -1,11 +1,24 @@ import { renderHook, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { usePagination } from '../hooks/usePagination'; -import * as api from '../services/api'; +import { pokemonApi } from '../api/api'; import { mockItem, mockItems } from './mocks/mockData'; import type { ReactNode } from 'react'; -vi.mock('../services/api'); +const mockDispatch = vi.hoisted(() => vi.fn()); + +vi.mock('../store/hooks', () => ({ + useAppDispatch: () => mockDispatch, +})); + +vi.mock('../api/api', () => ({ + pokemonApi: { + endpoints: { + getSinglePokemon: { initiate: vi.fn() }, + getAllPokemons: { initiate: vi.fn() }, + }, + }, +})); const wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -14,11 +27,20 @@ const wrapper = ({ children }: { children: ReactNode }) => ( describe('usePagination', () => { beforeEach(() => { vi.useFakeTimers(); - vi.mocked(api.getAllData).mockResolvedValue({ - results: mockItems, - count: 40, + + mockDispatch.mockImplementation(async (action: unknown) => { + if (typeof action === 'function') + return (action as (dispatch: unknown) => unknown)(mockDispatch); + return action; }); - vi.mocked(api.getData).mockResolvedValue(mockItem); + + vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( + (() => + Promise.resolve({ data: { results: mockItems, count: 40 } })) as never + ); + vi.mocked(pokemonApi.endpoints.getSinglePokemon.initiate).mockReturnValue( + (() => Promise.resolve({ data: mockItem })) as never + ); }); afterEach(() => { @@ -38,7 +60,10 @@ describe('usePagination', () => { await act(async () => { await vi.runAllTimersAsync(); }); - expect(api.getAllData).toHaveBeenCalledWith(0, 20); + expect(pokemonApi.endpoints.getAllPokemons.initiate).toHaveBeenCalledWith({ + offset: 0, + limit: 20, + }); expect(result.current.items).toEqual(mockItems); expect(result.current.loading).toBe(false); }); @@ -50,14 +75,19 @@ describe('usePagination', () => { await act(async () => { await vi.runAllTimersAsync(); }); - expect(api.getData).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(api.getAllData).mockRejectedValue( - new Error('Server error. Please try again later') + vi.mocked(pokemonApi.endpoints.getAllPokemons.initiate).mockReturnValue( + (() => + Promise.reject( + new Error('Server error. Please try again later') + )) as never ); const { result } = renderHook(() => usePagination(''), { wrapper }); await act(async () => { @@ -122,10 +152,12 @@ describe('usePagination', () => { }); it('uses initialPage when page URL param resolves to 0', async () => { - const wrapper = ({ children }: { children: ReactNode }) => ( + const localWrapper = ({ children }: { children: ReactNode }) => ( {children} ); - const { result } = renderHook(() => usePagination('', 3), { wrapper }); + const { result } = renderHook(() => usePagination('', 3), { + wrapper: localWrapper, + }); await act(async () => { await vi.runAllTimersAsync(); }); @@ -133,9 +165,12 @@ describe('usePagination', () => { }); it('clears error on new fetch', async () => { - vi.mocked(api.getData).mockRejectedValueOnce( - new Error('Pokemon not found. Please check the name') - ); + 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' } } @@ -147,7 +182,6 @@ describe('usePagination', () => { 'Pokemon not found. Please check the name' ); - vi.mocked(api.getData).mockResolvedValue(mockItem); rerender({ term: 'bulbasaur' }); await act(async () => { await vi.runAllTimersAsync(); diff --git a/src/api/api.test.tsx b/src/api/api.test.tsx new file mode 100644 index 0000000..c311d32 --- /dev/null +++ b/src/api/api.test.tsx @@ -0,0 +1,305 @@ +import { configureStore } from '@reduxjs/toolkit'; +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 { mockItem } from '../__tests__/mocks/mockData'; + +const createStore = () => { + const store = configureStore({ + reducer: { [pokemonApi.reducerPath]: pokemonApi.reducer }, + middleware: (m) => m().concat(pokemonApi.middleware), + }); + setupListeners(store.dispatch); + return store; +}; + +type TestStore = ReturnType; + +const makeWrapper = (store: TestStore) => { + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + return Wrapper; +}; + +const makeJsonResponse = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +describe('pokemonApi / useGetSinglePokemonQuery', () => { + let store: TestStore; + let wrapper: ReturnType; + let fetchMock: ReturnType; + + beforeEach(() => { + store = createStore(); + wrapper = makeWrapper(store); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is in loading state while the request is in flight', () => { + fetchMock.mockReturnValue(new Promise(() => {})); + 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, + }); + 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, + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual({ + message: 'Pokemon not found. Please check the name', + }); + }); + + it('transforms a 400 response into the correct error message', async () => { + fetchMock.mockResolvedValue(makeJsonResponse({}, 400)); + 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', + }); + }); + + it('transforms a 503 response into the correct error message', async () => { + fetchMock.mockResolvedValue(makeJsonResponse({}, 503)); + const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { + wrapper, + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual({ + message: 'Service is temporarily unavailable', + }); + }); + + it('falls back to a generic error message for unknown status codes', async () => { + fetchMock.mockResolvedValue(makeJsonResponse({}, 500)); + const { result } = renderHook(() => useGetSinglePokemonQuery('bulbasaur'), { + wrapper, + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual({ message: 'Something went wrong' }); + }); + + it('does not fetch when skip is true', () => { + fetchMock.mockResolvedValue(makeJsonResponse(mockItem)); + const { result } = renderHook( + () => useGetSinglePokemonQuery('bulbasaur', { skip: true }), + { wrapper } + ); + expect(result.current.isUninitialized).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('serves subsequent identical queries from cache without a new fetch', async () => { + fetchMock.mockResolvedValue(makeJsonResponse(mockItem)); + + const { result: r1 } = renderHook( + () => useGetSinglePokemonQuery('bulbasaur'), + { wrapper } + ); + await waitFor(() => expect(r1.current.isSuccess).toBe(true)); + + const { result: r2 } = renderHook( + () => useGetSinglePokemonQuery('bulbasaur'), + { wrapper } + ); + await waitFor(() => expect(r2.current.isSuccess).toBe(true)); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(r2.current.data).toEqual(mockItem); + }); + + it('makes a new fetch for different query arguments', async () => { + fetchMock + .mockResolvedValueOnce(makeJsonResponse(mockItem)) + .mockResolvedValueOnce( + makeJsonResponse({ ...mockItem, id: 4, name: 'charmander' }) + ); + + const { result: r1 } = renderHook( + () => useGetSinglePokemonQuery('bulbasaur'), + { wrapper } + ); + await waitFor(() => expect(r1.current.isSuccess).toBe(true)); + + const { result: r2 } = renderHook( + () => useGetSinglePokemonQuery('charmander'), + { wrapper } + ); + await waitFor(() => expect(r2.current.isSuccess).toBe(true)); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(r2.current.data?.name).toBe('charmander'); + }); +}); + +describe('pokemonApi / useGetAllPokemonsQuery', () => { + let store: TestStore; + let wrapper: ReturnType; + let fetchMock: ReturnType; + + beforeEach(() => { + store = createStore(); + wrapper = makeWrapper(store); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is in loading state while the request is in flight', () => { + fetchMock.mockReturnValue(new Promise(() => {})); + const { result } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + expect(result.current.isLoading).toBe(true); + }); + + it('fetches individual pokemon details via getSinglePokemon and returns combined data', async () => { + fetchMock + .mockResolvedValueOnce( + makeJsonResponse({ + results: [{ url: 'https://pokeapi.co/api/v2/pokemon/1/' }], + count: 100, + }) + ) + .mockResolvedValueOnce(makeJsonResponse(mockItem)); + + const { result } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.count).toBe(100); + expect(result.current.data?.results).toEqual([mockItem]); + }); + + it('transforms a 503 error for the list endpoint', async () => { + fetchMock.mockResolvedValue(makeJsonResponse({}, 503)); + const { result } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual({ + message: 'Service is temporarily unavailable', + }); + }); + + it('transforms a 404 error for the list endpoint', async () => { + fetchMock.mockResolvedValue(makeJsonResponse({}, 404)); + const { result } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual({ message: 'Pokemon list not found' }); + }); + + it('falls back to a generic error message for unknown status codes', async () => { + fetchMock.mockResolvedValue(makeJsonResponse({}, 500)); + const { result } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual({ + message: 'Failed to load pokemon list. Please try again', + }); + }); + + it('serves repeated queries with the same args from cache without a new fetch', async () => { + fetchMock + .mockResolvedValueOnce( + makeJsonResponse({ + results: [{ url: 'https://pokeapi.co/api/v2/pokemon/1/' }], + count: 100, + }) + ) + .mockResolvedValueOnce(makeJsonResponse(mockItem)); + + const { result: r1 } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(r1.current.isSuccess).toBe(true)); + + const { result: r2 } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(r2.current.isSuccess).toBe(true)); + + // 2 fetches total (list + 1 detail fetch), not 4 — second hook hit the cache + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(r2.current.data).toEqual(r1.current.data); + }); + + it('fetches fresh data for a different offset value', async () => { + fetchMock + .mockResolvedValueOnce( + 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, + }) + ) + .mockResolvedValueOnce( + makeJsonResponse({ ...mockItem, id: 4, name: 'charmander' }) + ); + + const { result: r1 } = renderHook( + () => useGetAllPokemonsQuery({ offset: 0, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(r1.current.isSuccess).toBe(true)); + + const { result: r2 } = renderHook( + () => useGetAllPokemonsQuery({ offset: 20, limit: 20 }), + { wrapper } + ); + await waitFor(() => expect(r2.current.isSuccess).toBe(true)); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(r2.current.data?.results[0].name).toBe('charmander'); + }); +}); diff --git a/src/api/api.tsx b/src/api/api.tsx new file mode 100644 index 0000000..8bfb3ea --- /dev/null +++ b/src/api/api.tsx @@ -0,0 +1,73 @@ +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); + +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 } + >({ + 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, + }), + }), +}); +export const { useGetSinglePokemonQuery, useGetAllPokemonsQuery } = pokemonApi; diff --git a/src/components/DetailsPannel/DetailsPannel.tsx b/src/components/DetailsPannel/DetailsPannel.tsx index 96515f4..f397d70 100644 --- a/src/components/DetailsPannel/DetailsPannel.tsx +++ b/src/components/DetailsPannel/DetailsPannel.tsx @@ -1,30 +1,20 @@ -import { useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; import LoadingSpinner from '../LoadingSpinner/LoadingSpinner'; import type { Stat } from '../../types'; import styles from './style.module.css'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { - fetchPokemonDetails, - clearDetails, -} from '../../slices/pokemonDetailsSlice'; +import { useGetSinglePokemonQuery } from '../../api/api'; const DetailsPannel = () => { const { id } = useParams<{ id: string }>(); - const dispatch = useAppDispatch(); - const { details, loading, error } = useAppSelector( - (state) => state.pokemonDetails - ); + const { + data: details, + isLoading, + error, + } = useGetSinglePokemonQuery(id ?? '', { + skip: !id, + }); const navigate = useNavigate(); - useEffect(() => { - if (!id) return; - dispatch(fetchPokemonDetails(id)); - return () => { - dispatch(clearDetails()); - }; - }, [id, dispatch]); - const handleClose = () => { navigate('/'); }; @@ -38,7 +28,7 @@ const DetailsPannel = () => { - {loading && ( + {isLoading && ( @@ -46,11 +36,14 @@ const DetailsPannel = () => { {error && ( - Error: {error} + + Error:{' '} + {'message' in error ? error.message : 'Failed to load pokemon'} + )} - {details && !loading && !error && ( + {details && !isLoading && !error && ( { 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 + Unselect all diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index ed13f5f..fad7750 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -8,17 +8,18 @@ import CardList from '../CardList/CardList'; import TestButton from '../testButton/testButton'; import { useTheme } from '../../ThemeContext/context'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { - fetchPokemonList, - setSearchTerm, - setCurrentPage, - selectTotalPages, -} from '../../slices/pokemonListSlice'; +import { setSearchTerm, setCurrentPage } from '../../slices/pokemonListSlice'; import { toggleItem, clearAll } from '../../slices/selectedItemsSlice'; -import { getData } from '../../services/api'; +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 RefreshBtn from '../refreshBtn/refreshBtn'; export default function Layout() { const detailsMatch = useMatch('/details/:id'); @@ -26,17 +27,44 @@ export default function Layout() { const dispatch = useAppDispatch(); const [, setSearchParams] = useSearchParams(); - const { items, loading, error, currentPage, searchTerm } = useAppSelector( + const { currentPage, searchTerm } = useAppSelector( (state) => state.pokemonList ); - const totalPages = useAppSelector(selectTotalPages); + 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]); + }, [currentPage]); const handleCardClick = (pokemonId: number) => { navigate(`/details/${pokemonId}`); @@ -54,24 +82,52 @@ export default function Layout() { const handleDownload = async () => { const cachedById = new Map(items.map((item) => [item.id, item])); - const pokemonData = await Promise.all( + const resolved = await Promise.all( selectedIds.map((id) => cachedById.has(id) ? Promise.resolve(cachedById.get(id)!) - : getData(String(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 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(' | ') + ? 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] + return [ + p.id, + p.name, + type, + p.weight, + p.height ?? '', + ability, + description, + detailsUrl, + ] .map((v) => `"${String(v).replace(/"/g, '""')}"`) .join(','); }); @@ -95,14 +151,17 @@ export default function Layout() { /> - dispatch(setSearchTerm(term))} - /> - {loading ? ( + + dispatch(setSearchTerm(term))} + /> + + + {isLoading ? ( ) : error ? ( - {error} + {'message' in error ? error.message : 'Failed to load pokemon'} ) : ( )} - {!loading && items.length > 0 && ( + {!isLoading && items.length > 0 && ( { it('calls onSearch with typed value when Enter is pressed', async () => { const onSearch = vi.fn(); render(); - await userEvent.type(screen.getByPlaceholderText('Search...'), 'pikachu{Enter}'); + await userEvent.type( + screen.getByPlaceholderText('Search...'), + 'pikachu{Enter}' + ); expect(onSearch).toHaveBeenCalledWith('pikachu'); }); it('trims whitespace before calling onSearch', async () => { const onSearch = vi.fn(); render(); - await userEvent.type(screen.getByPlaceholderText('Search...'), ' pikachu '); + await userEvent.type( + screen.getByPlaceholderText('Search...'), + ' pikachu ' + ); await userEvent.click(screen.getByRole('button', { name: /search/i })); expect(onSearch).toHaveBeenCalledWith('pikachu'); }); diff --git a/src/components/pagination/Pagination.test.tsx b/src/components/pagination/Pagination.test.tsx index c16e99c..03111a0 100644 --- a/src/components/pagination/Pagination.test.tsx +++ b/src/components/pagination/Pagination.test.tsx @@ -4,48 +4,64 @@ import Pagination from './Pagination'; describe('Pagination', () => { it('disables Previous button on page 1', () => { - render(); + render( + + ); expect(screen.getByText('Previous')).toBeDisabled(); }); it('disables Next button on last page', () => { - render(); + render( + + ); expect(screen.getByText('Next')).toBeDisabled(); }); it('Previous button is enabled when not on page 1', () => { - render(); + render( + + ); expect(screen.getByText('Previous')).not.toBeDisabled(); }); it('Next button is enabled when not on last page', () => { - render(); + render( + + ); expect(screen.getByText('Next')).not.toBeDisabled(); }); it('clicking Previous calls onPageChange with currentPage - 1', async () => { const onPageChange = vi.fn(); - render(); + render( + + ); await userEvent.click(screen.getByText('Previous')); expect(onPageChange).toHaveBeenCalledWith(2); }); it('clicking Next calls onPageChange with currentPage + 1', async () => { const onPageChange = vi.fn(); - render(); + render( + + ); await userEvent.click(screen.getByText('Next')); expect(onPageChange).toHaveBeenCalledWith(4); }); it('clicking a page number calls onPageChange with that page', async () => { const onPageChange = vi.fn(); - render(); + render( + + ); await userEvent.click(screen.getByText('3')); expect(onPageChange).toHaveBeenCalledWith(3); }); it('shows up to 5 page numbers', () => { - render(); + render( + + ); const pageButtons = screen .getAllByRole('button') .filter((btn) => !['Previous', 'Next'].includes(btn.textContent ?? '')); @@ -53,7 +69,9 @@ describe('Pagination', () => { }); it('centers visible pages around current page', () => { - render(); + render( + + ); expect(screen.getByText('3')).toBeInTheDocument(); expect(screen.getByText('4')).toBeInTheDocument(); expect(screen.getByText('5')).toBeInTheDocument(); diff --git a/src/components/refreshBtn/refreshBtn.tsx b/src/components/refreshBtn/refreshBtn.tsx new file mode 100644 index 0000000..c976ff6 --- /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 ( + + + + + + + Refresh items + + ); +}; + +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..db31ae6 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,23 @@ 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()) + ); + if (!item.data) throw new Error('Pokemon not found'); + 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, + }) + ); + if (!fetchData.data) throw new Error('Failed to fetch pokemon list'); + results = fetchData.data.results; + count = fetchData.data.count; } setItems(results); 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: { - 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 ae6436a..0000000 --- a/src/slices/pokemonDetailsSlice.test.ts +++ /dev/null @@ -1,58 +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.test.ts b/src/slices/pokemonListSlice.test.ts index 7f6290f..878f64d 100644 --- a/src/slices/pokemonListSlice.test.ts +++ b/src/slices/pokemonListSlice.test.ts @@ -59,10 +59,7 @@ describe('pokemonListSlice', () => { }); it('fetchPokemonList.pending sets loading true and clears error', () => { - const state = reducer( - undefined, - { type: fetchPokemonList.pending.type } - ); + const state = reducer(undefined, { type: fetchPokemonList.pending.type }); expect(state.loading).toBe(true); expect(state.error).toBeNull(); }); @@ -72,20 +69,20 @@ describe('pokemonListSlice', () => { results: [{ id: 1, name: 'bulbasaur' }], count: 1, }; - const state = reducer( - undefined, - { type: fetchPokemonList.fulfilled.type, payload } - ); + const state = reducer(undefined, { + type: fetchPokemonList.fulfilled.type, + payload, + }); expect(state.loading).toBe(false); expect(state.items).toEqual(payload.results); expect(state.totalCount).toBe(1); }); it('fetchPokemonList.rejected sets error and clears items', () => { - const state = reducer( - undefined, - { type: fetchPokemonList.rejected.type, payload: 'Pokemon not found' } - ); + const state = reducer(undefined, { + type: fetchPokemonList.rejected.type, + payload: 'Pokemon not found', + }); expect(state.loading).toBe(false); expect(state.error).toBe('Pokemon not found'); expect(state.items).toEqual([]); diff --git a/src/slices/pokemonListSlice.ts b/src/slices/pokemonListSlice.ts index 8f53adf..c6a7c96 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,26 @@ 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 acd39f5..6be0f7f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,14 +1,16 @@ 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'; 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/' : '/',
Error: {error}
+ Error:{' '} + {'message' in error ? error.message : 'Failed to load pokemon'} +
{error}
{'message' in error ? error.message : 'Failed to load pokemon'}
🌟 Portfolio: