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} + ); + }; +}