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
83 changes: 66 additions & 17 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("App", () => {

it("renders the first book once loaded", async () => {
withProviders(<App />);
const heading = await screen.findByRole("heading", { name: "The Hobbit" });
const heading = await screen.findByRole("heading", { name: "A Wizard of Earthsea" });
expect(heading).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Book viewer" })).toBeInTheDocument();
});
Expand All @@ -52,23 +52,23 @@ describe("App", () => {
it("navigates to the next and previous book with the controls", async () => {
const user = userEvent.setup();
withProviders(<App />);
await screen.findByRole("heading", { name: "The Hobbit" });
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

await user.click(screen.getByRole("button", { name: "Show next book" }));
expect(
await screen.findByRole("heading", { name: "A Wizard of Earthsea" }),
).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "The Hobbit" })).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(
await screen.findByRole("heading", { name: "A Wizard of Earthsea" }),
).toBeInTheDocument();
expect(screen.getByText("1 / 3")).toBeInTheDocument();
});

it("jumps directly to a book from the list", async () => {
const user = userEvent.setup();
withProviders(<App />);
await screen.findByRole("heading", { name: "The Hobbit" });
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

await user.selectOptions(screen.getByLabelText("Jump to book"), "2");
expect(
Expand All @@ -81,27 +81,45 @@ describe("App", () => {
resetSearchRequestCount();
const user = userEvent.setup();
withProviders(<App />);
await screen.findByRole("heading", { name: "The Hobbit" });
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

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.click(screen.getByRole("button", { name: "Show previous book" }));
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });
await user.selectOptions(screen.getByLabelText("Jump to book"), "2");
await screen.findByRole("heading", { name: "The Left Hand of Darkness" });

expect(getSearchRequestCount()).toBe(1);
});

it("sorts the displayed books without making another API request", async () => {
const user = userEvent.setup();
withProviders(<App />);
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

await user.selectOptions(screen.getByRole("combobox", { name: "Sort by" }), "newest");

expect(
await screen.findByRole("heading", { name: "The Left Hand of Darkness" }),
).toBeInTheDocument();
expect(getSearchRequestCount()).toBe(1);
expect(screen.getByRole("combobox", { name: "Jump to book" })).toHaveValue("0");
expect(screen.getByRole("option", { name: "The Left Hand of Darkness" })).toBeInTheDocument();
});

it("fetches once more when the subject changes", async () => {
withProviders(<App />);
await screen.findByRole("heading", { name: "The Hobbit" });
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

fireEvent.change(screen.getByRole("combobox", { name: "Subject" }), {
target: { value: "mystery" },
});

await waitFor(() => expect(getSearchRequestCount()).toBe(2));
expect(
await screen.findByRole("heading", { name: "A Wizard of Earthsea" }),
).toBeInTheDocument();
expect(JSON.parse(sessionStorage.getItem("t19.preferences") ?? "{}")).toMatchObject({
subject: "mystery",
});
Expand All @@ -115,31 +133,62 @@ describe("App", () => {
await waitFor(() => expect(getSearchRequestCount()).toBe(1));
});

it("restores the selected sort and preserves the subject preference", async () => {
sessionStorage.setItem(
"t19.preferences",
JSON.stringify({ subject: "mystery", sort: "newest" }),
);
withProviders(<App />);

expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("mystery");
expect(screen.getByRole("combobox", { name: "Sort by" })).toHaveValue("newest");
expect(
await screen.findByRole("heading", { name: "The Left Hand of Darkness" }),
).toBeInTheDocument();
});

it("preserves unrelated preferences when the sort changes", async () => {
const user = userEvent.setup();
sessionStorage.setItem("t19.preferences", JSON.stringify({ subject: "mystery" }));
withProviders(<App />);
await screen.findByRole("heading", { name: "A Wizard of Earthsea" });

await user.selectOptions(screen.getByRole("combobox", { name: "Sort by" }), "oldest");

expect(JSON.parse(sessionStorage.getItem("t19.preferences") ?? "{}")).toEqual({
subject: "mystery",
sort: "oldest",
});
});

it("uses the default subject when stored preferences are corrupt", () => {
sessionStorage.setItem("t19.preferences", "not valid json");
withProviders(<App />);

expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("fantasy");
expect(screen.getByRole("combobox", { name: "Sort by" })).toHaveValue("title-asc");
});

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

fireEvent.click(screen.getByRole("button", { name: "Add The Hobbit to favorites" }));
expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL1"]));
fireEvent.click(screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" }));
expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL2"]));

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

fireEvent.click(screen.getByRole("button", { name: "Remove The Hobbit from favorites list" }));
fireEvent.click(
screen.getByRole("button", { name: "Remove A Wizard of Earthsea from favorites list" }),
);
expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify([]));
});

it("matches the snapshot", async () => {
const { container } = withProviders(<App />);
await screen.findByRole("heading", { level: 2, name: "The Hobbit" });
await screen.findByRole("heading", { level: 2, name: "A Wizard of Earthsea" });
expect(container).toMatchSnapshot();
});
});
19 changes: 13 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ import { SubjectFilter } from "./components/SubjectFilter";
import { useBooks } from "./hooks/useBooks";
import { useFavorites } from "./hooks/useFavorites";
import { usePreferences } from "./hooks/usePreferences";
import { SortSelect } from "./components/SortSelect";
import { sortBooks } from "./utils/sortBooks";
import "./App.css";

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

