From 09df5c5063375fd64b198823709b1f7c5ce968ad Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Wed, 9 Sep 2026 10:07:17 -0600 Subject: [PATCH 1/2] feat: wire navigation and jump list into the book viewer (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App: index state with bounds-safe clamping, NavigationControls (prev/next) and BookJumpList (direct selection via onSelect) rendered inside the book viewer region - tests: navigation changes the displayed book and position text, jump selection works, and the fetch counter stays at 1 across prev/next and jump interactions (no unnecessary API calls) - App snapshot updated deliberately (viewer now includes both controls) Completes #12: BookCard, NavigationControls and BookJumpList are now wired together — the issue's remaining scope. --- src/App.test.tsx | 52 ++++++++++++++++++++++++++++- src/App.tsx | 18 +++++++++- src/__snapshots__/App.test.tsx.snap | 51 ++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index b161d68..0275e00 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,10 +1,16 @@ import { describe, expect, it } from "vitest"; import { http, HttpResponse, delay } from "msw"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import App from "./App"; import { withProviders } from "./test/utils"; import { server } from "./test/server"; -import { emptySearchHandler, failingSearchHandler } from "./test/handlers"; +import { + emptySearchHandler, + failingSearchHandler, + getSearchRequestCount, + resetSearchRequestCount, +} from "./test/handlers"; describe("App", () => { it("shows a loading indicator while the request is in flight", async () => { @@ -37,6 +43,50 @@ describe("App", () => { expect(await screen.findByText(/No books found/)).toBeInTheDocument(); }); + it("navigates to the next and previous book with the controls", async () => { + const user = userEvent.setup(); + withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + + await user.click(screen.getByRole("button", { name: "Show next book" })); + expect( + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }), + ).toBeInTheDocument(); + expect(screen.getByText("2 / 3")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Show previous book" })); + expect(await screen.findByRole("heading", { name: "The Hobbit" })).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + }); + + it("jumps directly to a book from the list", async () => { + const user = userEvent.setup(); + withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + + await user.selectOptions(screen.getByLabelText("Jump to book"), "2"); + expect( + await screen.findByRole("heading", { name: "The Left Hand of Darkness" }), + ).toBeInTheDocument(); + expect(screen.getByText("3 / 3")).toBeInTheDocument(); + }); + + it("does not refetch while navigating between books", async () => { + resetSearchRequestCount(); + const user = userEvent.setup(); + withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + + await user.click(screen.getByRole("button", { name: "Show next book" })); + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }); + await user.click(screen.getByRole("button", { name: "Show previous book" })); + await screen.findByRole("heading", { name: "The Hobbit" }); + await user.selectOptions(screen.getByLabelText("Jump to book"), "2"); + await screen.findByRole("heading", { name: "The Left Hand of Darkness" }); + + expect(getSearchRequestCount()).toBe(1); + }); + it("matches the snapshot", async () => { const { container } = withProviders(); await screen.findByRole("heading", { level: 2 }); diff --git a/src/App.tsx b/src/App.tsx index e8394b0..25a3626 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,7 @@ +import { useState } from "react"; import { BookCard } from "./components/BookCard"; +import { BookJumpList } from "./components/BookJumpList"; +import { NavigationControls } from "./components/NavigationControls"; import { useBooks } from "./hooks/useBooks"; import "./App.css"; @@ -6,7 +9,9 @@ const SUBJECT = "fantasy"; function App() { const { books, isLoading, isError } = useBooks(SUBJECT); - const current = books[0]; + const [index, setIndex] = useState(0); + const safeIndex = books.length > 0 ? Math.min(index, books.length - 1) : 0; + const current = books[safeIndex]; return (
@@ -23,6 +28,17 @@ function App() { {current && (
+ setIndex(Math.max(0, safeIndex - 1))} + onNext={() => setIndex(Math.min(books.length - 1, safeIndex + 1))} + /> + book.title)} + currentIndex={safeIndex} + onSelect={setIndex} + />
)} diff --git a/src/__snapshots__/App.test.tsx.snap b/src/__snapshots__/App.test.tsx.snap index 169f307..65ec41b 100644 --- a/src/__snapshots__/App.test.tsx.snap +++ b/src/__snapshots__/App.test.tsx.snap @@ -61,6 +61,57 @@ exports[`App > matches the snapshot 1`] = `
+ +