From 6940d5fadb7dca05db00ac6f41cf3b4c732e0401 Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Fri, 11 Sep 2026 10:31:07 +0200 Subject: [PATCH] feat(favorites): add favorites model with local storage * Add favorites model that stores pokemon IDs in localStorage * Validate stored data and drop invalid or duplicate IDs * Add useFavorites hook that keeps state and local storage in sync * Make Favorite a presentational component with isFavorite and onToggle props * Wire the hook into App and add tests for model, hook and component References #8 --- src/App.tsx | 7 +- src/components/Favorite/Favorite.test.tsx | 39 +++++++++++ src/components/Favorite/Favorite.tsx | 37 ++-------- .../__snapshots__/Favorite.test.tsx.snap | 13 ++++ src/controllers/.gitkeep | 0 src/controllers/useFavorites.test.ts | 44 ++++++++++++ src/controllers/useFavorites.ts | 28 ++++++++ src/models/.gitkeep | 0 src/models/favorites.test.ts | 68 +++++++++++++++++++ src/models/favorites.ts | 51 ++++++++++++++ 10 files changed, 254 insertions(+), 33 deletions(-) create mode 100644 src/components/Favorite/Favorite.test.tsx create mode 100644 src/components/Favorite/__snapshots__/Favorite.test.tsx.snap delete mode 100644 src/controllers/.gitkeep create mode 100644 src/controllers/useFavorites.test.ts create mode 100644 src/controllers/useFavorites.ts delete mode 100644 src/models/.gitkeep create mode 100644 src/models/favorites.test.ts create mode 100644 src/models/favorites.ts diff --git a/src/App.tsx b/src/App.tsx index 4d00613..71b5d0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,10 +3,12 @@ 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"; export default function App() { // Start at a default ID const [pokemonId, setPokemonId] = useState(1); + const { isFavorite, toggleFavorite } = useFavorites(); const query = useQuery({ queryKey: ["pokemon", pokemonId], @@ -58,7 +60,10 @@ export default function App() { {query.isPending &&

