Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,39 @@
margin: 0;
}

.view-navigation {
display: flex;
gap: 0.5rem;
max-width: 32rem;
margin: 0 auto 1.5rem;
}

.view-navigation button {
flex: 1;
padding: 0.625rem 0.75rem;
border: 1px solid #cbd2d9;
border-radius: 0.375rem;
background-color: #ffffff;
color: #243b53;
font: inherit;
font-weight: 600;
}

.view-navigation button:hover {
background-color: #edf2f7;
}

.view-navigation button[aria-pressed="true"] {
border-color: #2c3e50;
background-color: #2c3e50;
color: #ffffff;
}

.view-navigation button:focus-visible {
outline: 0.1875rem solid #2c3e50;
outline-offset: 0.125rem;
}

.book-viewer {
max-width: 32rem;
margin: 0 auto;
Expand Down
91 changes: 89 additions & 2 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ describe("App", () => {
expect(await screen.findByText(/No books found/)).toBeInTheDocument();
});

it("switches between the library and favorites views", async () => {
const user = userEvent.setup();
withProviders(<App />);
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

await user.click(screen.getByRole("button", { name: "Favorites" }));
expect(screen.getByRole("button", { name: "Favorites" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.queryByRole("region", { name: "Book viewer" })).not.toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Library" }));
expect(screen.getByRole("button", { name: "Library" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("region", { name: "Book viewer" })).toBeInTheDocument();
});

it("navigates to the next and previous book with the controls", async () => {
const user = userEvent.setup();
withProviders(<App />);
Expand Down Expand Up @@ -192,14 +209,24 @@ describe("App", () => {
});

it("saves, restores, and removes a favorite", async () => {
const user = userEvent.setup();
const firstRender = withProviders(<App />);
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

fireEvent.click(screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" }));
expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL2"]));
expect(JSON.parse(localStorage.getItem("t19.favorites") ?? "[]")).toEqual([
{
key: "/works/OL2",
title: "A Wizard of Earthsea",
authors: ["Ursula K. Le Guin"],
firstPublishYear: 1968,
coverId: null,
},
]);

firstRender.unmount();
withProviders(<App />);
await user.click(screen.getByRole("button", { name: "Favorites" }));
await screen.findByRole("button", { name: "Remove A Wizard of Earthsea from favorites list" });

fireEvent.click(
Expand All @@ -208,6 +235,58 @@ describe("App", () => {
expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify([]));
});

it("keeps favorites from different subjects together after reload", async () => {
server.use(
http.get("https://openlibrary.org/search.json", ({ request }) => {
const subject = new URL(request.url).searchParams.get("subject");
const docs =
subject === "mystery"
? [
{
key: "/works/OL4",
title: "The Moonstone",
author_name: ["Wilkie Collins"],
first_publish_year: 1868,
cover_i: 456,
},
]
: [
{
key: "/works/OL2",
title: "A Wizard of Earthsea",
author_name: ["Ursula K. Le Guin"],
first_publish_year: 1968,
},
];
return HttpResponse.json({ docs });
}),
);
const user = userEvent.setup();
const firstRender = withProviders(<App />);

await screen.findByRole("heading", { name: "A Wizard of Earthsea" });
await user.click(screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" }));

await user.selectOptions(screen.getByRole("combobox", { name: "Subject" }), "mystery");
await screen.findByRole("heading", { name: "The Moonstone" });
await user.click(screen.getByRole("button", { name: "Add The Moonstone to favorites" }));
await user.click(screen.getByRole("button", { name: "Favorites" }));

expect(
screen.getByRole("heading", { name: "A Wizard of Earthsea", level: 3 }),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "The Moonstone", level: 3 })).toBeInTheDocument();

firstRender.unmount();
withProviders(<App />);
await user.click(screen.getByRole("button", { name: "Favorites" }));

expect(
await screen.findByRole("heading", { name: "A Wizard of Earthsea", level: 3 }),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "The Moonstone", level: 3 })).toBeInTheDocument();
});

it("supports the complete library user flow without unnecessary requests", async () => {
const user = userEvent.setup();
withProviders(<App />);
Expand All @@ -229,7 +308,15 @@ describe("App", () => {

await user.click(screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" }));

expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL2"]));
expect(JSON.parse(localStorage.getItem("t19.favorites") ?? "[]")).toEqual([
{
key: "/works/OL2",
title: "A Wizard of Earthsea",
authors: ["Ursula K. Le Guin"],
firstPublishYear: 1968,
coverId: null,
},
]);
expect(getSearchRequestCount()).toBe(2);
});

Expand Down
95 changes: 62 additions & 33 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { BookCard } from "./components/BookCard";
import { BookJumpList } from "./components/BookJumpList";
import { NavigationControls } from "./components/NavigationControls";
Expand All @@ -12,17 +12,27 @@ import { SortSelect } from "./components/SortSelect";
import { sortBooks } from "./utils/sortBooks";
import "./App.css";

type AppView = "library" | "favorites";

function App() {
const { subject, setSubject, sort, setSort } = usePreferences();
const { books, isLoading, isError } = useBooks(subject);

const sortedBooks = sortBooks(books, sort);

const [index, setIndex] = useState(0);
const [view, setView] = useState<AppView>("library");
const safeIndex = sortedBooks.length > 0 ? Math.min(index, sortedBooks.length - 1) : 0;
const current = sortedBooks[safeIndex];

const { favoriteKeys, toggleFavorite, removeFavorite } = useFavorites();
const { favoriteBooks, isFavorite, toggleFavorite, removeFavorite, updateFavoriteDetails } =
useFavorites();

useEffect(() => {
if (current !== undefined) {
updateFavoriteDetails(current);
}
}, [current, updateFavoriteDetails]);

function handleSubjectChange(nextSubject: string) {
setSubject(nextSubject);
Expand All @@ -41,37 +51,56 @@ function App() {
<p>Browse reading material from OpenLibrary, one book at a time.</p>
</header>
<main className="app-main">
<div className="library-controls">
<SubjectFilter subject={subject} onSubjectChange={handleSubjectChange} />
<SortSelect sort={sort} onSortChange={handleSortChange} />
</div>
{isLoading && <p role="status">Loading books…</p>}
{isError && <p role="alert">Could not load books. Please try refreshing the page.</p>}
{!isLoading && !isError && books.length === 0 && (
<p>No books found for the subject “{subject}”.</p>
)}
{current && (
<section className="book-viewer" aria-label="Book viewer">
<BookCard
book={current}
isFavorite={favoriteKeys.includes(current.key)}
onFavoriteToggle={() => toggleFavorite(current.key)}
/>
<NavigationControls
currentIndex={safeIndex}
totalCount={sortedBooks.length}
onPrevious={() => setIndex(Math.max(0, safeIndex - 1))}
onNext={() => setIndex(Math.min(sortedBooks.length - 1, safeIndex + 1))}
/>
<BookJumpList
titles={sortedBooks.map((book) => book.title)}
currentIndex={safeIndex}
onSelect={setIndex}
/>
</section>
)}
{!isLoading && !isError && (
<FavoritesView books={books} favoriteKeys={favoriteKeys} onRemove={removeFavorite} />
<nav className="view-navigation" aria-label="Library views">
<button
type="button"
aria-pressed={view === "library"}
onClick={() => setView("library")}
>
Library
</button>
<button
type="button"
aria-pressed={view === "favorites"}
onClick={() => setView("favorites")}
>
Favorites
</button>
</nav>
{view === "library" ? (
<>
<div className="library-controls">
<SubjectFilter subject={subject} onSubjectChange={handleSubjectChange} />
<SortSelect sort={sort} onSortChange={handleSortChange} />
</div>
{isLoading && <p role="status">Loading books…</p>}
{isError && <p role="alert">Could not load books. Please try refreshing the page.</p>}
{!isLoading && !isError && books.length === 0 && (
<p>No books found for the subject “{subject}”.</p>
)}
{current && (
<section className="book-viewer" aria-label="Book viewer">
<BookCard
book={current}
isFavorite={isFavorite(current.key)}
onFavoriteToggle={() => toggleFavorite(current)}
/>
<NavigationControls
currentIndex={safeIndex}
totalCount={sortedBooks.length}
onPrevious={() => setIndex(Math.max(0, safeIndex - 1))}
onNext={() => setIndex(Math.min(sortedBooks.length - 1, safeIndex + 1))}
/>
<BookJumpList
titles={sortedBooks.map((book) => book.title)}
currentIndex={safeIndex}
onSelect={setIndex}
/>
</section>
)}
</>
) : (
<FavoritesView favorites={favoriteBooks} onRemove={removeFavorite} />
)}
</main>
<footer className="app-footer">
Expand Down
30 changes: 17 additions & 13 deletions src/__snapshots__/App.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ exports[`App > matches the snapshot 1`] = `
<main
class="app-main"
>
<nav
aria-label="Library views"
class="view-navigation"
>
<button
aria-pressed="true"
type="button"
>
Library
</button>
<button
aria-pressed="false"
type="button"
>
Favorites
</button>
</nav>
<div
class="library-controls"
>
Expand Down Expand Up @@ -212,19 +229,6 @@ exports[`App > matches the snapshot 1`] = `
</select>
</label>
</section>
<section
aria-labelledby="favorites-heading"
class="favorites-view"
>
<h2
id="favorites-heading"
>
Favorites
</h2>
<p>
No favorite books yet.
</p>
</section>
</main>
<footer
class="app-footer"
Expand Down
34 changes: 24 additions & 10 deletions src/components/FavoritesView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Book } from "../types";
import type { FavoriteBook } from "../types";
import { FavoritesView } from "./FavoritesView";

const books: Book[] = [
const favorites: FavoriteBook[] = [
{
key: "/works/OL1",
title: "The Hobbit",
authors: ["J. R. R. Tolkien"],
firstPublishYear: 1937,
averageRating: 4.2,
ratingsCount: 100,
coverId: 123,
subjects: ["fantasy"],
},
];

describe("FavoritesView", () => {
it("shows a favorite with its author and remove control", () => {
const onRemove = vi.fn();
render(<FavoritesView books={books} favoriteKeys={["/works/OL1"]} onRemove={onRemove} />);
render(<FavoritesView favorites={favorites} onRemove={onRemove} />);

expect(screen.getByRole("heading", { name: "The Hobbit" })).toBeInTheDocument();
expect(screen.getByText("J. R. R. Tolkien")).toBeInTheDocument();
Expand All @@ -30,15 +27,32 @@ describe("FavoritesView", () => {
});

it("shows an empty state", () => {
render(<FavoritesView books={books} favoriteKeys={[]} onRemove={vi.fn()} />);
render(<FavoritesView favorites={[]} onRemove={vi.fn()} />);

expect(screen.getByText("No favorite books yet.")).toBeInTheDocument();
});

it("orders favorites alphabetically in the overview", () => {
const favoritesOutOfOrder: FavoriteBook[] = [
favorites[0],
{
key: "/works/OL2",
title: "A Wizard of Earthsea",
authors: ["Ursula K. Le Guin"],
firstPublishYear: 1968,
coverId: null,
},
];

render(<FavoritesView favorites={favoritesOutOfOrder} onRemove={vi.fn()} />);

expect(
screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent),
).toEqual(["A Wizard of Earthsea", "The Hobbit"]);
});

it("matches the rendered snapshot", () => {
const { container } = render(
<FavoritesView books={books} favoriteKeys={["/works/OL1"]} onRemove={vi.fn()} />,
);
const { container } = render(<FavoritesView favorites={favorites} onRemove={vi.fn()} />);

expect(container).toMatchSnapshot();
});
Expand Down
Loading