const sortedBooks = sortBooks(books, sort);

const [index, setIndex] = useState(0);
const safeIndex = books.length > 0 ? Math.min(index, books.length - 1) : 0;
const current = books[safeIndex];
const safeIndex = sortedBooks.length > 0 ? Math.min(index, sortedBooks.length - 1) : 0;
const current = sortedBooks[safeIndex];

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

return (
Expand All @@ -25,6 +31,7 @@ function App() {
</header>
<main className="app-main">
<SubjectFilter subject={subject} onSubjectChange={setSubject} />
<SortSelect sort={sort} onSortChange={setSort} />
{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 && (
Expand All @@ -39,12 +46,12 @@ function App() {
/>
<NavigationControls
currentIndex={safeIndex}
totalCount={books.length}
totalCount={sortedBooks.length}
onPrevious={() => setIndex(Math.max(0, safeIndex - 1))}
onNext={() => setIndex(Math.min(books.length - 1, safeIndex + 1))}
onNext={() => setIndex(Math.min(sortedBooks.length - 1, safeIndex + 1))}
/>
<BookJumpList
titles={books.map((book) => book.title)}
titles={sortedBooks.map((book) => book.title)}
currentIndex={safeIndex}
onSelect={setIndex}
/>
Expand Down
51 changes: 40 additions & 11 deletions src/__snapshots__/App.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@ exports[`App > matches the snapshot 1`] = `
</option>
</select>
</label>
<label
class="sort-select"
for="sort-select"
>
Sort by
<select
id="sort-select"
>
<option
value="title-asc"
>
Title A-Z
</option>
<option
value="title-desc"
>
Title Z-A
</option>
<option
value="newest"
>
Newest first
</option>
<option
value="oldest"
>
Oldest first
</option>
</select>
</label>
<section
aria-label="Book viewer"
class="book-viewer"
Expand All @@ -68,25 +98,24 @@ exports[`App > matches the snapshot 1`] = `
<div
class="book-card-cover"
>
<img
alt="Cover of The Hobbit"
src="https://covers.openlibrary.org/b/id/123-L.jpg"
/>
<p>
No cover available
</p>
</div>
<div
class="book-card-content"
>
<h2>
The Hobbit
A Wizard of Earthsea
</h2>
<p
class="book-card-authors"
>
J. R. R. Tolkien
Ursula K. Le Guin
</p>
<p>
First published:
1937
1968
</p>
<ul
aria-label="Subjects"
Expand All @@ -96,11 +125,11 @@ exports[`App > matches the snapshot 1`] = `
fantasy
</li>
<li>
dragons
magic
</li>
</ul>
<button
aria-label="Add The Hobbit to favorites"
aria-label="Add A Wizard of Earthsea to favorites"
aria-pressed="false"
class="favorite-button"
type="button"
Expand Down Expand Up @@ -146,12 +175,12 @@ exports[`App > matches the snapshot 1`] = `
<option
value="0"
>
The Hobbit
A Wizard of Earthsea
</option>
<option
value="1"
>
A Wizard of Earthsea
The Hobbit
</option>
<option
value="2"
Expand Down
25 changes: 25 additions & 0 deletions src/components/SortSelect.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.sort-select {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 20rem;
margin: 0 auto 1.5rem;
color: #243b53;
font-weight: 600;
}

.sort-select select {
min-height: 2.75rem;
padding: 0.625rem 0.75rem;
border: 1px solid #d5dde5;
border-radius: 0.5rem;
background-color: #ffffff;
color: #1f2933;
font: inherit;
font-weight: 400;
}

.sort-select select:focus-visible {
outline: 0.1875rem solid #2c3e50;
outline-offset: 0.125rem;
}
30 changes: 30 additions & 0 deletions src/components/SortSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SortSelect } from "./SortSelect";

describe("SortSelect", () => {
it("renders an accessible select with all sort options", () => {
render(<SortSelect sort="title-asc" onSortChange={vi.fn()} />);

const select = screen.getByRole("combobox", { name: "Sort by" });

expect(select).toHaveValue("title-asc");
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
"Title A-Z",
"Title Z-A",
"Newest first",
"Oldest first",
]);
});

it("reports the selected sort option", () => {
const onSortChange = vi.fn();
render(<SortSelect sort="title-asc" onSortChange={onSortChange} />);

fireEvent.change(screen.getByRole("combobox", { name: "Sort by" }), {
target: { value: "newest" },
});

expect(onSortChange).toHaveBeenCalledWith("newest");
});
});
27 changes: 27 additions & 0 deletions src/components/SortSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { SortOption } from "../types";
import { SORT_OPTIONS } from "../utils/sortBooks";
import "./SortSelect.css";

interface SortSelectProps {
sort: SortOption;
onSortChange: (sort: SortOption) => void;
}

export function SortSelect({ sort, onSortChange }: SortSelectProps) {
return (
<label className="sort-select" htmlFor="sort-select">
Sort by
<select
id="sort-select"
value={sort}
onChange={(event) => onSortChange(event.target.value as SortOption)}
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
Loading