Loading...

} {query.isError &&

Failed to load Pokemon

} {query.data && } - + toggleFavorite(pokemonId)} + /> ); } diff --git a/src/components/Favorite/Favorite.test.tsx b/src/components/Favorite/Favorite.test.tsx new file mode 100644 index 0000000..ce3e580 --- /dev/null +++ b/src/components/Favorite/Favorite.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, test, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Favorite } from "./Favorite"; + +describe("Favorite Component", () => { + test("shows empty heart when not favorite", () => { + render( {}} />); + + const button = screen.getByRole("button", { name: "Add to favorites" }); + expect(button.textContent).toBe("🤍"); + expect(button.getAttribute("aria-pressed")).toBe("false"); + }); + + test("shows filled heart when favorite", () => { + render( {}} />); + + const button = screen.getByRole("button", { + name: "Remove from favorites", + }); + expect(button.textContent).toBe("❤️"); + expect(button.getAttribute("aria-pressed")).toBe("true"); + }); + + test("calls onToggle when clicked", () => { + const handleToggle = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button")); + + expect(handleToggle).toHaveBeenCalledTimes(1); + }); + + test("matches snapshot", () => { + const { asFragment } = render( + {}} />, + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/Favorite/Favorite.tsx b/src/components/Favorite/Favorite.tsx index 408588e..11c4f3a 100644 --- a/src/components/Favorite/Favorite.tsx +++ b/src/components/Favorite/Favorite.tsx @@ -1,42 +1,15 @@ -import { useState } from "react"; import "./Favorite.css"; type FavoriteProps = { - pokemonId: number; + isFavorite: boolean; + onToggle: () => void; }; -export function Favorite({ pokemonId }: FavoriteProps) { - // 1. Lazy initialization from localStorage - const [favorites, setFavorites] = useState(() => { - try { - const storedFavorites = window.localStorage.getItem("favorites"); - return storedFavorites ? JSON.parse(storedFavorites) : []; - } catch (error) { - console.error("Error reading localStorage", error); - return []; - } - }); - - const isFavorite = favorites.includes(pokemonId); - - // 2. The click handler syncing State and Storage - const toggleFavorite = () => { - let updatedFavorites: number[]; - - if (isFavorite) { - updatedFavorites = favorites.filter((id) => id !== pokemonId); - } else { - updatedFavorites = [...favorites, pokemonId]; - } - - setFavorites(updatedFavorites); - window.localStorage.setItem("favorites", JSON.stringify(updatedFavorites)); - }; - - // 3. Accessible UI rendering +export function Favorite({ isFavorite, onToggle }: FavoriteProps) { return ( + +`; diff --git a/src/controllers/.gitkeep b/src/controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/controllers/useFavorites.test.ts b/src/controllers/useFavorites.test.ts new file mode 100644 index 0000000..24dcebe --- /dev/null +++ b/src/controllers/useFavorites.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { saveFavorites } from "../models/favorites"; +import { useFavorites } from "./useFavorites"; + +describe("useFavorites Hook", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + test("starts empty when nothing is stored", () => { + const { result } = renderHook(() => useFavorites()); + + expect(result.current.favorites).toEqual([]); + expect(result.current.isFavorite(1)).toBe(false); + }); + + test("restores favorites from localStorage", () => { + saveFavorites([1, 25]); + + const { result } = renderHook(() => useFavorites()); + + expect(result.current.favorites).toEqual([1, 25]); + expect(result.current.isFavorite(25)).toBe(true); + }); + + test("toggleFavorite adds and removes and updates storage", () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.toggleFavorite(4); + }); + + expect(result.current.favorites).toEqual([4]); + expect(window.localStorage.getItem("favorites")).toBe("[4]"); + + act(() => { + result.current.toggleFavorite(4); + }); + + expect(result.current.favorites).toEqual([]); + expect(window.localStorage.getItem("favorites")).toBe("[]"); + }); +}); diff --git a/src/controllers/useFavorites.ts b/src/controllers/useFavorites.ts new file mode 100644 index 0000000..09e509f --- /dev/null +++ b/src/controllers/useFavorites.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useState } from "react"; +import { + loadFavorites, + saveFavorites, + toggleFavorite as toggleInList, +} from "../models/favorites"; + +export function useFavorites() { + // 1. Lazy initialization from localStorage + const [favorites, setFavorites] = useState(loadFavorites); + + // 2. Keep storage in sync so favorites survive closing the browser + useEffect(() => { + saveFavorites(favorites); + }, [favorites]); + + // 3. Handlers passed down to the components + const toggleFavorite = useCallback((id: number) => { + setFavorites((prev) => toggleInList(prev, id)); + }, []); + + const isFavorite = useCallback( + (id: number) => favorites.includes(id), + [favorites], + ); + + return { favorites, isFavorite, toggleFavorite }; +} diff --git a/src/models/.gitkeep b/src/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/favorites.test.ts b/src/models/favorites.test.ts new file mode 100644 index 0000000..c8d074c --- /dev/null +++ b/src/models/favorites.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + isFavorite, + loadFavorites, + parseFavorites, + saveFavorites, + toggleFavorite, +} from "./favorites"; + +describe("Favorites Model", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("returns empty list when nothing is stored", () => { + expect(parseFavorites(null)).toEqual([]); + expect(loadFavorites()).toEqual([]); + }); + + test("returns empty list for invalid data", () => { + expect(parseFavorites("{not json")).toEqual([]); + expect(parseFavorites("42")).toEqual([]); + expect(parseFavorites('{"id":1}')).toEqual([]); + }); + + test("removes invalid ids and duplicates", () => { + const raw = JSON.stringify([1, "4", -2, 25, 1, 1.5, null]); + + expect(parseFavorites(raw)).toEqual([1, 25]); + }); + + test("saves and loads favorites from localStorage", () => { + saveFavorites([1, 4, 25]); + + expect(window.localStorage.getItem("favorites")).toBe("[1,4,25]"); + expect(loadFavorites()).toEqual([1, 4, 25]); + }); + + test("does not crash when storage is unavailable", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + + expect(() => saveFavorites([1])).not.toThrow(); + expect(loadFavorites()).toEqual([]); + }); + + test("toggleFavorite adds and removes an id without mutating", () => { + const favorites = [1, 4]; + + expect(toggleFavorite(favorites, 25)).toEqual([1, 4, 25]); + expect(toggleFavorite(favorites, 4)).toEqual([1]); + expect(favorites).toEqual([1, 4]); + }); + + test("isFavorite checks if an id is in the list", () => { + expect(isFavorite([1, 4], 4)).toBe(true); + expect(isFavorite([1, 4], 25)).toBe(false); + }); +}); diff --git a/src/models/favorites.ts b/src/models/favorites.ts new file mode 100644 index 0000000..f9a22ba --- /dev/null +++ b/src/models/favorites.ts @@ -0,0 +1,51 @@ +const STORAGE_KEY = "favorites"; + +// Only keeps valid pokemon IDs and removes duplicates +export function parseFavorites(raw: string | null): number[] { + if (!raw) { + return []; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return []; + } + + if (!Array.isArray(data)) { + return []; + } + + const ids = data.filter((id): id is number => Number.isInteger(id) && id > 0); + + return [...new Set(ids)]; +} + +export function loadFavorites(): number[] { + try { + return parseFavorites(window.localStorage.getItem(STORAGE_KEY)); + } catch (error) { + console.error("Error reading localStorage", error); + return []; + } +} + +export function saveFavorites(favorites: number[]) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)); + } catch (error) { + console.error("Error writing localStorage", error); + } +} + +export function isFavorite(favorites: number[], id: number) { + return favorites.includes(id); +} + +export function toggleFavorite(favorites: number[], id: number): number[] { + if (isFavorite(favorites, id)) { + return favorites.filter((favoriteId) => favoriteId !== id); + } + return [...favorites, id]; +}