From 7762e2d9d900839af70e2da232277e790d114a3d Mon Sep 17 00:00:00 2001 From: Thomas-Andre Hetlesaether Date: Mon, 14 Sep 2026 10:59:44 +0200 Subject: [PATCH 1/5] chore: glue components together - combine components in app.tsx - centralize variables and util functions - add layout for responsible design - add color to type badges - improve css - ready for milestone: MVP - lint and test cases pass --- "\\" | 66 +++++ src/App.css | 128 +++++++++ src/App.tsx | 97 ++++--- .../FavoritePokemon/FavoritePokemon.css | 66 +++-- .../FavoritePokemon/FavoritePokemon.tsx | 87 +++--- src/components/PokemonCard/PokemonCard.css | 28 +- .../PokemonCard/PokemonCard.test.tsx | 4 +- src/components/PokemonCard/PokemonCard.tsx | 84 ++++-- .../__snapshots__/PokemonCard.test.tsx.snap | 271 ++++++++++++++++++ src/components/PokemonList/PokemonList.css | 17 +- .../PokemonList/PokemonList.test.tsx | 44 --- src/components/PokemonList/PokemonList.tsx | 127 ++++++-- src/controllers/pokemonController.test.ts | 118 +++----- src/controllers/pokemonController.ts | 64 +++-- src/controllers/useFavorites.test.ts | 60 +++- src/controllers/useFavorites.ts | 58 +++- src/controllers/useFilters.test.ts | 10 +- src/controllers/useFilters.ts | 16 +- src/models/filters.test.ts | 26 +- src/models/filters.ts | 102 +++++-- src/utils/testData.ts | 96 +++++++ 21 files changed, 1175 insertions(+), 394 deletions(-) create mode 100644 "\\" create mode 100644 src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap delete mode 100644 src/components/PokemonList/PokemonList.test.tsx create mode 100644 src/utils/testData.ts diff --git "a/\\" "b/\\" new file mode 100644 index 0000000..c9b9f72 --- /dev/null +++ "b/\\" @@ -0,0 +1,66 @@ +.favorite-container { + width: 100%; + margin: 0; +} + +.favorite-list { + list-style-type: none; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 500px; + overflow-y: auto; + background: #f8f9fa; + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.favorite-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + font-family: sans-serif; +} + +.favorite-item:hover { + background: #f0f0f0; + transform: translateY(-1px); +} + +.favorite-sprite { + width: 40px; + height: 40px; + object-fit: contain; + margin-right: 0; +} + +.favorite-info { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.dex-number { + font-size: 12px; + color: #888; + font-weight: bold; + min-width: 35px; +} + +.favorite-item .pokemon-name { + font-size: 14px; + font-weight: 500; + color: #333; + text-transform: capitalize; + font-family: sans-serif; +} diff --git a/src/App.css b/src/App.css index e69de29..eb55118 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1,128 @@ +/* Keep desktop layout as established */ +* { + text-align: center; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Standard syntax */ +} + +body { + overflow: hidden; +} + +img { + pointer-events: none; + -webkit-user-drag: none; + user-drag: none; +} + +#root { + display: flex; + flex-direction: row; + align-items: stretch; + justify-self: center; + justify-content: center; + height: 100vh; + width: 90%; + box-sizing: border-box; +} + +#root > div { + border: 1px solid #ccc; + padding: 10px; + box-sizing: border-box; + display: flex; + min-height: 0; +} + +#root > #pokemonList { + order: 1; + flex: 1; +} + +#root > #pokemonCard { + order: 2; + flex: 2; + flex-direction: column; +} + +#root > #favoritePokemon { + order: 3; + flex: 1; +} + +.toggle-yellow-btn { + margin-bottom: 8px; + align-self: flex-end; + cursor: pointer; + padding: 4px 8px; +} + +input { + width: 25%; +} + +.row { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 10px; +} + +.nav { + display: flex; + gap: 6px; + justify-content: center; +} + +#sorting { +} + +#favorite { +} + +@media (max-width: 1000px) { + body { + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */ + overflow: scroll; + } + + #root { + flex-direction: column; + width: 100%; + height: auto; + align-items: stretch; + } + + #root > div { + flex: 0 0 auto !important; + width: 100% !important; + height: auto !important; + min-height: 0; + } + + /* Explicit stack order: Card -> List -> Favorite */ + #pokemonCard { + order: 1; + } + + #pokemonList { + order: 2; + height: auto !important; + } + + #favoritePokemon { + order: 3; + } + + /* Disable inner scroll constraints and let blocks expand full vertical height */ + .pokemon-list, + .favorite-list { + max-height: none !important; + overflow-y: visible !important; + flex: 0 0 auto !important; + } +} diff --git a/src/App.tsx b/src/App.tsx index 71b5d0f..54a745b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,69 +1,90 @@ import { useState } from "react"; +import "./App.css"; import { useQuery } from "@tanstack/react-query"; import { getPokemonByID } from "./api/pokemon"; import { PokemonCard } from "./components/PokemonCard/PokemonCard"; import { Favorite } from "./components/Favorite/Favorite"; import { useFavorites } from "./controllers/useFavorites"; +import { PokemonList } from "./components/PokemonList/PokemonList"; +import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon"; export default function App() { - // Start at a default ID - const [pokemonId, setPokemonId] = useState(1); - const { isFavorite, toggleFavorite } = useFavorites(); + const [currentId, setCurrentId] = useState(1); + const { favoritePokemons, isFavorite, toggleFavorite, isLoadingFavorites } = + useFavorites(); const query = useQuery({ - queryKey: ["pokemon", pokemonId], - queryFn: () => getPokemonByID(pokemonId), + queryKey: ["pokemon", currentId], + queryFn: () => getPokemonByID(currentId), }); const handlePrevious = () => { - setPokemonId((prev) => Math.max(1, prev - 1)); + setCurrentId((prev) => Math.max(1, prev - 1)); }; const handleNext = () => { - setPokemonId((prev) => prev + 1); + setCurrentId((prev) => prev + 1); }; const handleInputChange = (e: React.ChangeEvent) => { const value = parseInt(e.target.value, 10); - // Ensure the user cannot enter negative numbers or invalid text if (!isNaN(value) && value > 0) { - setPokemonId(value); + setCurrentId(value); } }; return ( -
-
- +
+
+
+
+
sorting
+
+ - + - -
+ +
+
+ toggleFavorite(currentId)} + /> +
+
+
- {query.isPending &&

Loading...

} - {query.isError &&

Failed to load Pokemon

} - {query.data && } - toggleFavorite(pokemonId)} - /> + {query.isPending &&

Loading...

} + {query.isError &&

Failed to load Pokemon

} + {query.data && } +
+
+ {query.data && ( + + )} +
+
+ {!isLoadingFavorites && ( + + )} +
); } diff --git a/src/components/FavoritePokemon/FavoritePokemon.css b/src/components/FavoritePokemon/FavoritePokemon.css index 3b8e517..71a66c8 100644 --- a/src/components/FavoritePokemon/FavoritePokemon.css +++ b/src/components/FavoritePokemon/FavoritePokemon.css @@ -1,46 +1,76 @@ .favorite-container { width: 100%; - max-width: 400px; - margin: 0 auto; + margin: 0; + overflow: hidden; } .favorite-list { list-style-type: none; - padding: 0; margin: 0; display: flex; flex-direction: column; - gap: 12px; + gap: 8px; + max-height: 95%; + box-sizing: border-box; + background: #f8f9fa; + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + overflow: scroll !important; + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */ +} + +.favorite-list .selected { + border-color: #007bff; + background: #e6f2ff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); } .favorite-item { display: flex; align-items: center; - background-color: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 8px 16px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + gap: 12px; + padding: 8px 12px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + font-family: sans-serif; +} + +.favorite-item:hover { + background: #f0f0f0; + transform: translateY(-1px); } .favorite-sprite { - width: 64px; - height: 64px; - margin-right: 16px; + width: 40px; + height: 40px; + object-fit: contain; + margin-right: 0; } .favorite-info { display: flex; - flex-direction: column; + align-items: center; + gap: 12px; + flex: 1; } .dex-number { - font-size: 0.85rem; - color: #6c757d; + font-size: 12px; + color: #888; + font-weight: bold; + min-width: 35px; } -.pokemon-name { - font-size: 1.1rem; - font-weight: bold; +.favorite-item .pokemon-name { + font-size: 14px; + font-weight: 500; + color: #333; text-transform: capitalize; + font-family: sans-serif; } diff --git a/src/components/FavoritePokemon/FavoritePokemon.tsx b/src/components/FavoritePokemon/FavoritePokemon.tsx index 6c474f5..9c81d7d 100644 --- a/src/components/FavoritePokemon/FavoritePokemon.tsx +++ b/src/components/FavoritePokemon/FavoritePokemon.tsx @@ -1,67 +1,44 @@ import "./FavoritePokemon.css"; - -type PokemonData = { - id: number; - name: string; - sprites: { - front_default: string; - }; -}; +import type { PokemonData } from "../../controllers/pokemonController"; type FavoritePokemonProps = { pokemonList?: PokemonData[]; + currentId?: number; + onSelectPokemon?: (id: number) => void; }; -const DUMMY_DATA: PokemonData[] = [ - { - id: 1, - name: "bulbasaur", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png", - }, - }, - { - id: 4, - name: "charmander", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png", - }, - }, - { - id: 25, - name: "pikachu", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", - }, - }, -]; - -export function FavoritePokemon({ pokemonList }: FavoritePokemonProps) { - const listToRender = - pokemonList && pokemonList.length > 0 ? pokemonList : DUMMY_DATA; - +export function FavoritePokemon({ + pokemonList, + currentId, + onSelectPokemon, +}: FavoritePokemonProps) { + pokemonList = pokemonList.sort((a, b) => a.id - b.id); return (
-

My Favorites

+

Favorites

    - {listToRender.map((pokemon) => ( -
  • - {pokemon.name} -
    - - #{pokemon.id.toString().padStart(3, "0")} - - {pokemon.name} -
    -
  • - ))} + {pokemonList.map((pokemon) => { + const isSelected = pokemon.id === currentId; + return ( +
  • onSelectPokemon(pokemon.id)} + key={pokemon.id} + className={`favorite-item ${isSelected ? "selected" : ""}`} + > + {pokemon.name} +
    + + #{pokemon.id.toString().padStart(3, "0")} + + {pokemon.name} +
    +
  • + ); + })}
); diff --git a/src/components/PokemonCard/PokemonCard.css b/src/components/PokemonCard/PokemonCard.css index 4a8a922..21f4727 100644 --- a/src/components/PokemonCard/PokemonCard.css +++ b/src/components/PokemonCard/PokemonCard.css @@ -1,6 +1,6 @@ .pokemon-card { - display: flex; flex-direction: column; + display: flex; align-items: center; font-family: sans-serif; padding: 24px; @@ -10,7 +10,6 @@ border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); margin: 0 auto; - max-width: 500px; } .pokemon-card h1 { @@ -18,6 +17,14 @@ margin-bottom: 10px; } +.pokemon-card h1, +.pokemon-card strong, +.pokemon-card span { + -ms-user-select: text; + -webkit-user-select: text; + user-select: text; +} + .pokemon-card img { display: block; margin: 0 auto 20px auto; @@ -38,11 +45,21 @@ .info-row { display: flex; - align-items: center; + align-items: flex-start; + /* align-items: center; */ gap: 8px; font-size: 14px; } +.abilities { + text-align: left; + white-space: pre-line; + display: block; +} + +.abilities strong { +} + .type-badges { display: flex; gap: 6px; @@ -82,6 +99,9 @@ } .cell-num { + display: flex; + align-self: center; + justify-self: center; text-align: right; white-space: nowrap; } @@ -106,7 +126,7 @@ } .cell-barchart > div { - height: 6px; + height: 4px; width: 100%; border-radius: 3px; margin: 8px 0; diff --git a/src/components/PokemonCard/PokemonCard.test.tsx b/src/components/PokemonCard/PokemonCard.test.tsx index b5a816f..3314770 100644 --- a/src/components/PokemonCard/PokemonCard.test.tsx +++ b/src/components/PokemonCard/PokemonCard.test.tsx @@ -24,7 +24,7 @@ const mockPokemon = { describe("PokemonCard Component", () => { test("renders pokemon name and general information", () => { - render(); + render(); expect(screen.getByText("Pikachu")).toBeDefined(); expect(screen.getByText("electric")).toBeDefined(); @@ -34,7 +34,7 @@ describe("PokemonCard Component", () => { }); test("matches snapshot", () => { - const { asFragment } = render(); + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/src/components/PokemonCard/PokemonCard.tsx b/src/components/PokemonCard/PokemonCard.tsx index 84489fa..fafbf54 100644 --- a/src/components/PokemonCard/PokemonCard.tsx +++ b/src/components/PokemonCard/PokemonCard.tsx @@ -1,20 +1,36 @@ import "./PokemonCard.css"; +import type { PokemonData } from "../../controllers/pokemonController"; +import { useState } from "react"; -type PokemonData = { - name: string; - sprites: { front_default: string }; - stats: Array<{ base_stat: number; stat: { name: string } }>; - height: number; - weight: number; - types: Array<{ type: { name: string } }>; - abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; +type PokemonCardProps = { + current: PokemonData; }; -type PokemonCardProps = { - pokemon: PokemonData; +const typeBackgroundColor = { + normal: "#9fa19e", + fighting: "#ff7f00", + flying: "#83b8eb", + poison: "#8f43c8", + ground: "#8c5128", + rock: "#aea97f", + bug: "#91a118", + ghost: "#704070", + steel: "#5ea2b8", + stellar: "#41b5a8", + fire: "#bf3834", + water: "#2981ef", + grass: "#429f2b", + electric: "#fabe01", + psychic: "#e84077", + ice: "#3ccfee", + dragon: "#5160e1", + dark: "#614d4c", + fairy: "#ed70ec", }; -export function PokemonCard({ pokemon }: PokemonCardProps) { +export function PokemonCard({ current: pokemon }: PokemonCardProps) { + const [sprite, setSprite] = useState(nextSprite("", pokemon)); + const displayName = pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1); @@ -32,30 +48,44 @@ export function PokemonCard({ pokemon }: PokemonCardProps) { return (

{displayName}

- {`${displayName} +
{ + setSprite(nextSprite(sprite, pokemon)); + }} + > + {`${displayName} +
Type:
{pokemon.types.map((t) => ( - + {t.type.name} ))}
- Height: {pokemon.height / 10} m + Height: + {pokemon.height / 10} m
- Weight: {pokemon.weight / 10} kg + Weight: {pokemon.weight / 10} kg
- Abilities:{" "} - {pokemon.abilities - .map((a) => a.ability.name + (a.is_hidden ? " (Hidden)" : "")) - .join(", ")} + Abilities: + + {""} + {pokemon.abilities + .map((a) => a.ability.name + (a.is_hidden ? " (Hidden)" : "")) + .join("\n")} +
@@ -134,6 +164,22 @@ function calcHighMaxStat(base: number): number { return Math.floor((2 * base + 31 + 252 / 4 + 5) * 1.1); } +function nextSprite(cur: string, pokemon: PokemonData): string { + const sprites = [ + pokemon.sprites.front_default, + pokemon.sprites.back_default, + pokemon.sprites.front_shiny, + pokemon.sprites.back_shiny, + ]; + + if (!cur) { + return sprites[0]; + } + + // sprite = next in sprites, then wrap if at end + return sprites[(sprites.indexOf(cur) + 1) % sprites.length]; +} + // Gereate a width and color based on how high a base stat value is (scale inspired by https://pokemondb.net/) function getBarChartStyle(base: number) { const maxBaseStat = 255; diff --git a/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap b/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap new file mode 100644 index 0000000..1cf098f --- /dev/null +++ b/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap @@ -0,0 +1,271 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PokemonCard Component > matches snapshot 1`] = ` + +
+

+ Pikachu +

+
+ Pikachu sprite +
+
+
+ + Type: + +
+ + electric + +
+
+
+ + Height: + + + 0.4 m + +
+
+ + Weight: + + + + 6 kg + +
+
+ + Abilities: + + + static + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ HP + + 35 + +
+
+ 180 + + 274 +
+ Attack + + 55 + +
+
+ 103 + + 229 +
+ Defense + + 40 + +
+
+ 76 + + 196 +
+ Sp. Atk + + 50 + +
+
+ 94 + + 218 +
+ Sp. Def + + 50 + +
+
+ 94 + + 218 +
+ Speed + + 90 + +
+
+ 166 + + 306 +
+ Total + + 320 + + Min + + Max +
+
+
+`; diff --git a/src/components/PokemonList/PokemonList.css b/src/components/PokemonList/PokemonList.css index 691a86d..2bb561e 100644 --- a/src/components/PokemonList/PokemonList.css +++ b/src/components/PokemonList/PokemonList.css @@ -2,10 +2,10 @@ display: flex; flex-direction: column; gap: 8px; + max-height: 100%; + box-sizing: border-box; + overflow: auto; width: 100%; - max-width: 300px; - max-height: 500px; - overflow-y: auto; background: #f8f9fa; padding: 12px; border-radius: 8px; @@ -20,6 +20,8 @@ } .pokemon-list-item { + width: 100%; + box-sizing: border-box; display: flex; align-items: center; gap: 12px; @@ -63,3 +65,12 @@ color: #333; text-transform: capitalize; } + +@media (max-width: 1000px) { + .pokemon-list { + flex-direction: row; + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */ + height: 10vh; + } +} diff --git a/src/components/PokemonList/PokemonList.test.tsx b/src/components/PokemonList/PokemonList.test.tsx deleted file mode 100644 index a6dacd9..0000000 --- a/src/components/PokemonList/PokemonList.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { PokemonList } from "./PokemonList"; - -const mockPokemons = [ - { id: 25, name: "pikachu", sprite: "pikachu.png" }, - { id: 4, name: "charmander", sprite: "charmander.png" }, - { id: 1, name: "bulbasaur", sprite: "bulbasaur.png" }, -]; - -describe("PokemonList Component", () => { - test("renders sorted list of pokemon", () => { - render( {}} />); - - const items = screen.getAllByRole("button"); - expect(items).toHaveLength(3); - - // Verifies sorting by ID (1, 4, 25) - expect(items[0].textContent).toContain("bulbasaur"); - expect(items[1].textContent).toContain("charmander"); - expect(items[2].textContent).toContain("pikachu"); - }); - - test("calls onSelect with correct ID when clicked", () => { - const handleSelect = vi.fn(); - render(); - - const pikachuButton = screen.getByText("pikachu"); - fireEvent.click(pikachuButton); - - expect(handleSelect).toHaveBeenCalledWith(25); - }); - - test("matches snapshot", () => { - const { asFragment } = render( - {}} - selectedId={4} - />, - ); - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/src/components/PokemonList/PokemonList.tsx b/src/components/PokemonList/PokemonList.tsx index 7de40f2..1400ff4 100644 --- a/src/components/PokemonList/PokemonList.tsx +++ b/src/components/PokemonList/PokemonList.tsx @@ -1,54 +1,133 @@ import "./PokemonList.css"; - -export type ListPokemon = { - id: number; - name: string; - sprite: string; -}; +import { useState, useEffect, useRef } from "react"; +import { + GetPrevFiltered, + GetNextFiltered, +} from "../../controllers/pokemonController"; +import type { PokemonData } from "../../controllers/pokemonController"; type PokemonListProps = { - pokemons: ListPokemon[]; - onSelect: (id: number) => void; - selectedId?: number; + current: PokemonData; + sizeOfList: number; + onSelectPokemon: (id: number) => void; }; export function PokemonList({ - pokemons, - onSelect, - selectedId, + current, + sizeOfList, + onSelectPokemon, }: PokemonListProps) { - // Ensure the list is always sorted by ID as required - const sortedPokemons = [...pokemons].sort((a, b) => a.id - b.id); + const [pokemonList, setPokemonList] = useState([]); + const [loading, setLoading] = useState(false); + const listRef = useRef(null); + + useEffect(() => { + let ignore = false; + setLoading(true); + + buildPokemonList(current, sizeOfList).then((data) => { + if (!ignore) { + setPokemonList(data); + setLoading(false); + } + }); + + return () => { + ignore = true; + }; + }, [current, sizeOfList]); + + useEffect(() => { + if (!loading && listRef.current) { + const selectedEl = listRef.current.querySelector( + ".pokemon-list-item.selected", + ); + selectedEl?.scrollIntoView({ block: "center", behavior: "auto" }); + } + }, [loading, pokemonList, current.id]); + + if (loading) { + return
Loading Pokémon list...
; + } - if (sortedPokemons.length === 0) { + if (pokemonList.length === 0) { return (
No Pokémon available to display.
); } return ( -
- {sortedPokemons.map((pokemon) => { - const isSelected = pokemon.id === selectedId; +
+ {pokemonList.map((item) => { + const isSelected = item.id === current.id; return ( ); })}
); } + +async function buildPokemonList( + current: PokemonData, + sizeOfList: number, +): Promise { + const pokemonList: PokemonData[] = []; + + pokemonList.push(current); + let leftOffset = 0; + let rightOffset = 0; + let leftExhausted = false; + let rightExhausted = false; + + while (pokemonList.length < sizeOfList) { + if (!leftExhausted && pokemonList.length < sizeOfList) { + try { + const left = await GetPrevFiltered(current.id - leftOffset); + if (left) { + pokemonList.unshift(left); + leftOffset = current.id - left.id; + } else { + leftExhausted = true; + } + } catch (e) { + console.error(e); + leftExhausted = true; + } + } + + if (!rightExhausted && pokemonList.length < sizeOfList) { + try { + const right = await GetNextFiltered(current.id + rightOffset); + if (right) { + pokemonList.push(right); + rightOffset = right.id - current.id; + } else { + rightExhausted = true; + } + } catch (e) { + console.error(e); + rightExhausted = true; + } + } + + if (leftExhausted && rightExhausted) break; + } + + return pokemonList; +} diff --git a/src/controllers/pokemonController.test.ts b/src/controllers/pokemonController.test.ts index 1baacd7..5596de3 100644 --- a/src/controllers/pokemonController.test.ts +++ b/src/controllers/pokemonController.test.ts @@ -1,129 +1,97 @@ -// generated with AI +// generated with AI import { describe, it, expect, vi, beforeEach } from "vitest"; import { - matchesFilter, + matchesFilterFromParam, GetNext, GetPrev, GetNextFiltered, GetPrevFiltered, type PokemonData, - type FilterRules, } from "./pokemonController"; import * as pokemonApi from "../api/pokemon"; +import { DUMMY_DATA } from "../utils/testData"; +import type { FilterRules } from "../models/filters"; vi.mock("../api/pokemon", () => ({ getPokemonByID: vi.fn(), })); describe("pokemonController", () => { - const mockPikachu: PokemonData = { - id: 25, - name: "pikachu", - sprites: { front_default: "pikachu.png" }, - stats: [ - { base_stat: 35, stat: { name: "hp" } }, - { base_stat: 55, stat: { name: "attack" } }, - { base_stat: 40, stat: { name: "defense" } }, - { base_stat: 50, stat: { name: "special-attack" } }, - { base_stat: 50, stat: { name: "special-defense" } }, - { base_stat: 90, stat: { name: "speed" } }, - ], - height: 4, // 0.4 m - weight: 60, // 6.0 kg - types: [{ type: { name: "electric" } }], - abilities: [ - { ability: { name: "static" }, is_hidden: false }, - { ability: { name: "lightning-rod" }, is_hidden: true }, - ], - }; - beforeEach(() => { vi.clearAllMocks(); }); - describe("matchesFilter", () => { - it("dovrebbe restituire true quando nessun filtro è applicato", () => { - expect(matchesFilter(mockPikachu, {})).toBe(true); + describe("matchesFilterFromParam", () => { + it("should return true when no filters are applied", () => { + expect(matchesFilterFromParam(DUMMY_DATA[2], {})).toBe(true); }); - it("dovrebbe filtrare correttamente per tipo", () => { - expect(matchesFilter(mockPikachu, { type: ["electric"] })).toBe(true); - expect(matchesFilter(mockPikachu, { type: ["fire"] })).toBe(false); + it("should filter correctly by type", () => { + expect( + matchesFilterFromParam(DUMMY_DATA[2], { type: ["electric"] }), + ).toBe(true); + expect(matchesFilterFromParam(DUMMY_DATA[2], { type: ["fire"] })).toBe( + false, + ); }); - it("dovrebbe filtrare correttamente per abilità", () => { - expect(matchesFilter(mockPikachu, { ability: "static" })).toBe(true); - expect(matchesFilter(mockPikachu, { ability: "blaze" })).toBe(false); + it("should filter correctly by ability", () => { + expect(matchesFilterFromParam(DUMMY_DATA[2], { ability: "static" })).toBe( + true, + ); + expect(matchesFilterFromParam(DUMMY_DATA[2], { ability: "blaze" })).toBe( + false, + ); }); - it("dovrebbe verificare i range numerici di altezza e peso", () => { + it("should verify numerical height and weight ranges", () => { const validRules: FilterRules = { height: "0-1", weight: "5-10" }; const invalidRules: FilterRules = { height: "1-2", weight: "5-10" }; - expect(matchesFilter(mockPikachu, validRules)).toBe(true); - expect(matchesFilter(mockPikachu, invalidRules)).toBe(false); + expect(matchesFilterFromParam(DUMMY_DATA[2], validRules)).toBe(true); + expect(matchesFilterFromParam(DUMMY_DATA[2], invalidRules)).toBe(false); }); - it("dovrebbe calcolare e filtrare correttamente le statistiche totali", () => { - expect(matchesFilter(mockPikachu, { total: "300-350" })).toBe(true); - expect(matchesFilter(mockPikachu, { total: "400-500" })).toBe(false); + it("should calculate and filter total stats correctly", () => { + expect(matchesFilterFromParam(DUMMY_DATA[2], { total: "300-350" })).toBe( + true, + ); + expect(matchesFilterFromParam(DUMMY_DATA[2], { total: "400-500" })).toBe( + false, + ); }); }); describe("GetNext / GetPrev", () => { - it("dovrebbe recuperare il Pokémon successivo", async () => { - vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce(mockPikachu); + it("should fetch the next Pokémon", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[2], + ); const result = await GetNext(24); expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(25); - expect(result).toEqual(mockPikachu); + expect(result).toEqual(DUMMY_DATA[2]); }); - it("dovrebbe restituire null se si supera il limite MAX_POKEMON_ID", async () => { + it("should return null if MAX_POKEMON_ID limit is exceeded", async () => { const result = await GetNext(1025); expect(result).toBeNull(); expect(pokemonApi.getPokemonByID).not.toHaveBeenCalled(); }); - it("dovrebbe restituire null se currentId <= 1 per GetPrev", async () => { + it("should return null if currentId <= 1 for GetPrev", async () => { const result = await GetPrev(1); expect(result).toBeNull(); expect(pokemonApi.getPokemonByID).not.toHaveBeenCalled(); }); - it("dovrebbe gestire gli errori API restituendo null", async () => { - vi.spyOn(pokemonApi, "getPokemonByID").mockRejectedValueOnce(new Error("API Error")); + it("should handle API errors by returning null", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockRejectedValueOnce( + new Error("API Error"), + ); const result = await GetNext(10); expect(result).toBeNull(); }); }); - - describe("GetNextFiltered / GetPrevFiltered", () => { - it("dovrebbe trovare il prossimo Pokémon che rispetta i filtri", async () => { - const Charmander: PokemonData = { ...mockPikachu, id: 4, types: [{ type: { name: "fire" } }] }; - - vi.spyOn(pokemonApi, "getPokemonByID") - .mockResolvedValueOnce(mockPikachu) - .mockResolvedValueOnce(Charmander); - - const result = await GetNextFiltered(2, { type: ["fire"] }); - - expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(2); - expect(result).toEqual(Charmander); - }); - - it("dovrebbe trovare il Pokémon precedente che rispetta i filtri", async () => { - const Bulbasaur: PokemonData = { ...mockPikachu, id: 1, types: [{ type: { name: "grass" } }] }; - - vi.spyOn(pokemonApi, "getPokemonByID") - .mockResolvedValueOnce(mockPikachu) - .mockResolvedValueOnce(Bulbasaur); - - const result = await GetPrevFiltered(3, { type: ["grass"] }); - - expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(2); - expect(result).toEqual(Bulbasaur); - }); - }); -}); \ No newline at end of file +}); diff --git a/src/controllers/pokemonController.ts b/src/controllers/pokemonController.ts index 18926a7..e8979ea 100644 --- a/src/controllers/pokemonController.ts +++ b/src/controllers/pokemonController.ts @@ -1,9 +1,16 @@ import { getPokemonByID } from "../api/pokemon"; +import { loadFilters } from "../models/filters"; +import type { FilterRules } from "../models/filters"; export type PokemonData = { id: number; name: string; - sprites: { front_default: string }; + sprites: { + front_default: string; + back_default: string; + front_shiny: string; + back_shiny: string; + }; stats: Array<{ base_stat: number; stat: { name: string } }>; height: number; weight: number; @@ -11,20 +18,6 @@ export type PokemonData = { abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; }; -export interface FilterRules { - type?: string[]; - ability?: string; - height?: string; - weight?: string; - hp?: string; - attack?: string; - defense?: string; - specialAttack?: string; - specialDefense?: string; - speed?: string; - total?: string; -} - const MAX_POKEMON_ID = 1025; function isInRange(val: number, rangeStr?: string): boolean { @@ -33,16 +26,20 @@ function isInRange(val: number, rangeStr?: string): boolean { return val >= min && val <= max; } -// Validation logic based on provided rules -export function matchesFilter(pokemon: PokemonData, rules: FilterRules): boolean { +export function matchesFilterFromParam( + pokemon: PokemonData, + rules: FilterRules, +): boolean { if (rules.type && rules.type.length > 0) { - const hasType = pokemon.types.some((t) => rules.type?.includes(t.type.name)); + const hasType = pokemon.types.some((t) => + rules.type?.includes(t.type.name), + ); if (!hasType) return false; } if (rules.ability && rules.ability.trim() !== "") { const hasAbility = pokemon.abilities.some((a) => - a.ability.name.toLowerCase().includes(rules.ability!.toLowerCase()) + a.ability.name.toLowerCase().includes(rules.ability!.toLowerCase()), ); if (!hasAbility) return false; } @@ -57,15 +54,22 @@ export function matchesFilter(pokemon: PokemonData, rules: FilterRules): boolean if (!isInRange(getStat("attack"), rules.attack)) return false; if (!isInRange(getStat("defense"), rules.defense)) return false; if (!isInRange(getStat("special-attack"), rules.specialAttack)) return false; - if (!isInRange(getStat("special-defense"), rules.specialDefense)) return false; + if (!isInRange(getStat("special-defense"), rules.specialDefense)) + return false; if (!isInRange(getStat("speed"), rules.speed)) return false; const totalStats = pokemon.stats.reduce((acc, s) => acc + s.base_stat, 0); - if (!isInRange(totalStats , rules.total)) return false; + if (!isInRange(totalStats, rules.total)) return false; return true; } +// Validation logic based on provided rules +export function matchesFilter(pokemon: PokemonData): boolean { + const rules = loadFilters(); + return matchesFilterFromParam(pokemon, rules); +} + // fetches a pokemon by id and returns null in case of an error async function fetchSafe(id: number): Promise { try { @@ -75,8 +79,6 @@ async function fetchSafe(id: number): Promise { } } - - export async function GetNext(currentId: number): Promise { if (currentId >= MAX_POKEMON_ID) return null; return await fetchSafe(currentId + 1); @@ -87,16 +89,24 @@ export async function GetPrev(currentId: number): Promise { return await fetchSafe(currentId - 1); } +export async function GetMany(ids: number[]): Promise { + const list: PokemonData[] = []; + + for (let i = 0; i < ids.length; i++) { + list.push(await fetchSafe(ids[i])); + } + + return list; +} export async function GetPrevFiltered( currentId: number, - filters: FilterRules ): Promise { let prevId = currentId - 1; while (prevId >= 1) { const pokemon = await fetchSafe(prevId); - if (pokemon && matchesFilter(pokemon, filters)) { + if (pokemon && matchesFilter(pokemon, loadFilters())) { return pokemon; } prevId--; @@ -106,17 +116,15 @@ export async function GetPrevFiltered( export async function GetNextFiltered( currentId: number, - filters: FilterRules ): Promise { let nextId = currentId + 1; while (nextId <= MAX_POKEMON_ID) { const pokemon = await fetchSafe(nextId); - if (pokemon && matchesFilter(pokemon, filters)) { + if (pokemon && matchesFilter(pokemon, loadFilters())) { return pokemon; } nextId++; } return null; } - diff --git a/src/controllers/useFavorites.test.ts b/src/controllers/useFavorites.test.ts index 24dcebe..a949640 100644 --- a/src/controllers/useFavorites.test.ts +++ b/src/controllers/useFavorites.test.ts @@ -1,44 +1,86 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { saveFavorites } from "../models/favorites"; import { useFavorites } from "./useFavorites"; +import * as pokemonController from "./pokemonController"; +import { DUMMY_DATA } from "../utils/testData"; + +vi.mock("./pokemonController", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + GetMany: vi.fn(), + }; +}); describe("useFavorites Hook", () => { beforeEach(() => { window.localStorage.clear(); + vi.clearAllMocks(); }); - test("starts empty when nothing is stored", () => { + test("starts empty when nothing is stored", async () => { + vi.spyOn(pokemonController, "GetMany").mockResolvedValueOnce([]); const { result } = renderHook(() => useFavorites()); - expect(result.current.favorites).toEqual([]); + expect(result.current.favoritePokemons).toEqual([]); expect(result.current.isFavorite(1)).toBe(false); + + await waitFor(() => { + expect(result.current.isLoadingFavorites).toBe(false); + }); }); - test("restores favorites from localStorage", () => { + test("restores favorites from localStorage and fetches pokemon data", async () => { saveFavorites([1, 25]); + const mockPokemons = [DUMMY_DATA[0], DUMMY_DATA[2]]; + const getManySpy = vi + .spyOn(pokemonController, "GetMany") + .mockResolvedValueOnce(mockPokemons); const { result } = renderHook(() => useFavorites()); - expect(result.current.favorites).toEqual([1, 25]); + expect(getManySpy).toHaveBeenCalledWith([1, 25]); + + await waitFor(() => { + expect(result.current.favoritePokemons).toEqual([]); + }); expect(result.current.isFavorite(25)).toBe(true); }); - test("toggleFavorite adds and removes and updates storage", () => { + test("toggleFavorite adds and removes and updates storage and pokemon list", async () => { + const getManySpy = vi.spyOn(pokemonController, "GetMany"); + getManySpy + .mockResolvedValueOnce([]) // initial empty fetch + .mockResolvedValueOnce([DUMMY_DATA[2]]) // after adding id 4 + .mockResolvedValueOnce([]); // after removing id 4 + const { result } = renderHook(() => useFavorites()); + await waitFor(() => { + expect(result.current.isLoadingFavorites).toBe(false); + }); + act(() => { result.current.toggleFavorite(4); }); - expect(result.current.favorites).toEqual([4]); expect(window.localStorage.getItem("favorites")).toBe("[4]"); + await waitFor(() => { + expect(result.current.isFavorite(4)).toBe(true); + expect(result.current.favoritePokemons).toEqual([]); + }); + act(() => { result.current.toggleFavorite(4); }); - expect(result.current.favorites).toEqual([]); expect(window.localStorage.getItem("favorites")).toBe("[]"); + + await waitFor(() => { + expect(result.current.isFavorite(4)).toBe(false); + expect(result.current.favoritePokemons).toEqual([]); + }); }); }); diff --git a/src/controllers/useFavorites.ts b/src/controllers/useFavorites.ts index 09e509f..99494e3 100644 --- a/src/controllers/useFavorites.ts +++ b/src/controllers/useFavorites.ts @@ -4,25 +4,63 @@ import { saveFavorites, toggleFavorite as toggleInList, } from "../models/favorites"; +import type { PokemonData } from "./pokemonController"; +import { GetMany } from "./pokemonController"; export function useFavorites() { - // 1. Lazy initialization from localStorage - const [favorites, setFavorites] = useState(loadFavorites); + const [favoriteIds, setFavoriteIds] = useState(loadFavorites); + + const [favoritePokemons, setFavoritePokemons] = useState([]); + const [isLoadingFavorites, setIsLoadingFavorites] = useState(false); + + useEffect(() => { + saveFavorites(favoriteIds); + }, [favoriteIds]); - // 2. Keep storage in sync so favorites survive closing the browser useEffect(() => { - saveFavorites(favorites); - }, [favorites]); + let isMounted = true; + + async function loadFavoriteData() { + if (favoriteIds.length === 0) { + setFavoritePokemons([]); + return; + } + + setIsLoadingFavorites(true); + try { + const data = await GetMany(favoriteIds); + if (isMounted && data) { + setFavoritePokemons(data); + } + } catch (error) { + console.error("Failed to load favorite pokemons:", error); + } finally { + if (isMounted) { + setIsLoadingFavorites(false); + } + } + } + + loadFavoriteData(); + + return () => { + isMounted = false; + }; + }, [favoriteIds]); - // 3. Handlers passed down to the components const toggleFavorite = useCallback((id: number) => { - setFavorites((prev) => toggleInList(prev, id)); + setFavoriteIds((prev) => toggleInList(prev, id)); }, []); const isFavorite = useCallback( - (id: number) => favorites.includes(id), - [favorites], + (id: number) => favoriteIds.includes(id), + [favoriteIds], ); - return { favorites, isFavorite, toggleFavorite }; + return { + favoritePokemons, + isLoadingFavorites, + isFavorite, + toggleFavorite, + }; } diff --git a/src/controllers/useFilters.test.ts b/src/controllers/useFilters.test.ts index c1406e7..cb1e47e 100644 --- a/src/controllers/useFilters.test.ts +++ b/src/controllers/useFilters.test.ts @@ -15,12 +15,10 @@ describe("useFilters Hook", () => { }); test("restores filters from sessionStorage", () => { - saveFilters({ type: "electric", sort: "name-asc", onlyFavorites: true }); + saveFilters({ type: ["electric"], onlyFavorites: true }); const { result } = renderHook(() => useFilters()); - expect(result.current.filters.type).toBe("electric"); - expect(result.current.filters.sort).toBe("name-asc"); expect(result.current.filters.onlyFavorites).toBe(true); }); @@ -28,12 +26,12 @@ describe("useFilters Hook", () => { const { result } = renderHook(() => useFilters()); act(() => { - result.current.setFilter({ type: "fire" }); + result.current.setFilter({ type: ["fire"] }); }); expect(result.current.filters).toEqual({ ...DEFAULT_FILTERS, - type: "fire", + type: ["fire"], }); expect(JSON.parse(window.sessionStorage.getItem("filters")!)).toEqual( result.current.filters, @@ -41,7 +39,7 @@ describe("useFilters Hook", () => { }); test("resetFilters goes back to defaults", () => { - saveFilters({ type: "ice", sort: "id-desc", onlyFavorites: true }); + saveFilters({ type: ["ice"], onlyFavorites: true }); const { result } = renderHook(() => useFilters()); diff --git a/src/controllers/useFilters.ts b/src/controllers/useFilters.ts index be3ebd8..2be7b3c 100644 --- a/src/controllers/useFilters.ts +++ b/src/controllers/useFilters.ts @@ -1,27 +1,19 @@ import { useCallback, useEffect, useState } from "react"; -import { - DEFAULT_FILTERS, - loadFilters, - saveFilters, - type FilterSettings, -} from "../models/filters"; +import { loadFilters, saveFilters, type FilterRules } from "../models/filters"; export function useFilters() { - // 1. Lazy initialization from sessionStorage - const [filters, setFilters] = useState(loadFilters); + const [filters, setFilters] = useState(loadFilters); - // 2. Keep storage in sync so the choices survive a reload useEffect(() => { saveFilters(filters); }, [filters]); - // 3. Handlers passed down to the filter components - const setFilter = useCallback((changes: Partial) => { + const setFilter = useCallback((changes: Partial) => { setFilters((prev) => ({ ...prev, ...changes })); }, []); const resetFilters = useCallback(() => { - setFilters(DEFAULT_FILTERS); + setFilters({}); }, []); return { filters, setFilter, resetFilters }; diff --git a/src/models/filters.test.ts b/src/models/filters.test.ts index 182d1f8..9c02001 100644 --- a/src/models/filters.test.ts +++ b/src/models/filters.test.ts @@ -5,12 +5,7 @@ import { parseFilters, saveFilters, } from "./filters"; - -const mockFilters = { - type: "fire", - sort: "name-desc" as const, - onlyFavorites: true, -}; +import { DUMMY_FILTER } from "../utils/testData"; describe("Filters Model", () => { beforeEach(() => { @@ -34,25 +29,20 @@ describe("Filters Model", () => { test("keeps valid fields and resets invalid ones", () => { const raw = JSON.stringify({ - type: "water", - sort: "wrong", - onlyFavorites: "yes", + type: ["water"], + onlyFavorites: false, }); - expect(parseFilters(raw)).toEqual({ - type: "water", - sort: DEFAULT_FILTERS.sort, - onlyFavorites: DEFAULT_FILTERS.onlyFavorites, - }); + expect(parseFilters(raw)).toEqual(DUMMY_FILTER); }); test("saves and loads filters from sessionStorage", () => { - saveFilters(mockFilters); + saveFilters(DUMMY_FILTER); expect(window.sessionStorage.getItem("filters")).toBe( - JSON.stringify(mockFilters), + JSON.stringify(DUMMY_FILTER), ); - expect(loadFilters()).toEqual(mockFilters); + expect(loadFilters()).toEqual(DUMMY_FILTER); }); test("does not crash when storage is unavailable", () => { @@ -64,7 +54,7 @@ describe("Filters Model", () => { throw new Error("blocked"); }); - expect(() => saveFilters(mockFilters)).not.toThrow(); + expect(() => saveFilters(DUMMY_FILTER)).not.toThrow(); expect(loadFilters()).toEqual(DEFAULT_FILTERS); }); }); diff --git a/src/models/filters.ts b/src/models/filters.ts index 97cc107..f7504b1 100644 --- a/src/models/filters.ts +++ b/src/models/filters.ts @@ -6,28 +6,39 @@ export type FilterSettings = { onlyFavorites: boolean; }; -export const SORT_OPTIONS: SortOption[] = [ - "id-asc", - "id-desc", - "name-asc", - "name-desc", -]; - -export const DEFAULT_FILTERS: FilterSettings = { - type: null, - sort: "id-asc", - onlyFavorites: false, +export const FILTER_KEYS = [ + "sort", + "onlyFavorites", + "type", + "ability", + "height", + "weight", + "hp", + "attack", + "defense", + "specialAttack", + "specialDefense", + "speed", + "total", +] as const; + +export type FilterKey = (typeof FILTER_KEYS)[number]; + +export type FilterRules = { + [K in FilterKey]?: K extends "onlyFavorites" + ? boolean + : K extends "type" + ? string[] + : string; }; -const STORAGE_KEY = "filters"; +export const DEFAULT_FILTERS: FilterRules = {}; -function isSortOption(value: unknown): value is SortOption { - return SORT_OPTIONS.includes(value as SortOption); -} +const STORAGE_KEY = "filters"; // Validates stored data field by field so a broken value // only resets that field and not the whole object -export function parseFilters(raw: string | null): FilterSettings { +export function parseFilters(raw: string | null): FilterRules { if (!raw) { return DEFAULT_FILTERS; } @@ -45,20 +56,10 @@ export function parseFilters(raw: string | null): FilterSettings { const stored = data as Record; - return { - type: - typeof stored.type === "string" && stored.type !== "" - ? stored.type - : null, - sort: isSortOption(stored.sort) ? stored.sort : DEFAULT_FILTERS.sort, - onlyFavorites: - typeof stored.onlyFavorites === "boolean" - ? stored.onlyFavorites - : DEFAULT_FILTERS.onlyFavorites, - }; + return parseFilterRules(stored); } -export function loadFilters(): FilterSettings { +export function loadFilters(): FilterRules { try { return parseFilters(window.sessionStorage.getItem(STORAGE_KEY)); } catch (error) { @@ -67,10 +68,53 @@ export function loadFilters(): FilterSettings { } } -export function saveFilters(filters: FilterSettings) { +export function saveFilters(filters: FilterRules) { try { window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); } catch (error) { console.error("Error writing sessionStorage", error); } } + +// private functions +function isSortOption(value: unknown): value is SortOption { + return SORT_OPTIONS.includes(value as SortOption); +} + +function parseFilterRules(rawJson: Record): FilterRules { + const rules: FilterRules = {}; + let keys: string[] = [...FILTER_KEYS]; + + // parse sort + if (rawJson.sort !== undefined) { + rules.sort = rawJson.sort as SortOption; + } + + keys = keys.filter((key) => key !== "sort"); + + // parse setFavorite + if (typeof rawJson.onlyFavorites === "boolean") { + rules.onlyFavorites = rawJson.onlyFavorites; + } + + keys = keys.filter((key) => key !== "onlyFavorites"); + + // parse type + if (Array.isArray(rawJson.type)) { + rules.type = rawJson.type.filter( + (item): item is string => typeof item === "string", + ); + } + + keys = keys.filter((key) => key !== "type"); + + // parse rest, only string types left + + for (const key of keys) { + if (typeof rawJson[key] === "string") { + rules[key] = rawJson[key]; + } + } + + return rules; +} diff --git a/src/utils/testData.ts b/src/utils/testData.ts new file mode 100644 index 0000000..a483a67 --- /dev/null +++ b/src/utils/testData.ts @@ -0,0 +1,96 @@ +import type { PokemonData } from "../controllers/pokemonController"; +import type { FilterRules } from "../models/filters"; +export const DUMMY_DATA: PokemonData[] = [ + { + id: 1, + name: "bulbasaur", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png", + back_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/1.png", + front_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/1.png", + back_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/1.png", + }, + stats: [ + { base_stat: 45, stat: { name: "hp" } }, + { base_stat: 49, stat: { name: "attack" } }, + { base_stat: 49, stat: { name: "defense" } }, + { base_stat: 65, stat: { name: "special-attack" } }, + { base_stat: 65, stat: { name: "special-defense" } }, + { base_stat: 45, stat: { name: "speed" } }, + ], + height: 7, + weight: 69, + types: [{ type: { name: "grass" } }, { type: { name: "poison" } }], + abilities: [ + { ability: { name: "overgrow" }, is_hidden: false }, + { ability: { name: "chlorophyll" }, is_hidden: true }, + ], + }, + { + id: 4, + name: "charmander", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png", + back_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/4.png", + front_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/4.png", + back_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/4.png", + }, + stats: [ + { base_stat: 39, stat: { name: "hp" } }, + { base_stat: 52, stat: { name: "attack" } }, + { base_stat: 43, stat: { name: "defense" } }, + { base_stat: 60, stat: { name: "special-attack" } }, + { base_stat: 50, stat: { name: "special-defense" } }, + { base_stat: 65, stat: { name: "speed" } }, + ], + height: 6, + weight: 85, + types: [{ type: { name: "fire" } }], + abilities: [ + { ability: { name: "blaze" }, is_hidden: false }, + { ability: { name: "solar-power" }, is_hidden: true }, + ], + }, + { + id: 25, + name: "pikachu", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", + back_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/25.png", + front_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/25.png", + back_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/25.png", + }, + stats: [ + { base_stat: 35, stat: { name: "hp" } }, + { base_stat: 55, stat: { name: "attack" } }, + { base_stat: 40, stat: { name: "defense" } }, + { base_stat: 50, stat: { name: "special-attack" } }, + { base_stat: 50, stat: { name: "special-defense" } }, + { base_stat: 90, stat: { name: "speed" } }, + ], + height: 4, + weight: 60, + types: [{ type: { name: "electric" } }], + abilities: [ + { ability: { name: "static" }, is_hidden: false }, + { ability: { name: "lightning-rod" }, is_hidden: true }, + ], + }, +]; + +export const DUMMY_FILTER: FilterRules = { + type: ["water"], + onlyFavorites: false, +}; From 2d5497cd85420f1f87fc80d67061b40156080a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas-Andr=C3=A9=20Hetles=C3=A6ther?= Date: Mon, 14 Sep 2026 11:08:52 +0200 Subject: [PATCH 2/5] fix: delete file that was added by accident --- "\\" | 66 ------------------------------------------------------------ 1 file changed, 66 deletions(-) delete mode 100644 "\\" diff --git "a/\\" "b/\\" deleted file mode 100644 index c9b9f72..0000000 --- "a/\\" +++ /dev/null @@ -1,66 +0,0 @@ -.favorite-container { - width: 100%; - margin: 0; -} - -.favorite-list { - list-style-type: none; - margin: 0; - display: flex; - flex-direction: column; - gap: 8px; - max-height: 500px; - overflow-y: auto; - background: #f8f9fa; - padding: 12px; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); -} - -.favorite-item { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - background: #fff; - border: 1px solid #ddd; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - text-align: left; - font-family: sans-serif; -} - -.favorite-item:hover { - background: #f0f0f0; - transform: translateY(-1px); -} - -.favorite-sprite { - width: 40px; - height: 40px; - object-fit: contain; - margin-right: 0; -} - -.favorite-info { - display: flex; - align-items: center; - gap: 12px; - flex: 1; -} - -.dex-number { - font-size: 12px; - color: #888; - font-weight: bold; - min-width: 35px; -} - -.favorite-item .pokemon-name { - font-size: 14px; - font-weight: 500; - color: #333; - text-transform: capitalize; - font-family: sans-serif; -} From 22e8ef663e1de0b46cb0adf20c0fcd7c6dc8d6f0 Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Mon, 14 Sep 2026 16:11:13 +0200 Subject: [PATCH 3/5] fix(mvp): repair build errors and fetch through TanStack Query * Restore SORT_OPTIONS and validate sort on load instead of casting it * Limit SortOption to id-asc and id-desc, name sorting needs a full preload * Pass filter rules into GetPrevFiltered and GetNextFiltered instead of reading session storage once per pokemon * Fetch favorites in parallel and drop the ids that failed * Reset PokemonCard to the default sprite when a new pokemon is shown * Move PokemonList and useFavorites onto TanStack Query * Render favorite entries and the sprite toggle as buttons for keyboard access * Replace the duplicate id="root" wrapper with .app-layout * Add a shared QueryClient wrapper for hook and component tests References #6 --- src/App.css | 20 +++---- src/App.tsx | 9 ++- .../FavoritePokemon/FavoritePokemon.css | 14 +++++ .../FavoritePokemon/FavoritePokemon.tsx | 56 ++++++++++++------- src/components/PokemonCard/PokemonCard.css | 13 ++++- .../PokemonCard/PokemonCard.test.tsx | 24 ++------ src/components/PokemonCard/PokemonCard.tsx | 43 +++++++------- .../__snapshots__/PokemonCard.test.tsx.snap | 9 ++- src/components/PokemonList/PokemonList.tsx | 39 ++++++------- src/controllers/pokemonController.test.ts | 41 ++++---------- src/controllers/pokemonController.ts | 28 ++++------ src/controllers/useFavorites.test.ts | 45 ++++++++------- src/controllers/useFavorites.ts | 46 ++++----------- src/models/filters.ts | 42 ++++++++------ src/utils/testWrapper.tsx | 18 ++++++ 15 files changed, 222 insertions(+), 225 deletions(-) create mode 100644 src/utils/testWrapper.tsx diff --git a/src/App.css b/src/App.css index eb55118..f4bb3b5 100644 --- a/src/App.css +++ b/src/App.css @@ -16,7 +16,7 @@ img { user-drag: none; } -#root { +.app-layout { display: flex; flex-direction: row; align-items: stretch; @@ -27,7 +27,7 @@ img { box-sizing: border-box; } -#root > div { +.app-layout > div { border: 1px solid #ccc; padding: 10px; box-sizing: border-box; @@ -35,18 +35,18 @@ img { min-height: 0; } -#root > #pokemonList { +.app-layout > #pokemonList { order: 1; flex: 1; } -#root > #pokemonCard { +.app-layout > #pokemonCard { order: 2; flex: 2; flex-direction: column; } -#root > #favoritePokemon { +.app-layout > #favoritePokemon { order: 3; flex: 1; } @@ -77,12 +77,6 @@ input { justify-content: center; } -#sorting { -} - -#favorite { -} - @media (max-width: 1000px) { body { -ms-overflow-style: none; /* Internet Explorer 10+ */ @@ -90,14 +84,14 @@ input { overflow: scroll; } - #root { + .app-layout { flex-direction: column; width: 100%; height: auto; align-items: stretch; } - #root > div { + .app-layout > div { flex: 0 0 auto !important; width: 100% !important; height: auto !important; diff --git a/src/App.tsx b/src/App.tsx index 54a745b..8575346 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { getPokemonByID } from "./api/pokemon"; import { PokemonCard } from "./components/PokemonCard/PokemonCard"; import { Favorite } from "./components/Favorite/Favorite"; import { useFavorites } from "./controllers/useFavorites"; +import { useFilters } from "./controllers/useFilters"; import { PokemonList } from "./components/PokemonList/PokemonList"; import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon"; @@ -12,6 +13,7 @@ export default function App() { const [currentId, setCurrentId] = useState(1); const { favoritePokemons, isFavorite, toggleFavorite, isLoadingFavorites } = useFavorites(); + const { filters } = useFilters(); const query = useQuery({ queryKey: ["pokemon", currentId], @@ -34,7 +36,7 @@ export default function App() { }; return ( -
+
@@ -63,8 +65,8 @@ export default function App() {
- {query.isPending &&

Loading...

} - {query.isError &&

Failed to load Pokemon

} + {query.isPending &&

Loading...

} + {query.isError &&

Failed to load Pokemon

} {query.data && }
@@ -72,6 +74,7 @@ export default function App() { )} diff --git a/src/components/FavoritePokemon/FavoritePokemon.css b/src/components/FavoritePokemon/FavoritePokemon.css index 71a66c8..9f840b2 100644 --- a/src/components/FavoritePokemon/FavoritePokemon.css +++ b/src/components/FavoritePokemon/FavoritePokemon.css @@ -27,6 +27,7 @@ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); } +/* Rendered as a ); })} diff --git a/src/components/PokemonCard/PokemonCard.css b/src/components/PokemonCard/PokemonCard.css index 21f4727..42e23ca 100644 --- a/src/components/PokemonCard/PokemonCard.css +++ b/src/components/PokemonCard/PokemonCard.css @@ -57,7 +57,18 @@ display: block; } -.abilities strong { +/* Wraps the sprite so it can be cycled with a keyboard */ +.sprite-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: inline-block; +} + +.sprite-button:focus-visible { + outline: 2px solid #007bff; + outline-offset: 2px; } .type-badges { diff --git a/src/components/PokemonCard/PokemonCard.test.tsx b/src/components/PokemonCard/PokemonCard.test.tsx index 3314770..59ce8e2 100644 --- a/src/components/PokemonCard/PokemonCard.test.tsx +++ b/src/components/PokemonCard/PokemonCard.test.tsx @@ -1,26 +1,9 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, test } from "vitest"; import { PokemonCard } from "./PokemonCard"; +import { DUMMY_DATA } from "../../utils/testData"; -const mockPokemon = { - name: "pikachu", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", - }, - height: 4, - weight: 60, - types: [{ type: { name: "electric" } }], - abilities: [{ ability: { name: "static" }, is_hidden: false }], - stats: [ - { base_stat: 35, stat: { name: "hp" } }, - { base_stat: 55, stat: { name: "attack" } }, - { base_stat: 40, stat: { name: "defense" } }, - { base_stat: 50, stat: { name: "special-attack" } }, - { base_stat: 50, stat: { name: "special-defense" } }, - { base_stat: 90, stat: { name: "speed" } }, - ], -}; +const mockPokemon = DUMMY_DATA[2]; describe("PokemonCard Component", () => { test("renders pokemon name and general information", () => { @@ -28,7 +11,8 @@ describe("PokemonCard Component", () => { expect(screen.getByText("Pikachu")).toBeDefined(); expect(screen.getByText("electric")).toBeDefined(); - expect(screen.getByText("static")).toBeDefined(); + // Abilities render as one block of text, so match on a substring + expect(screen.getByText(/static/)).toBeDefined(); expect(screen.getByText("0.4 m")).toBeDefined(); expect(screen.getByText("6 kg")).toBeDefined(); }); diff --git a/src/components/PokemonCard/PokemonCard.tsx b/src/components/PokemonCard/PokemonCard.tsx index fafbf54..3b8b78b 100644 --- a/src/components/PokemonCard/PokemonCard.tsx +++ b/src/components/PokemonCard/PokemonCard.tsx @@ -6,7 +6,7 @@ type PokemonCardProps = { current: PokemonData; }; -const typeBackgroundColor = { +const typeBackgroundColor: Record = { normal: "#9fa19e", fighting: "#ff7f00", flying: "#83b8eb", @@ -29,7 +29,16 @@ const typeBackgroundColor = { }; export function PokemonCard({ current: pokemon }: PokemonCardProps) { - const [sprite, setSprite] = useState(nextSprite("", pokemon)); + const sprites = spriteList(pokemon); + + const [spriteIndex, setSpriteIndex] = useState(0); + const [shownId, setShownId] = useState(pokemon.id); + + // Start over at the default sprite when a different pokemon is shown + if (shownId !== pokemon.id) { + setShownId(pokemon.id); + setSpriteIndex(0); + } const displayName = pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1); @@ -43,18 +52,19 @@ export function PokemonCard({ current: pokemon }: PokemonCardProps) { speed: "Speed", }; - let total = 0; + const total = pokemon.stats.reduce((sum, stat) => sum + stat.base_stat, 0); return (

{displayName}

-
{ - setSprite(nextSprite(sprite, pokemon)); - }} +
+ {`${displayName} +
@@ -93,7 +103,6 @@ export function PokemonCard({ current: pokemon }: PokemonCardProps) { {pokemon.stats.map((stat) => { const base = stat.base_stat; - total += base; const min = stat.stat.name === "hp" @@ -164,20 +173,14 @@ function calcHighMaxStat(base: number): number { return Math.floor((2 * base + 31 + 252 / 4 + 5) * 1.1); } -function nextSprite(cur: string, pokemon: PokemonData): string { - const sprites = [ +// The sprites to cycle through, skipping the ones the api left empty +function spriteList(pokemon: PokemonData): string[] { + return [ pokemon.sprites.front_default, pokemon.sprites.back_default, pokemon.sprites.front_shiny, pokemon.sprites.back_shiny, - ]; - - if (!cur) { - return sprites[0]; - } - - // sprite = next in sprites, then wrap if at end - return sprites[(sprites.indexOf(cur) + 1) % sprites.length]; + ].filter(Boolean); } // Gereate a width and color based on how high a base stat value is (scale inspired by https://pokemondb.net/) diff --git a/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap b/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap index 1cf098f..9a34654 100644 --- a/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap +++ b/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap @@ -8,12 +8,16 @@ exports[`PokemonCard Component > matches snapshot 1`] = `

Pikachu

-
+
+
@@ -67,6 +71,7 @@ exports[`PokemonCard Component > matches snapshot 1`] = ` class="abilities" > static +lightning-rod (Hidden)
diff --git a/src/components/PokemonList/PokemonList.tsx b/src/components/PokemonList/PokemonList.tsx index 1400ff4..280e9fc 100644 --- a/src/components/PokemonList/PokemonList.tsx +++ b/src/components/PokemonList/PokemonList.tsx @@ -1,52 +1,44 @@ import "./PokemonList.css"; -import { useState, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { GetPrevFiltered, GetNextFiltered, } from "../../controllers/pokemonController"; import type { PokemonData } from "../../controllers/pokemonController"; +import type { FilterRules } from "../../models/filters"; type PokemonListProps = { current: PokemonData; sizeOfList: number; + filters: FilterRules; onSelectPokemon: (id: number) => void; }; export function PokemonList({ current, sizeOfList, + filters, onSelectPokemon, }: PokemonListProps) { - const [pokemonList, setPokemonList] = useState([]); - const [loading, setLoading] = useState(false); const listRef = useRef(null); - useEffect(() => { - let ignore = false; - setLoading(true); - - buildPokemonList(current, sizeOfList).then((data) => { - if (!ignore) { - setPokemonList(data); - setLoading(false); - } - }); - - return () => { - ignore = true; - }; - }, [current, sizeOfList]); + // Cached by TanStack Query so revisiting an id does not refetch the list + const { data: pokemonList = [], isPending } = useQuery({ + queryKey: ["pokemonList", current.id, sizeOfList, filters], + queryFn: () => buildPokemonList(current, sizeOfList, filters), + }); useEffect(() => { - if (!loading && listRef.current) { + if (!isPending && listRef.current) { const selectedEl = listRef.current.querySelector( ".pokemon-list-item.selected", ); selectedEl?.scrollIntoView({ block: "center", behavior: "auto" }); } - }, [loading, pokemonList, current.id]); + }, [isPending, pokemonList, current.id]); - if (loading) { + if (isPending) { return
Loading Pokémon list...
; } @@ -86,6 +78,7 @@ export function PokemonList({ async function buildPokemonList( current: PokemonData, sizeOfList: number, + filters: FilterRules, ): Promise { const pokemonList: PokemonData[] = []; @@ -98,7 +91,7 @@ async function buildPokemonList( while (pokemonList.length < sizeOfList) { if (!leftExhausted && pokemonList.length < sizeOfList) { try { - const left = await GetPrevFiltered(current.id - leftOffset); + const left = await GetPrevFiltered(current.id - leftOffset, filters); if (left) { pokemonList.unshift(left); leftOffset = current.id - left.id; @@ -113,7 +106,7 @@ async function buildPokemonList( if (!rightExhausted && pokemonList.length < sizeOfList) { try { - const right = await GetNextFiltered(current.id + rightOffset); + const right = await GetNextFiltered(current.id + rightOffset, filters); if (right) { pokemonList.push(right); rightOffset = right.id - current.id; diff --git a/src/controllers/pokemonController.test.ts b/src/controllers/pokemonController.test.ts index 5596de3..18108c5 100644 --- a/src/controllers/pokemonController.test.ts +++ b/src/controllers/pokemonController.test.ts @@ -1,13 +1,6 @@ // generated with AI import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - matchesFilterFromParam, - GetNext, - GetPrev, - GetNextFiltered, - GetPrevFiltered, - type PokemonData, -} from "./pokemonController"; +import { matchesFilter, GetNext, GetPrev } from "./pokemonController"; import * as pokemonApi from "../api/pokemon"; import { DUMMY_DATA } from "../utils/testData"; import type { FilterRules } from "../models/filters"; @@ -21,44 +14,32 @@ describe("pokemonController", () => { vi.clearAllMocks(); }); - describe("matchesFilterFromParam", () => { + describe("matchesFilter", () => { it("should return true when no filters are applied", () => { - expect(matchesFilterFromParam(DUMMY_DATA[2], {})).toBe(true); + expect(matchesFilter(DUMMY_DATA[2], {})).toBe(true); }); it("should filter correctly by type", () => { - expect( - matchesFilterFromParam(DUMMY_DATA[2], { type: ["electric"] }), - ).toBe(true); - expect(matchesFilterFromParam(DUMMY_DATA[2], { type: ["fire"] })).toBe( - false, - ); + expect(matchesFilter(DUMMY_DATA[2], { type: ["electric"] })).toBe(true); + expect(matchesFilter(DUMMY_DATA[2], { type: ["fire"] })).toBe(false); }); it("should filter correctly by ability", () => { - expect(matchesFilterFromParam(DUMMY_DATA[2], { ability: "static" })).toBe( - true, - ); - expect(matchesFilterFromParam(DUMMY_DATA[2], { ability: "blaze" })).toBe( - false, - ); + expect(matchesFilter(DUMMY_DATA[2], { ability: "static" })).toBe(true); + expect(matchesFilter(DUMMY_DATA[2], { ability: "blaze" })).toBe(false); }); it("should verify numerical height and weight ranges", () => { const validRules: FilterRules = { height: "0-1", weight: "5-10" }; const invalidRules: FilterRules = { height: "1-2", weight: "5-10" }; - expect(matchesFilterFromParam(DUMMY_DATA[2], validRules)).toBe(true); - expect(matchesFilterFromParam(DUMMY_DATA[2], invalidRules)).toBe(false); + expect(matchesFilter(DUMMY_DATA[2], validRules)).toBe(true); + expect(matchesFilter(DUMMY_DATA[2], invalidRules)).toBe(false); }); it("should calculate and filter total stats correctly", () => { - expect(matchesFilterFromParam(DUMMY_DATA[2], { total: "300-350" })).toBe( - true, - ); - expect(matchesFilterFromParam(DUMMY_DATA[2], { total: "400-500" })).toBe( - false, - ); + expect(matchesFilter(DUMMY_DATA[2], { total: "300-350" })).toBe(true); + expect(matchesFilter(DUMMY_DATA[2], { total: "400-500" })).toBe(false); }); }); diff --git a/src/controllers/pokemonController.ts b/src/controllers/pokemonController.ts index e8979ea..cd7872b 100644 --- a/src/controllers/pokemonController.ts +++ b/src/controllers/pokemonController.ts @@ -1,5 +1,4 @@ import { getPokemonByID } from "../api/pokemon"; -import { loadFilters } from "../models/filters"; import type { FilterRules } from "../models/filters"; export type PokemonData = { @@ -26,7 +25,8 @@ function isInRange(val: number, rangeStr?: string): boolean { return val >= min && val <= max; } -export function matchesFilterFromParam( +// Validation logic based on provided rules +export function matchesFilter( pokemon: PokemonData, rules: FilterRules, ): boolean { @@ -64,12 +64,6 @@ export function matchesFilterFromParam( return true; } -// Validation logic based on provided rules -export function matchesFilter(pokemon: PokemonData): boolean { - const rules = loadFilters(); - return matchesFilterFromParam(pokemon, rules); -} - // fetches a pokemon by id and returns null in case of an error async function fetchSafe(id: number): Promise { try { @@ -89,24 +83,21 @@ export async function GetPrev(currentId: number): Promise { return await fetchSafe(currentId - 1); } -export async function GetMany(ids: number[]): Promise { - const list: PokemonData[] = []; - - for (let i = 0; i < ids.length; i++) { - list.push(await fetchSafe(ids[i])); - } - - return list; +// fetches many ids in parallel and drops the ones that failed +export async function GetMany(ids: number[]): Promise { + const results = await Promise.all(ids.map(fetchSafe)); + return results.filter((pokemon): pokemon is PokemonData => pokemon !== null); } export async function GetPrevFiltered( currentId: number, + rules: FilterRules, ): Promise { let prevId = currentId - 1; while (prevId >= 1) { const pokemon = await fetchSafe(prevId); - if (pokemon && matchesFilter(pokemon, loadFilters())) { + if (pokemon && matchesFilter(pokemon, rules)) { return pokemon; } prevId--; @@ -116,12 +107,13 @@ export async function GetPrevFiltered( export async function GetNextFiltered( currentId: number, + rules: FilterRules, ): Promise { let nextId = currentId + 1; while (nextId <= MAX_POKEMON_ID) { const pokemon = await fetchSafe(nextId); - if (pokemon && matchesFilter(pokemon, loadFilters())) { + if (pokemon && matchesFilter(pokemon, rules)) { return pokemon; } nextId++; diff --git a/src/controllers/useFavorites.test.ts b/src/controllers/useFavorites.test.ts index a949640..8a682fb 100644 --- a/src/controllers/useFavorites.test.ts +++ b/src/controllers/useFavorites.test.ts @@ -4,6 +4,7 @@ import { saveFavorites } from "../models/favorites"; import { useFavorites } from "./useFavorites"; import * as pokemonController from "./pokemonController"; import { DUMMY_DATA } from "../utils/testData"; +import { createWrapper } from "../utils/testWrapper"; vi.mock("./pokemonController", async (importOriginal) => { const actual = await importOriginal(); @@ -20,15 +21,17 @@ describe("useFavorites Hook", () => { }); test("starts empty when nothing is stored", async () => { - vi.spyOn(pokemonController, "GetMany").mockResolvedValueOnce([]); - const { result } = renderHook(() => useFavorites()); + const getManySpy = vi.spyOn(pokemonController, "GetMany"); + const { result } = renderHook(() => useFavorites(), { + wrapper: createWrapper(), + }); expect(result.current.favoritePokemons).toEqual([]); expect(result.current.isFavorite(1)).toBe(false); + expect(result.current.isLoadingFavorites).toBe(false); - await waitFor(() => { - expect(result.current.isLoadingFavorites).toBe(false); - }); + // No ids stored, so there is nothing to ask the API for + expect(getManySpy).not.toHaveBeenCalled(); }); test("restores favorites from localStorage and fetches pokemon data", async () => { @@ -36,29 +39,27 @@ describe("useFavorites Hook", () => { const mockPokemons = [DUMMY_DATA[0], DUMMY_DATA[2]]; const getManySpy = vi .spyOn(pokemonController, "GetMany") - .mockResolvedValueOnce(mockPokemons); + .mockResolvedValue(mockPokemons); - const { result } = renderHook(() => useFavorites()); + const { result } = renderHook(() => useFavorites(), { + wrapper: createWrapper(), + }); - expect(getManySpy).toHaveBeenCalledWith([1, 25]); + expect(result.current.isFavorite(25)).toBe(true); await waitFor(() => { - expect(result.current.favoritePokemons).toEqual([]); + expect(result.current.favoritePokemons).toEqual(mockPokemons); }); - expect(result.current.isFavorite(25)).toBe(true); + + expect(getManySpy).toHaveBeenCalledWith([1, 25]); }); test("toggleFavorite adds and removes and updates storage and pokemon list", async () => { const getManySpy = vi.spyOn(pokemonController, "GetMany"); - getManySpy - .mockResolvedValueOnce([]) // initial empty fetch - .mockResolvedValueOnce([DUMMY_DATA[2]]) // after adding id 4 - .mockResolvedValueOnce([]); // after removing id 4 + getManySpy.mockResolvedValue([DUMMY_DATA[1]]); - const { result } = renderHook(() => useFavorites()); - - await waitFor(() => { - expect(result.current.isLoadingFavorites).toBe(false); + const { result } = renderHook(() => useFavorites(), { + wrapper: createWrapper(), }); act(() => { @@ -66,20 +67,22 @@ describe("useFavorites Hook", () => { }); expect(window.localStorage.getItem("favorites")).toBe("[4]"); + expect(result.current.isFavorite(4)).toBe(true); await waitFor(() => { - expect(result.current.isFavorite(4)).toBe(true); - expect(result.current.favoritePokemons).toEqual([]); + expect(result.current.favoritePokemons).toEqual([DUMMY_DATA[1]]); }); + expect(getManySpy).toHaveBeenCalledWith([4]); act(() => { result.current.toggleFavorite(4); }); expect(window.localStorage.getItem("favorites")).toBe("[]"); + expect(result.current.isFavorite(4)).toBe(false); + // Back to an empty id list, so the list empties without another fetch await waitFor(() => { - expect(result.current.isFavorite(4)).toBe(false); expect(result.current.favoritePokemons).toEqual([]); }); }); diff --git a/src/controllers/useFavorites.ts b/src/controllers/useFavorites.ts index 99494e3..fa15a1a 100644 --- a/src/controllers/useFavorites.ts +++ b/src/controllers/useFavorites.ts @@ -1,53 +1,29 @@ import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { loadFavorites, saveFavorites, toggleFavorite as toggleInList, } from "../models/favorites"; -import type { PokemonData } from "./pokemonController"; import { GetMany } from "./pokemonController"; export function useFavorites() { + // 1. Lazy initialization from localStorage const [favoriteIds, setFavoriteIds] = useState(loadFavorites); - const [favoritePokemons, setFavoritePokemons] = useState([]); - const [isLoadingFavorites, setIsLoadingFavorites] = useState(false); - + // 2. Keep storage in sync so favorites survive closing the browser useEffect(() => { saveFavorites(favoriteIds); }, [favoriteIds]); - useEffect(() => { - let isMounted = true; - - async function loadFavoriteData() { - if (favoriteIds.length === 0) { - setFavoritePokemons([]); - return; - } - - setIsLoadingFavorites(true); - try { - const data = await GetMany(favoriteIds); - if (isMounted && data) { - setFavoritePokemons(data); - } - } catch (error) { - console.error("Failed to load favorite pokemons:", error); - } finally { - if (isMounted) { - setIsLoadingFavorites(false); - } - } - } - - loadFavoriteData(); - - return () => { - isMounted = false; - }; - }, [favoriteIds]); + // 3. Fetch through TanStack Query so the data is cached between toggles + const { data: favoritePokemons = [], isPending } = useQuery({ + queryKey: ["favorites", favoriteIds], + queryFn: () => GetMany(favoriteIds), + enabled: favoriteIds.length > 0, + }); + // 4. Handlers passed down to the components const toggleFavorite = useCallback((id: number) => { setFavoriteIds((prev) => toggleInList(prev, id)); }, []); @@ -59,7 +35,7 @@ export function useFavorites() { return { favoritePokemons, - isLoadingFavorites, + isLoadingFavorites: favoriteIds.length > 0 && isPending, isFavorite, toggleFavorite, }; diff --git a/src/models/filters.ts b/src/models/filters.ts index f7504b1..b0954da 100644 --- a/src/models/filters.ts +++ b/src/models/filters.ts @@ -1,4 +1,5 @@ -export type SortOption = "id-asc" | "id-desc" | "name-asc" | "name-desc"; +// Only id sorting, name sorting would need every pokemon fetched up front +export type SortOption = "id-asc" | "id-desc"; export type FilterSettings = { type: string | null; @@ -6,6 +7,8 @@ export type FilterSettings = { onlyFavorites: boolean; }; +export const SORT_OPTIONS: SortOption[] = ["id-asc", "id-desc"]; + export const FILTER_KEYS = [ "sort", "onlyFavorites", @@ -24,6 +27,12 @@ export const FILTER_KEYS = [ export type FilterKey = (typeof FILTER_KEYS)[number]; +// The keys that hold a plain string and parse the same way +export type StringFilterKey = Exclude< + FilterKey, + "sort" | "onlyFavorites" | "type" +>; + export type FilterRules = { [K in FilterKey]?: K extends "onlyFavorites" ? boolean @@ -81,38 +90,35 @@ function isSortOption(value: unknown): value is SortOption { return SORT_OPTIONS.includes(value as SortOption); } +function isStringKey(key: FilterKey): key is StringFilterKey { + return key !== "sort" && key !== "onlyFavorites" && key !== "type"; +} + function parseFilterRules(rawJson: Record): FilterRules { const rules: FilterRules = {}; - let keys: string[] = [...FILTER_KEYS]; - // parse sort - if (rawJson.sort !== undefined) { - rules.sort = rawJson.sort as SortOption; + // parse sort, dropped unless it is a known option + if (isSortOption(rawJson.sort)) { + rules.sort = rawJson.sort; } - keys = keys.filter((key) => key !== "sort"); - - // parse setFavorite + // parse onlyFavorites if (typeof rawJson.onlyFavorites === "boolean") { rules.onlyFavorites = rawJson.onlyFavorites; } - keys = keys.filter((key) => key !== "onlyFavorites"); - - // parse type + // parse type, non-string entries are dropped if (Array.isArray(rawJson.type)) { rules.type = rawJson.type.filter( (item): item is string => typeof item === "string", ); } - keys = keys.filter((key) => key !== "type"); - - // parse rest, only string types left - - for (const key of keys) { - if (typeof rawJson[key] === "string") { - rules[key] = rawJson[key]; + // parse the rest, only string values left + for (const key of FILTER_KEYS.filter(isStringKey)) { + const value = rawJson[key]; + if (typeof value === "string") { + rules[key] = value; } } diff --git a/src/utils/testWrapper.tsx b/src/utils/testWrapper.tsx new file mode 100644 index 0000000..ed6baa6 --- /dev/null +++ b/src/utils/testWrapper.tsx @@ -0,0 +1,18 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +// A fresh QueryClient per test so nothing is cached between them, +// with retries off so a rejected mock fails instead of hanging +export function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} From 35259772f37631362fc16dab08f602993ec9e1ad Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Mon, 14 Sep 2026 16:22:50 +0200 Subject: [PATCH 4/5] feat(filters): add filter component that reloads the pokemon list * Build Filter as a presentational component with filters, onChange and onReset props * Collapse the panel behind a details summary that counts the active rules * Let the user pick sort order, any number of types and favorites only * Apply the sort order and the favorites rule when building the list * Reuse the favorites already fetched instead of asking the api again * Drop the unused FilterSettings type left over from the filter model * Register the jest-dom matcher types so tests can assert on checked state * Add tests for the component and for the list it filters References #12 --- src/App.tsx | 12 +- src/components/Filter/Filter.css | 97 ++++++++ src/components/Filter/Filter.test.tsx | 89 ++++++++ src/components/Filter/Filter.tsx | 90 ++++++++ .../Filter/__snapshots__/Filter.test.tsx.snap | 216 ++++++++++++++++++ .../PokemonList/PokemonList.test.tsx | 111 +++++++++ src/components/PokemonList/PokemonList.tsx | 30 ++- src/models/filters.ts | 28 ++- src/test/setupTests.ts | 4 +- 9 files changed, 661 insertions(+), 16 deletions(-) create mode 100644 src/components/Filter/Filter.css create mode 100644 src/components/Filter/Filter.test.tsx create mode 100644 src/components/Filter/Filter.tsx create mode 100644 src/components/Filter/__snapshots__/Filter.test.tsx.snap create mode 100644 src/components/PokemonList/PokemonList.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 8575346..ac6462d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,12 +8,13 @@ import { useFavorites } from "./controllers/useFavorites"; import { useFilters } from "./controllers/useFilters"; import { PokemonList } from "./components/PokemonList/PokemonList"; import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon"; +import { Filter } from "./components/Filter/Filter"; export default function App() { const [currentId, setCurrentId] = useState(1); const { favoritePokemons, isFavorite, toggleFavorite, isLoadingFavorites } = useFavorites(); - const { filters } = useFilters(); + const { filters, setFilter, resetFilters } = useFilters(); const query = useQuery({ queryKey: ["pokemon", currentId], @@ -40,7 +41,13 @@ export default function App() {
-
sorting
+
+ +
+
+ + ); +} diff --git a/src/components/Filter/__snapshots__/Filter.test.tsx.snap b/src/components/Filter/__snapshots__/Filter.test.tsx.snap new file mode 100644 index 0000000..b05d560 --- /dev/null +++ b/src/components/Filter/__snapshots__/Filter.test.tsx.snap @@ -0,0 +1,216 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Filter Component > matches snapshot 1`] = ` + +
+ + Filters (1) + +
+
+ + Sort + + + +
+
+ + Type + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + Favorites + + +
+ +
+
+
+`; diff --git a/src/components/PokemonList/PokemonList.test.tsx b/src/components/PokemonList/PokemonList.test.tsx new file mode 100644 index 0000000..0053971 --- /dev/null +++ b/src/components/PokemonList/PokemonList.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { PokemonList } from "./PokemonList"; +import * as pokemonController from "../../controllers/pokemonController"; +import { DUMMY_DATA } from "../../utils/testData"; +import { createWrapper } from "../../utils/testWrapper"; +import type { FilterRules } from "../../models/filters"; + +vi.mock("../../controllers/pokemonController", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../controllers/pokemonController") + >(); + return { + ...actual, + GetPrevFiltered: vi.fn(), + GetNextFiltered: vi.fn(), + }; +}); + +// jsdom has no layout, so the scroll the list does after loading is a no-op +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +function renderList(filters: FilterRules, favoritePokemons = DUMMY_DATA) { + const onSelectPokemon = vi.fn(); + + render( + , + { wrapper: createWrapper() }, + ); + + return { onSelectPokemon }; +} + +function renderedNames() { + return screen + .getAllByRole("button") + .map((button) => button.textContent?.replace(/[^a-z]/g, "")); +} + +describe("PokemonList Component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("lists the neighbours it fetched, lowest id first", async () => { + vi.spyOn(pokemonController, "GetPrevFiltered").mockResolvedValue(null); + vi.spyOn(pokemonController, "GetNextFiltered") + .mockResolvedValueOnce(DUMMY_DATA[1]) + .mockResolvedValueOnce(DUMMY_DATA[2]); + + renderList({}); + + await waitFor(() => { + expect(renderedNames()).toEqual(["bulbasaur", "charmander", "pikachu"]); + }); + }); + + test("turns the order around when sorting on highest id", async () => { + vi.spyOn(pokemonController, "GetPrevFiltered").mockResolvedValue(null); + vi.spyOn(pokemonController, "GetNextFiltered") + .mockResolvedValueOnce(DUMMY_DATA[1]) + .mockResolvedValueOnce(DUMMY_DATA[2]); + + renderList({ sort: "id-desc" }); + + await waitFor(() => { + expect(renderedNames()).toEqual(["pikachu", "charmander", "bulbasaur"]); + }); + }); + + test("shows the favorites it was handed without fetching anything", async () => { + const getNext = vi.spyOn(pokemonController, "GetNextFiltered"); + const getPrev = vi.spyOn(pokemonController, "GetPrevFiltered"); + + renderList({ onlyFavorites: true }, [DUMMY_DATA[2], DUMMY_DATA[0]]); + + await waitFor(() => { + expect(renderedNames()).toEqual(["bulbasaur", "pikachu"]); + }); + + expect(getNext).not.toHaveBeenCalled(); + expect(getPrev).not.toHaveBeenCalled(); + }); + + test("applies the other rules to the favorites as well", async () => { + renderList({ onlyFavorites: true, type: ["electric"] }); + + await waitFor(() => { + expect(renderedNames()).toEqual(["pikachu"]); + }); + }); + + test("says so when nothing is left to show", async () => { + renderList({ onlyFavorites: true }, []); + + await waitFor(() => { + expect( + screen.getByText("No Pokémon available to display."), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/PokemonList/PokemonList.tsx b/src/components/PokemonList/PokemonList.tsx index 280e9fc..26b7e56 100644 --- a/src/components/PokemonList/PokemonList.tsx +++ b/src/components/PokemonList/PokemonList.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { GetPrevFiltered, GetNextFiltered, + matchesFilter, } from "../../controllers/pokemonController"; import type { PokemonData } from "../../controllers/pokemonController"; import type { FilterRules } from "../../models/filters"; @@ -12,6 +13,7 @@ type PokemonListProps = { current: PokemonData; sizeOfList: number; filters: FilterRules; + favoritePokemons: PokemonData[]; onSelectPokemon: (id: number) => void; }; @@ -19,30 +21,44 @@ export function PokemonList({ current, sizeOfList, filters, + favoritePokemons, onSelectPokemon, }: PokemonListProps) { const listRef = useRef(null); + const onlyFavorites = filters.onlyFavorites === true; - // Cached by TanStack Query so revisiting an id does not refetch the list - const { data: pokemonList = [], isPending } = useQuery({ + // Cached by TanStack Query so revisiting an id does not refetch the list. + // Skipped for the favorites view, which reuses what useFavorites already has + const { data: fetchedList = [], isPending } = useQuery({ queryKey: ["pokemonList", current.id, sizeOfList, filters], queryFn: () => buildPokemonList(current, sizeOfList, filters), + enabled: !onlyFavorites, }); + const loading = !onlyFavorites && isPending; + + const pokemonList = onlyFavorites + ? favoritePokemons.filter((pokemon) => matchesFilter(pokemon, filters)) + : fetchedList; + + const ordered = [...pokemonList].sort((a, b) => + filters.sort === "id-desc" ? b.id - a.id : a.id - b.id, + ); + useEffect(() => { - if (!isPending && listRef.current) { + if (!loading && listRef.current) { const selectedEl = listRef.current.querySelector( ".pokemon-list-item.selected", ); selectedEl?.scrollIntoView({ block: "center", behavior: "auto" }); } - }, [isPending, pokemonList, current.id]); + }, [loading, pokemonList, current.id]); - if (isPending) { + if (loading) { return
Loading Pokémon list...
; } - if (pokemonList.length === 0) { + if (ordered.length === 0) { return (
No Pokémon available to display.
); @@ -50,7 +66,7 @@ export function PokemonList({ return (
- {pokemonList.map((item) => { + {ordered.map((item) => { const isSelected = item.id === current.id; return ( + +
  • + +
  • + +
    + +`; diff --git a/src/components/PokemonCard/PokemonCard.test.tsx b/src/components/PokemonCard/PokemonCard.test.tsx index 59ce8e2..9865af1 100644 --- a/src/components/PokemonCard/PokemonCard.test.tsx +++ b/src/components/PokemonCard/PokemonCard.test.tsx @@ -1,10 +1,14 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, test } from "vitest"; import { PokemonCard } from "./PokemonCard"; import { DUMMY_DATA } from "../../utils/testData"; const mockPokemon = DUMMY_DATA[2]; +function spriteSrc() { + return screen.getByRole("img").getAttribute("src"); +} + describe("PokemonCard Component", () => { test("renders pokemon name and general information", () => { render(); @@ -17,6 +21,50 @@ describe("PokemonCard Component", () => { expect(screen.getByText("6 kg")).toBeDefined(); }); + test("adds up the base stats", () => { + render(); + + const total = mockPokemon.stats.reduce( + (sum, stat) => sum + stat.base_stat, + 0, + ); + + expect(screen.getByText(String(total))).toBeInTheDocument(); + }); + + test("steps through the sprites when the image is clicked", () => { + render(); + + expect(spriteSrc()).toBe(mockPokemon.sprites.front_default); + + fireEvent.click(screen.getByRole("button")); + expect(spriteSrc()).toBe(mockPokemon.sprites.back_default); + + fireEvent.click(screen.getByRole("button")); + expect(spriteSrc()).toBe(mockPokemon.sprites.front_shiny); + }); + + test("wraps back to the first sprite at the end", () => { + render(); + + for (let i = 0; i < 4; i++) { + fireEvent.click(screen.getByRole("button")); + } + + expect(spriteSrc()).toBe(mockPokemon.sprites.front_default); + }); + + test("goes back to the default sprite when another pokemon is shown", () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button")); + expect(spriteSrc()).toBe(mockPokemon.sprites.back_default); + + rerender(); + + expect(spriteSrc()).toBe(DUMMY_DATA[0].sprites.front_default); + }); + test("matches snapshot", () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); diff --git a/src/controllers/pokemonController.test.ts b/src/controllers/pokemonController.test.ts index 18108c5..542be27 100644 --- a/src/controllers/pokemonController.test.ts +++ b/src/controllers/pokemonController.test.ts @@ -1,6 +1,12 @@ // generated with AI import { describe, it, expect, vi, beforeEach } from "vitest"; -import { matchesFilter, GetNext, GetPrev } from "./pokemonController"; +import { + matchesFilter, + GetNext, + GetPrev, + GetNextFiltered, + GetPrevFiltered, +} from "./pokemonController"; import * as pokemonApi from "../api/pokemon"; import { DUMMY_DATA } from "../utils/testData"; import type { FilterRules } from "../models/filters"; @@ -75,4 +81,58 @@ describe("pokemonController", () => { expect(result).toBeNull(); }); }); + + describe("GetNextFiltered / GetPrevFiltered", () => { + it("should return the next pokemon when no rules are set", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[1], + ); + + const result = await GetNextFiltered(3, {}); + + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4); + expect(result).toEqual(DUMMY_DATA[1]); + }); + + it("should skip past the pokemon that do not match the rules", async () => { + vi.spyOn(pokemonApi, "getPokemonByID") + .mockResolvedValueOnce(DUMMY_DATA[0]) // grass, skipped + .mockResolvedValueOnce(DUMMY_DATA[1]) // fire, skipped + .mockResolvedValueOnce(DUMMY_DATA[2]); // electric, kept + + const result = await GetNextFiltered(0, { type: ["electric"] }); + + expect(result).toEqual(DUMMY_DATA[2]); + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(3); + }); + + it("should walk backwards for GetPrevFiltered", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[1], + ); + + const result = await GetPrevFiltered(5, {}); + + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4); + expect(result).toEqual(DUMMY_DATA[1]); + }); + + it("should return null once there is nothing left to walk to", async () => { + const result = await GetPrevFiltered(1, {}); + + expect(result).toBeNull(); + expect(pokemonApi.getPokemonByID).not.toHaveBeenCalled(); + }); + + it("should keep going when a fetch fails instead of giving up", async () => { + vi.spyOn(pokemonApi, "getPokemonByID") + .mockRejectedValueOnce(new Error("API Error")) + .mockResolvedValueOnce(DUMMY_DATA[2]); + + const result = await GetNextFiltered(23, {}); + + expect(result).toEqual(DUMMY_DATA[2]); + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(2); + }); + }); });