diff --git a/app/api/languageCodes.ts b/app/api/languageCodes.ts index 2a4657e..24e9d8a 100644 --- a/app/api/languageCodes.ts +++ b/app/api/languageCodes.ts @@ -34,13 +34,10 @@ const languageCodes = [ { code: "geo", name: "Georgian" }, { code: "kor", name: "Korean" }, { code: "kur", name: "Kurdish" }, - { code: "lat", name: "Latin" }, { code: "lit", name: "Lithuanian" }, { code: "lat", name: "Latvian" }, { code: "mac", name: "Macedonian" }, - { code: "mal", name: "Malayalam" }, { code: "mon", name: "Mongolian" }, - { code: "mal", name: "Maltese" }, { code: "bur", name: "Burmese" }, { code: "dut", name: "Dutch; Flemish" }, { code: "nor", name: "Norwegian" }, diff --git a/app/api/openLibrary.test.ts b/app/api/openLibrary.test.ts index 10081a3..387c4ae 100644 --- a/app/api/openLibrary.test.ts +++ b/app/api/openLibrary.test.ts @@ -20,7 +20,7 @@ describe("toBook", () => { }; expect(toBook(doc)).toEqual({ - id: "/works/OL8400950W", + id: "OL8400950W", title: "The Blade Itself", author: ["Joe Abercrombie"], publishedYear: 2001, @@ -140,7 +140,7 @@ describe("fetchWork", () => { .spyOn(globalThis, "fetch") .mockResolvedValue(Response.json(workResponse)); - const data = await fetchWork("/works/OL8400950W"); + const data = await fetchWork("OL8400950W"); const url = fetchMock.mock.calls[0][0] as URL; expect(url.toString()).toBe( "https://openlibrary.org/works/OL8400950W.json", @@ -154,8 +154,8 @@ describe("fetchWork", () => { new Response(null, { status: 429 }), ); - await expect(fetchWork("/works/OL8400950W")).rejects.toThrow( - "Failed to fetch work /works/OL8400950W with status 429", + await expect(fetchWork("OL8400950W")).rejects.toThrow( + "Failed to fetch work OL8400950W with status 429", ); }); }); diff --git a/app/api/openLibrary.ts b/app/api/openLibrary.ts index 09165be..996cb4a 100644 --- a/app/api/openLibrary.ts +++ b/app/api/openLibrary.ts @@ -66,7 +66,7 @@ async function searchBooks( function toBook(doc: OpenLibraryDoc): Book { return { - id: doc.key, + id: doc.key.replace("/works/", ""), title: doc.title, author: doc.author_name ?? ["Unknown author"], publishedYear: doc.first_publish_year, @@ -78,7 +78,7 @@ function toBook(doc: OpenLibraryDoc): Book { } async function fetchWork(key: string): Promise { - const response = await fetch(`${BASE_URL}${key}.json`); + const response = await fetch(`${BASE_URL}/works/${key}.json`); if (!response.ok) { throw new Error( diff --git a/app/api/useBookDescription.ts b/app/api/useBookDescription.ts index fdd7eba..0f14760 100644 --- a/app/api/useBookDescription.ts +++ b/app/api/useBookDescription.ts @@ -3,7 +3,7 @@ import { fetchWork, toDescription } from "~/api/openLibrary"; function useBookDescription(key: string) { return useQuery({ - queryKey: ["work", key], + queryKey: ["bookDescription", key], queryFn: () => fetchWork(key), select: toDescription, }); diff --git a/app/api/useBookSearchParams.ts b/app/api/useBookSearchParams.ts new file mode 100644 index 0000000..037fe24 --- /dev/null +++ b/app/api/useBookSearchParams.ts @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import { useSearchParams } from "react-router"; + +function useBookSearchParams() { + const [searchParams] = useSearchParams(); + + const rawQuery = searchParams.get("q") ?? ""; + const page = parseInt(searchParams.get("page") ?? "1", 10); + + const author = searchParams.get("author") ?? undefined; + const subject = searchParams.get("subject") ?? undefined; + const place = searchParams.get("place") ?? undefined; + const person = searchParams.get("person") ?? undefined; + const language = searchParams.get("language") ?? undefined; + const publisher = searchParams.get("publisher") ?? undefined; + + // Only update the filters object when one of the individual filter values changes + const filters = useMemo( + () => ({ author, subject, place, person, language, publisher }), + [author, subject, place, person, language, publisher], + ); + + const hasFilters = + Object.values(filters).filter((value) => value !== undefined).length > 0; + + const query = rawQuery || hasFilters ? rawQuery : "subject:fantasy"; + + return { rawQuery, query, page, filters }; +} + +export { useBookSearchParams }; diff --git a/app/components/BookGrid/useDebounce.ts b/app/api/useDebounce.ts similarity index 100% rename from app/components/BookGrid/useDebounce.ts rename to app/api/useDebounce.ts diff --git a/app/api/useWork.ts b/app/api/useWork.ts new file mode 100644 index 0000000..ab0035f --- /dev/null +++ b/app/api/useWork.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchWork } from "~/api/openLibrary"; + +function useWork(key: string) { + return useQuery({ + queryKey: ["work", key], + queryFn: () => fetchWork(key), + }); +} + +export { useWork }; diff --git a/app/components/BookCard/BookCard.module.css b/app/components/BookCard/BookCard.module.css index a9305f7..9186421 100644 --- a/app/components/BookCard/BookCard.module.css +++ b/app/components/BookCard/BookCard.module.css @@ -9,7 +9,7 @@ border: 1px solid var(--border-color); } -.book-card-button { +.book-card-link { all: unset; cursor: pointer; @@ -17,6 +17,7 @@ flex: 1; flex-direction: column; width: 100%; + height: 100%; box-sizing: border-box; padding: 24px; diff --git a/app/components/BookCard/BookCard.test.tsx b/app/components/BookCard/BookCard.test.tsx index 8cc14d6..b2e3505 100644 --- a/app/components/BookCard/BookCard.test.tsx +++ b/app/components/BookCard/BookCard.test.tsx @@ -1,12 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { BookCard } from "~/components/BookCard/BookCard"; import { bookWithCover as book } from "~/test/fixtures/books"; +import { createRoutesStub } from "react-router"; + +const Stub = createRoutesStub([ + { + path: "/", + Component: () => , + }, +]); describe("BookCard", () => { it("shows title, authors and year from the book prop", () => { - render(); + render(); expect( screen.getByRole("heading", { name: "The Blade Itself" }), @@ -15,26 +22,20 @@ describe("BookCard", () => { expect(screen.getByText("2001")).toBeInTheDocument(); }); - it("calls onBookSelect with the book when clicked", async () => { - const user = userEvent.setup(); - const onBookSelect = vi.fn(); - render(); + it("links to the book's details page, keeping the current search", () => { + render(); - await user.click( - screen.getByRole("button", { name: "View details for The Blade Itself" }), + expect(screen.getByRole("link")).toHaveAttribute( + "href", + `/book/${book.id}?q=fantasy`, ); - - expect(onBookSelect).toHaveBeenCalledTimes(1); - expect(onBookSelect).toHaveBeenCalledWith(book); }); // Snapshot test: the rendered HTML is saved in __snapshots__/ the first time // the test runs. Later runs fail if the markup changes unexpectedly. // Run `pnpm test -u` to update the snapshot when a change is intended. it("matches the snapshot", () => { - const { container } = render( - , - ); + const { container } = render(); expect(container).toMatchSnapshot(); }); diff --git a/app/components/BookCard/BookCard.tsx b/app/components/BookCard/BookCard.tsx index d07d5e6..5fc48f9 100644 --- a/app/components/BookCard/BookCard.tsx +++ b/app/components/BookCard/BookCard.tsx @@ -2,25 +2,26 @@ import styles from "./BookCard.module.css"; import { BookCover } from "~/components/BookCover/BookCover"; import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton"; import type { Book } from "~/api/types"; +import { Link, useSearchParams } from "react-router"; interface BookCardProps { book: Book; - onBookSelect: (book: Book) => void; } -function BookCard({ book, onBookSelect }: BookCardProps) { - const selectBook = () => { - onBookSelect(book); - }; +function BookCard({ book }: BookCardProps) { + const [searchParams] = useSearchParams(); return (
- +
); } diff --git a/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap b/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap index f2a3684..5863db2 100644 --- a/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap +++ b/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap @@ -13,10 +13,11 @@ exports[`BookCard > matches the snapshot 1`] = ` > + - + `; diff --git a/app/components/BookDetails/BookDetails.test.tsx b/app/components/BookDetails/BookDetails.test.tsx deleted file mode 100644 index 3d53cfe..0000000 --- a/app/components/BookDetails/BookDetails.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { BookDetails } from "~/components/BookDetails/BookDetails"; -import { fetchWork } from "~/api/openLibrary"; -import { renderWithQueryClient } from "~/test/renderWithQueryClient"; -import { bookWithCover as book } from "~/test/fixtures/books"; -import workResponse from "~/test/fixtures/workResponse.json"; - -// BookDetails loads the description through useBookDescription -> fetchWork. -// Mock fetchWork so the component gets the saved work from the fixture. -vi.mock(import("~/api/openLibrary"), async (importOriginal) => ({ - ...(await importOriginal()), - fetchWork: vi.fn(), -})); - -describe("BookDetails", () => { - it("shows the description once it has loaded", async () => { - vi.mocked(fetchWork).mockResolvedValue(workResponse); - - renderWithQueryClient( - , - ); - - expect(screen.getByText("Loading description...")).toBeInTheDocument(); - expect(await screen.findByText(/Logen Ninefingers/)).toBeInTheDocument(); - expect(fetchWork).toHaveBeenCalledWith(book.id); - }); - - it("calls onGoBack when the back button is clicked", async () => { - vi.mocked(fetchWork).mockResolvedValue(workResponse); - const user = userEvent.setup(); - const onGoBack = vi.fn(); - renderWithQueryClient( - , - ); - - await user.click(screen.getByRole("button", { name: "Go back" })); - - expect(onGoBack).toHaveBeenCalledTimes(1); - }); -}); diff --git a/app/components/BookDetails/BookDetails.tsx b/app/components/BookDetails/BookDetails.tsx deleted file mode 100644 index a2ecfb6..0000000 --- a/app/components/BookDetails/BookDetails.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import styles from "./BookDetails.module.css"; -import { BookCover } from "~/components/BookCover/BookCover"; -import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton"; -import type { Book } from "~/api/types"; -import { useBookDescription } from "~/api/useBookDescription"; - -interface BookDetailsProps { - book: Book; - // The list the book was opened from, used to browse to its neighbours. - books: Book[]; - onBookSelect: (book: Book) => void; - onGoBack: () => void; -} - -function BookDetails({ - book, - books, - onBookSelect, - onGoBack, -}: BookDetailsProps) { - const { data: description, isPending, isError } = useBookDescription(book.id); - const descriptionText = isPending - ? "Loading description..." - : isError - ? "Error loading description" - : (description ?? "No description available"); - - // index is -1 if the list has changed since the book was opened; then both - // browse buttons are disabled. - const index = books.findIndex((b) => b.id === book.id); - const previousBook = index > 0 ? books[index - 1] : undefined; - const nextBook = index >= 0 ? books[index + 1] : undefined; - - return ( -
-
- - - - -
-
-

{book.title}

-

{book.author.join(", ")}

-

{book.publishedYear ?? "Unknown"}

-

Description

-

{descriptionText}

-
-
- ); -} - -export { BookDetails }; diff --git a/app/components/BookGrid/BookGrid.test.tsx b/app/components/BookGrid/BookGrid.test.tsx deleted file mode 100644 index 4d9bbcf..0000000 --- a/app/components/BookGrid/BookGrid.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import type { ComponentProps } from "react"; -import { describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { BookGrid } from "~/components/BookGrid/BookGrid"; -import { toBook } from "~/api/openLibrary"; -import { renderWithQueryClient } from "~/test/renderWithQueryClient"; -import searchResponse from "~/test/fixtures/searchResponse.json"; -import { useBooks } from "~/api/useBooks"; - -// Helper to create mock booksQuery results -function createMockBooksQuery(overrides = {}) { - return { - data: { numFound: 118610, books: searchResponse.docs.map(toBook) }, - isPending: false, - isError: false, - error: null, - ...overrides, - } as ReturnType; -} - -// Renders BookGrid with sensible defaults; tests override only what they need. -function renderBookGrid(props: Partial> = {}) { - return renderWithQueryClient( - , - ); -} - -describe("BookGrid", () => { - it("shows a loading message when data is pending", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ isPending: true }), - }); - - expect(screen.getByRole("status")).toHaveTextContent("Loading books..."); - }); - - it("shows one card per book when data loads", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ isPending: false }), - }); - - expect(screen.getByText("The Blade Itself")).toBeInTheDocument(); - expect(screen.getAllByRole("article")).toHaveLength(5); - }); - - it("shows the correct page count", () => { - renderBookGrid({ page: 3 }); - - // 118610 books in the fixture / 12 per page = 9885 pages - expect(screen.getByText("3 of 9885")).toBeInTheDocument(); - }); - - it("calls onPageChange when the Next button is clicked", async () => { - // jsdom cannot scroll; replace window.scrollTo so Pagination does not warn. - vi.spyOn(window, "scrollTo").mockImplementation(vi.fn()); - const user = userEvent.setup(); - const onPageChange = vi.fn(); - - renderBookGrid({ onPageChange }); - - await user.click(screen.getByRole("button", { name: "Next" })); - - expect(onPageChange).toHaveBeenCalledWith(2); - }); - - it("shows an error message when an error occurs", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ - isPending: false, - isError: true, - error: new Error("Open Library search failed with status 500"), - }), - }); - - expect( - screen.getByText("Error: Open Library search failed with status 500"), - ).toBeInTheDocument(); - }); - - it("shows a message when no books match the search", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ - data: { numFound: 0, books: [] }, - }), - }); - - expect( - screen.getByText("No books found for this search"), - ).toBeInTheDocument(); - }); -}); diff --git a/app/components/BookGrid/BookGrid.tsx b/app/components/BookGrid/BookGrid.tsx deleted file mode 100644 index 0242fc0..0000000 --- a/app/components/BookGrid/BookGrid.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { BookCard } from "~/components/BookCard/BookCard"; -import { SearchBar } from "~/components/SearchBar/SearchBar"; -import styles from "./BookGrid.module.css"; -import { useBooks } from "~/api/useBooks"; -import type { Book, BookSearchParams } from "~/api/types"; -import { Pagination } from "~/components/Pagination/Pagination"; -import { SEARCH_LIMIT } from "~/api/openLibrary"; - -interface BookGridProps { - // The result of useBooks, owned by App so it outlives this component. - booksQuery: ReturnType; - search: BookSearchParams; - onSearchChange: (query: BookSearchParams) => void; - page: number; - onBookSelect: (book: Book) => void; - onPageChange: (page: number) => void; -} - -function BookGrid({ - booksQuery, - search, - onSearchChange, - page, - onBookSelect, - onPageChange, -}: BookGridProps) { - const { data, isPending, isError, error } = booksQuery; - - const searchBar = ( - onSearchChange({ ...search, query: value })} - onFilterChange={(value) => onSearchChange({ ...search, filters: value })} - /> - ); - - if (isPending) { - return ( - <> - {searchBar} -

- Loading books... -

- - ); - } - - if (isError) { - return ( - <> - {searchBar} -

- Error: {error.message} -

- - ); - } - - if (data.books.length === 0) { - return ( - <> - {searchBar} -

- No books found for this search -

- - ); - } - - return ( - <> - {searchBar} - -
    - {data.books.map((book) => ( -
  • - -
  • - ))} -
- - - ); -} - -export { BookGrid }; diff --git a/app/components/FilterBooksSection/FilterBooksSection.tsx b/app/components/FilterBooksSection/FilterBooksSection.tsx index 75322b6..6e4bc70 100644 --- a/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -1,8 +1,11 @@ -import { useState } from "react"; import styles from "./FilterBooksSection.module.css"; import type { BookFilters } from "~/api/types"; import { languageCodes } from "~/api/languageCodes"; import { CircleQuestionMark } from "~/components/icons/CircleQuestionMark"; +import { useSearchParams } from "react-router"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; +import { useDebounce } from "~/api/useDebounce"; +import { useEffect, useRef, useState } from "react"; const FILTER_INPUTS: { name: keyof BookFilters; @@ -36,28 +39,36 @@ const FILTER_INPUTS: { }, ]; -function FilterBooksSection({ - onFilterChange, -}: { - onFilterChange?: (filter: BookFilters) => void; -}) { - const [filters, setFilters] = useState(() => { - const rawFilters = sessionStorage.getItem("filters"); - return JSON.parse(rawFilters ?? "{}") as BookFilters; - }); +function FilterBooksSection() { + const [, setSearchParams] = useSearchParams(); + const { filters: rawFilters } = useBookSearchParams(); - function handleFilterChange(field: keyof BookFilters, value: string) { - const newFilters = { ...filters, [field]: value }; + const [filters, setFilters] = useState(rawFilters); + const newFiltersFromInputRef = useRef(false); - if (value === "") { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- It is safe to remove the property from the BookFilter object. - delete newFilters[field]; + const debouncedFilters = useDebounce(filters, 500); + + useEffect(() => { + // If the filters are not changed using the input, e.g. browser navigation, + // we want to use those filters immediately. + if (!newFiltersFromInputRef.current) { + setFilters(rawFilters); + return; } - setFilters(newFilters); - sessionStorage.setItem("filters", JSON.stringify(newFilters)); - onFilterChange?.(newFilters); - } + setSearchParams((searchParams) => { + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (value && value.length > 0) { + searchParams.set(key, value); + } else { + searchParams.delete(key); + } + }); + return searchParams; + }); + + newFiltersFromInputRef.current = false; + }, [debouncedFilters, rawFilters, setSearchParams]); return (
@@ -75,7 +86,10 @@ function FilterBooksSection({ aria-label={input.ariaLabel} placeholder={input.placeholder} value={filters[input.name] ?? ""} - onChange={(e) => handleFilterChange(input.name, e.target.value)} + onChange={(e) => { + newFiltersFromInputRef.current = true; + setFilters({ ...filters, [input.name]: e.target.value }); + }} /> ))} @@ -94,7 +108,10 @@ function FilterBooksSection({ name="language" aria-label="Search for language" value={filters.language ?? ""} - onChange={(e) => handleFilterChange("language", e.target.value)} + onChange={(e) => { + newFiltersFromInputRef.current = true; + setFilters({ ...filters, language: e.target.value }); + }} > {languageCodes.map((lang) => ( diff --git a/app/components/Pagination/Pagination.test.tsx b/app/components/Pagination/Pagination.test.tsx index 4d6640f..52f2a94 100644 --- a/app/components/Pagination/Pagination.test.tsx +++ b/app/components/Pagination/Pagination.test.tsx @@ -1,40 +1,43 @@ import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Pagination } from "~/components/Pagination/Pagination"; +import { createRoutesStub } from "react-router"; + +// Pagination reads and writes the current page in the "page" search param. +const Stub = createRoutesStub([ + { + path: "/", + Component: () => , + }, +]); describe("Pagination", () => { it("disables Previous on the first page and Next on the last page", () => { - const { rerender } = render( - , - ); + const { unmount } = render(); expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); - // rerender updates the props of the component that is already rendered. - rerender( - , - ); + // A fresh render is needed because the page comes from the URL. + unmount(); + render(); expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); }); - it("calls onPageChange with the next or previous page and scrolls to the top", async () => { + it("moves to the next or previous page and scrolls to the top", async () => { // jsdom cannot scroll; replace window.scrollTo so it does not warn. const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(vi.fn()); const user = userEvent.setup(); - const onPageChange = vi.fn(); - render( - , - ); + render(); await user.click(screen.getByRole("button", { name: "Next" })); - expect(onPageChange).toHaveBeenCalledWith(4); + await waitFor(() => expect(screen.getByText("4 of 5")).toBeInTheDocument()); await user.click(screen.getByRole("button", { name: "Previous" })); - expect(onPageChange).toHaveBeenCalledWith(2); + await waitFor(() => expect(screen.getByText("3 of 5")).toBeInTheDocument()); expect(scrollTo).toHaveBeenCalledTimes(2); }); diff --git a/app/components/Pagination/Pagination.tsx b/app/components/Pagination/Pagination.tsx index c7fb61f..e4b9574 100644 --- a/app/components/Pagination/Pagination.tsx +++ b/app/components/Pagination/Pagination.tsx @@ -1,17 +1,17 @@ +import { useSearchParams } from "react-router"; import styles from "./Pagination.module.css"; -function Pagination({ - totalPages, - currentPage, - onPageChange, -}: { - totalPages: number; - currentPage: number; - onPageChange: (page: number) => void; -}) { +function Pagination({ totalPages }: { totalPages: number }) { + const [searchParams, setSearchParams] = useSearchParams(); + const currentPage = parseInt(searchParams.get("page") ?? "1"); + const handlePageChange = (newPage: number) => { window.scrollTo({ top: 0, behavior: "smooth" }); - onPageChange(newPage); + + setSearchParams((prev) => { + prev.set("page", newPage.toString()); + return prev; + }); }; return ( diff --git a/app/components/SearchBar/SearchBar.test.tsx b/app/components/SearchBar/SearchBar.test.tsx index e89a63d..157b639 100644 --- a/app/components/SearchBar/SearchBar.test.tsx +++ b/app/components/SearchBar/SearchBar.test.tsx @@ -1,37 +1,61 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SearchBar } from "~/components/SearchBar/SearchBar"; +import { createRoutesStub, useSearchParams } from "react-router"; + +// Shows the current "q" param so tests can assert what SearchBar wrote to the URL. +function SearchParamProbe() { + const [searchParams] = useSearchParams(); + return

{searchParams.get("q") ?? ""}

; +} + +const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + <> + + + + ), + }, +]); describe("SearchBar", () => { - it("shows the query it is given", () => { - render( - , - ); + it("shows the query from the URL", () => { + render(); expect( screen.getByRole("searchbox", { name: "Search for books" }), ).toHaveValue("The Blade Itself"); }); - it("calls onQueryChange with the new value when the user types", async () => { + it("writes the typed query to the URL after the debounce", async () => { const user = userEvent.setup(); - const onQueryChange = vi.fn(); - render( - , + render(); + + const searchbox = screen.getByRole("searchbox", { + name: "Search for books", + }); + await user.type(searchbox, "T"); + + // The input updates immediately, the URL only after the debounce. + expect(searchbox).toHaveValue("T"); + expect(screen.getByTestId("query")).toBeEmptyDOMElement(); + + await waitFor( + () => expect(screen.getByTestId("query")).toHaveTextContent("T"), + { timeout: 2000 }, ); + }); - await user.type(screen.getByRole("searchbox"), "T"); + it("shows the number of active filters", () => { + render(); - expect(onQueryChange).toHaveBeenCalledTimes(1); - expect(onQueryChange).toHaveBeenCalledWith("T"); + expect( + screen.getByRole("button", { name: "Filter books" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("2 filters active")).toHaveTextContent("2"); }); }); diff --git a/app/components/SearchBar/SearchBar.tsx b/app/components/SearchBar/SearchBar.tsx index 24d1da0..8975088 100644 --- a/app/components/SearchBar/SearchBar.tsx +++ b/app/components/SearchBar/SearchBar.tsx @@ -1,24 +1,48 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import styles from "./SearchBar.module.css"; import { FilterIcon } from "~/components/icons/FilterIcon"; import { FilterBooksSection } from "~/components/FilterBooksSection/FilterBooksSection"; -import type { BookFilters } from "~/api/types"; +import { useSearchParams } from "react-router"; +import { useDebounce } from "~/api/useDebounce"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; -interface SearchBarProps { - query: string; - filters?: BookFilters; - onQueryChange: (newQuery: string) => void; - onFilterChange: (filter: BookFilters) => void; -} +const SEARCH_DEBOUNCE_MS = 500; + +function SearchBar() { + const { rawQuery: query, filters } = useBookSearchParams(); + const [, setSearchParams] = useSearchParams(); + + const [inputField, setInputField] = useState(query); + const debouncedQuery = useDebounce(inputField, SEARCH_DEBOUNCE_MS); + const newQueryFromInputRef = useRef(false); + + useEffect(() => { + if (debouncedQuery !== inputField || debouncedQuery === query) { + return; + } + + // If the filters are not changed using the input, e.g. browser navigation, + // we want to use those filters immediately. + if (!newQueryFromInputRef.current) { + setInputField(query); + return; + } + + setSearchParams((searchParams) => { + if (debouncedQuery.length > 0) { + searchParams.set("q", debouncedQuery); + } else { + searchParams.delete("q"); + } + return searchParams; + }); + newQueryFromInputRef.current = false; + }, [debouncedQuery, inputField, query, setSearchParams]); -function SearchBar({ - query, - filters, - onQueryChange, - onFilterChange, -}: SearchBarProps) { const [filtersOpen, setFiltersOpen] = useState(false); - const filtersCount = filters ? Object.keys(filters).length : 0; + const filtersCount = Object.values(filters).filter( + (value) => value !== undefined, + ).length; const filtersDescription = `${filtersCount} ${filtersCount === 1 ? "filter" : "filters"} active`; @@ -31,8 +55,11 @@ function SearchBar({ aria-label="Search for books" className={styles.searchBar} placeholder="Search books..." - value={query} - onChange={(e) => onQueryChange(e.target.value)} + value={inputField} + onChange={(e) => { + newQueryFromInputRef.current = true; + setInputField(e.target.value); + }} />
- {filtersOpen && } + {filtersOpen && } ); } diff --git a/app/routes.ts b/app/routes.ts index 38f1a4d..3dc6530 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,3 +1,6 @@ -import { index, type RouteConfig } from "@react-router/dev/routes"; +import { index, route, type RouteConfig } from "@react-router/dev/routes"; -export default [index("routes/home.tsx")] satisfies RouteConfig; +export default [ + index("routes/home.tsx"), + route("book/:bookId", "routes/book/selectedBook.tsx"), +] satisfies RouteConfig; diff --git a/app/components/BookDetails/BookDetails.module.css b/app/routes/book/selectedBook.module.css similarity index 81% rename from app/components/BookDetails/BookDetails.module.css rename to app/routes/book/selectedBook.module.css index bc53572..666bdd8 100644 --- a/app/components/BookDetails/BookDetails.module.css +++ b/app/routes/book/selectedBook.module.css @@ -1,3 +1,11 @@ +.info-text { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + .book-details { display: flex; flex-direction: row; @@ -13,6 +21,7 @@ } .go-back-button { + all: unset; position: absolute; top: -42px; left: 0; @@ -37,6 +46,7 @@ } .browse-button { + all: unset; background-color: white; border: 1px solid var(--border-color); border-radius: var(--border-radius); @@ -54,6 +64,17 @@ } } +.browse-button-disabled { + all: unset; + background-color: white; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 8px 16px; + cursor: not-allowed; + font-size: 0.925rem; + color: #9ca3af; +} + .cover-section { position: relative; flex: 0 0 40%; diff --git a/app/routes/book/selectedBook.test.tsx b/app/routes/book/selectedBook.test.tsx new file mode 100644 index 0000000..9c9027b --- /dev/null +++ b/app/routes/book/selectedBook.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { fetchWork, searchBooks } from "~/api/openLibrary"; +import { renderWithQueryClient } from "~/test/renderWithQueryClient"; +import { bookWithCover as book } from "~/test/fixtures/books"; +import searchResponse from "~/test/fixtures/searchResponse.json"; +import workResponse from "~/test/fixtures/workResponse.json"; +import SelectedBookPage from "~/routes/book/selectedBook"; +import HomePage from "~/routes/home"; +import { createRoutesStub } from "react-router"; + +// SelectedBookPage needs both halves of the Open Library data: the book list +// through useBooks -> searchBooks, and the description through +// useBookDescription -> fetchWork. Mock both so no requests are made. +vi.mock(import("~/api/openLibrary"), async (importOriginal) => ({ + ...(await importOriginal()), + searchBooks: vi.fn(), + fetchWork: vi.fn(), +})); + +const Stub = createRoutesStub([ + { + path: "/", + Component: HomePage, + }, + { + path: "/book/:bookId", + Component: SelectedBookPage, + }, +]); + +describe("SelectedBookPage", () => { + it("shows the description once it has loaded", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + vi.mocked(fetchWork).mockResolvedValue(workResponse); + + renderWithQueryClient(); + + expect( + await screen.findByRole("heading", { name: "The Blade Itself" }), + ).toBeInTheDocument(); + expect(await screen.findByText(/Logen Ninefingers/)).toBeInTheDocument(); + expect(fetchWork).toHaveBeenCalledWith(book.id); + }); + + it("goes back to the grid when the back button is clicked", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + vi.mocked(fetchWork).mockResolvedValue(workResponse); + const user = userEvent.setup(); + + renderWithQueryClient(); + + await user.click( + await screen.findByRole("link", { name: "Go back to search results" }), + ); + + // The grid comes back, so the navigation to "/" resolved. + expect(await screen.findByRole("list")).toBeInTheDocument(); + expect(screen.queryByText(/Logen Ninefingers/)).toBe(null); + }); + + it("shows a message when the book is missing from the list", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + vi.mocked(fetchWork).mockResolvedValue(workResponse); + + renderWithQueryClient(); + + expect( + await screen.findByRole("heading", { name: "Book Not Found" }), + ).toBeInTheDocument(); + }); +}); diff --git a/app/routes/book/selectedBook.tsx b/app/routes/book/selectedBook.tsx new file mode 100644 index 0000000..604bbc5 --- /dev/null +++ b/app/routes/book/selectedBook.tsx @@ -0,0 +1,114 @@ +import type { Route } from "./+types/selectedBook"; +import styles from "./selectedBook.module.css"; +import { BookCover } from "~/components/BookCover/BookCover"; +import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton"; +import { useBookDescription } from "~/api/useBookDescription"; +import { Link, useSearchParams } from "react-router"; +import { useBooks } from "~/api/useBooks"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; + +export default function SelectedBookPage({ params }: Route.ComponentProps) { + const [searchParams] = useSearchParams(); + const { query, page, filters } = useBookSearchParams(); + + const { data: bookQuery, isPending: isBooksPending } = useBooks({ + query, + filters, + page, + }); + + const { + data: description, + isPending: isDescriptionPending, + isError, + } = useBookDescription(params.bookId); + + const books = bookQuery?.books; + const book = books?.find((b) => b.id === params.bookId); + + const descriptionText = isDescriptionPending + ? "Loading description..." + : isError + ? "Error loading description" + : (description ?? "No description available"); + + if (isBooksPending || isDescriptionPending) { + return ( +
+

Loading Book Details

+

The requested book details are currently being loaded.

+
+ ); + } + + if (!books || !book) { + return ( +
+

Book Not Found

+

The requested book could not be found.

+
+ ); + } + + // index is -1 if the list has changed since the book was opened; then both + // browse buttons are disabled. + const index = books.findIndex((b) => b.id === params.bookId); + const previousBook = index > 0 ? books[index - 1] : undefined; + const nextBook = index >= 0 ? books[index + 1] : undefined; + + return ( +
+
+
+ + Go back + + + + +
+
+

{book.title}

+

{book.author.join(", ")}

+

{book.publishedYear ?? "Unknown"}

+

Description

+

{descriptionText}

+
+
+
+ ); +} diff --git a/app/components/BookGrid/BookGrid.module.css b/app/routes/home.module.css similarity index 100% rename from app/components/BookGrid/BookGrid.module.css rename to app/routes/home.module.css diff --git a/app/routes/home.test.tsx b/app/routes/home.test.tsx index 69c85e7..c6b2ae6 100644 --- a/app/routes/home.test.tsx +++ b/app/routes/home.test.tsx @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import HomePage from "~/routes/home"; +import SelectedBookPage from "~/routes/book/selectedBook"; import { fetchWork, searchBooks } from "~/api/openLibrary"; import { renderWithQueryClient } from "~/test/renderWithQueryClient"; import searchResponse from "~/test/fixtures/searchResponse.json"; import workResponse from "~/test/fixtures/workResponse.json"; +import { createRoutesStub } from "react-router"; // Mock both API functions so nothing is fetched from openlibrary.org. vi.mock(import("~/api/openLibrary"), async (importOriginal) => ({ @@ -15,14 +17,25 @@ vi.mock(import("~/api/openLibrary"), async (importOriginal) => ({ })); describe("HomePage", () => { + const HomePageStub = createRoutesStub([ + { + path: "/", + Component: HomePage, + }, + { + path: "/book/:bookId", + Component: SelectedBookPage, + }, + ]); + it("opens a book's details from the grid and goes back", async () => { vi.mocked(searchBooks).mockResolvedValue(searchResponse); vi.mocked(fetchWork).mockResolvedValue(workResponse); const user = userEvent.setup(); - renderWithQueryClient(); + renderWithQueryClient(); await user.click( - await screen.findByRole("button", { + await screen.findByRole("link", { name: "View details for The Blade Itself", }), ); @@ -33,12 +46,13 @@ describe("HomePage", () => { expect(await screen.findByText(/Logen Ninefingers/)).toBeInTheDocument(); expect(screen.queryByRole("list")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Go back" })); + await user.click( + screen.getByRole("link", { name: "Go back to search results" }), + ); expect(screen.getByRole("list")).toBeInTheDocument(); expect(screen.getAllByRole("article")).toHaveLength(5); - // The test client uses the same staleTime as main.tsx, so going back reuses - // the cached result instead of calling the API again. + expect(searchBooks).toHaveBeenCalledTimes(1); expect(fetchWork).toHaveBeenCalledTimes(1); }); diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 086acd7..4793694 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; -import { BookGrid } from "~/components/BookGrid/BookGrid"; -import { BookDetails } from "~/components/BookDetails/BookDetails"; +import { Pagination } from "~/components/Pagination/Pagination"; +import { SEARCH_LIMIT } from "~/api/openLibrary"; import { useBooks } from "~/api/useBooks"; -import { useDebounce } from "~/components/BookGrid/useDebounce"; -import type { Book, BookFilters, BookSearchParams } from "~/api/types"; +import styles from "./home.module.css"; +import { SearchBar } from "~/components/SearchBar/SearchBar"; +import { BookCard } from "~/components/BookCard/BookCard"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; export function meta() { return [ @@ -13,56 +14,58 @@ export function meta() { } export default function HomePage() { - const [selectedBook, setSelectedBook] = useState(null); + const { query, page, filters } = useBookSearchParams(); - // Search state and the fetched books live here instead of in BookGrid, so - // they survive switching to BookDetails and back. - const [search, setSearch] = useState(() => { - const rawFilters = sessionStorage.getItem("filters"); - const initFilters = JSON.parse(rawFilters ?? "{}") as BookFilters; - - return { - query: "", - page: 1, - filters: initFilters, - }; + const { data, isPending, isError, error } = useBooks({ + query, + page, + filters, }); - const debouncedSearch = useDebounce(search, 500); - // We can perform a search if we have either a string query, - // or at least one filter. - // If we have neither, use the default "subject:fantasy". - const hasFilters = Object.keys(debouncedSearch.filters ?? {}).length > 0; - const query = - hasFilters || debouncedSearch.query.length > 0 - ? debouncedSearch.query - : "subject:fantasy"; + if (isPending) { + return ( +
+ +

+ Loading books... +

+
+ ); + } - const booksQuery = useBooks({ - query, - page: search.page, - filters: debouncedSearch.filters, - }); + if (isError) { + return ( +
+ +

+ Error: {error.message} +

+
+ ); + } + + if (data.books.length === 0) { + return ( +
+ +

+ No books found for this search +

+
+ ); + } return (
- {selectedBook ? ( - setSelectedBook(null)} - /> - ) : ( - setSearch({ ...search, page })} - /> - )} + +
    + {data.books.map((book) => ( +
  • + +
  • + ))} +
+
); } diff --git a/app/test/fixtures/books.ts b/app/test/fixtures/books.ts index 7fc5213..67914f3 100644 --- a/app/test/fixtures/books.ts +++ b/app/test/fixtures/books.ts @@ -1,9 +1,10 @@ import type { Book } from "~/api/types"; // Sample book for component tests. Matches the first doc in searchResponse.json -// and the work in workResponse.json. +// and the work in workResponse.json. The id has no "/works/" prefix, the same +// way toBook() maps an Open Library doc (app/api/openLibrary.ts). const bookWithCover: Book = { - id: "/works/OL8400950W", + id: "OL8400950W", title: "The Blade Itself", author: ["Joe Abercrombie"], publishedYear: 2001,