From 35259772f37631362fc16dab08f602993ec9e1ad Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Mon, 14 Sep 2026 16:22:50 +0200 Subject: [PATCH] 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 (