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 (
-
@@ -30,7 +31,7 @@ function BookCard({ book, onBookSelect }: BookCardProps) {
{book.author.join(", ")}
{book.publishedYear ?? "Unknown"}
-
+
);
}
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`] = `
>
+
-
matches the snapshot 1`] = `
2001
-
+
`;
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 (
-
-
-
- Go back
-
-
- {
- if (previousBook) onBookSelect(previousBook);
- }}
- disabled={!previousBook}
- aria-label="Previous book"
- >
- Previous
-
- {
- if (nextBook) onBookSelect(nextBook);
- }}
- disabled={!nextBook}
- aria-label="Next book"
- >
- Next
-
-
-
-
-
-
- {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 });
+ }}
>
Select a language
{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
+
+
+
+ Previous
+
+
+ Next
+
+
+
+
+
+
+ {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,