diff --git a/src/App.test.tsx b/src/App.test.tsx index 7b7911c..e8edf03 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -32,7 +32,7 @@ describe("App", () => { it("renders the first book once loaded", async () => { withProviders(); - 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(); }); @@ -52,23 +52,23 @@ describe("App", () => { it("navigates to the next and previous book with the controls", async () => { const user = userEvent.setup(); withProviders(); - await screen.findByRole("heading", { name: "The Hobbit" }); + await 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(); - 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( @@ -81,27 +81,45 @@ describe("App", () => { resetSearchRequestCount(); const user = userEvent.setup(); withProviders(); - 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(); + 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(); - 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", }); @@ -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(); + + 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(); + 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(); 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(); - 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(); - 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(); - await screen.findByRole("heading", { level: 2, name: "The Hobbit" }); + await screen.findByRole("heading", { level: 2, name: "A Wizard of Earthsea" }); expect(container).toMatchSnapshot(); }); }); diff --git a/src/App.tsx b/src/App.tsx index 2198822..8ab1884 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( @@ -25,6 +31,7 @@ function App() { + {isLoading && Loading books…} {isError && Could not load books. Please try refreshing the page.} {!isLoading && !isError && books.length === 0 && ( @@ -39,12 +46,12 @@ function App() { /> setIndex(Math.max(0, safeIndex - 1))} - onNext={() => setIndex(Math.min(books.length - 1, safeIndex + 1))} + onNext={() => setIndex(Math.min(sortedBooks.length - 1, safeIndex + 1))} /> book.title)} + titles={sortedBooks.map((book) => book.title)} currentIndex={safeIndex} onSelect={setIndex} /> diff --git a/src/__snapshots__/App.test.tsx.snap b/src/__snapshots__/App.test.tsx.snap index 77d621e..69cfc6c 100644 --- a/src/__snapshots__/App.test.tsx.snap +++ b/src/__snapshots__/App.test.tsx.snap @@ -58,6 +58,36 @@ exports[`App > matches the snapshot 1`] = ` + + Sort by + + + Title A-Z + + + Title Z-A + + + Newest first + + + Oldest first + + + matches the snapshot 1`] = ` - + + No cover available + - The Hobbit + A Wizard of Earthsea - J. R. R. Tolkien + Ursula K. Le Guin First published: - 1937 + 1968 matches the snapshot 1`] = ` fantasy - dragons + magic matches the snapshot 1`] = ` - The Hobbit + A Wizard of Earthsea - A Wizard of Earthsea + The Hobbit { + it("renders an accessible select with all sort options", () => { + render(); + + 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(); + + fireEvent.change(screen.getByRole("combobox", { name: "Sort by" }), { + target: { value: "newest" }, + }); + + expect(onSortChange).toHaveBeenCalledWith("newest"); + }); +}); diff --git a/src/components/SortSelect.tsx b/src/components/SortSelect.tsx new file mode 100644 index 0000000..bed1c05 --- /dev/null +++ b/src/components/SortSelect.tsx @@ -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 ( + + Sort by + onSortChange(event.target.value as SortOption)} + > + {SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + ); +} diff --git a/src/hooks/usePreferences.ts b/src/hooks/usePreferences.ts index e753872..bc079e9 100644 --- a/src/hooks/usePreferences.ts +++ b/src/hooks/usePreferences.ts @@ -1,7 +1,9 @@ import { useState } from "react"; import { DEFAULT_SUBJECT, SUBJECT_OPTIONS } from "../constants/subjects"; +import type { SortOption } from "../types"; const PREFERENCES_KEY = "t19.preferences"; +export const DEFAULT_SORT: SortOption = "title-asc"; interface Preferences { [key: string]: unknown; @@ -29,11 +31,20 @@ function isSupportedSubject(subject: unknown): subject is string { return typeof subject === "string" && SUBJECT_OPTIONS.some((option) => option.value === subject); } +function isSortOption(sort: unknown): sort is SortOption { + return sort === "title-asc" || sort === "title-desc" || sort === "newest" || sort === "oldest"; +} + function getStoredSubject(): string { const { subject } = readPreferences(); return isSupportedSubject(subject) ? subject : DEFAULT_SUBJECT; } +function getStoredSort(): SortOption { + const { sort } = readPreferences(); + return isSortOption(sort) ? sort : DEFAULT_SORT; +} + function saveSubject(subject: string) { try { sessionStorage.setItem(PREFERENCES_KEY, JSON.stringify({ ...readPreferences(), subject })); @@ -42,13 +53,27 @@ function saveSubject(subject: string) { } } +function saveSort(sort: SortOption) { + try { + sessionStorage.setItem(PREFERENCES_KEY, JSON.stringify({ ...readPreferences(), sort })); + } catch { + return; + } +} + export function usePreferences() { const [subject, setStoredSubject] = useState(getStoredSubject); + const [sort, setStoredSort] = useState(getStoredSort); function setSubject(nextSubject: string) { setStoredSubject(nextSubject); saveSubject(nextSubject); } - return { subject, setSubject }; + function setSort(nextSort: SortOption) { + setStoredSort(nextSort); + saveSort(nextSort); + } + + return { subject, setSubject, sort, setSort }; } diff --git a/src/types.ts b/src/types.ts index be55fa3..678843e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,3 +23,5 @@ export interface OpenLibrarySearchResponse { } export type CoverSize = "S" | "M" | "L"; + +export type SortOption = "title-asc" | "title-desc" | "newest" | "oldest"; diff --git a/src/utils/sortBooks.test.ts b/src/utils/sortBooks.test.ts new file mode 100644 index 0000000..941ad40 --- /dev/null +++ b/src/utils/sortBooks.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { fixtureDocs } from "../test/handlers"; +import { type Book } from "../types"; +import { sortBooks } from "./sortBooks"; + +const books: Book[] = fixtureDocs.map((doc) => ({ + key: doc.key, + title: doc.title, + authors: doc.author_name ?? [], + firstPublishYear: doc.first_publish_year ?? null, + coverId: doc.cover_i ?? null, + subjects: doc.subject ?? [], +})); + +const titles = (sort: Parameters[1]) => + sortBooks(books, sort).map((book) => book.title); + +describe("sortBooks", () => { + it.each([ + ["title-asc", ["A Wizard of Earthsea", "The Hobbit", "The Left Hand of Darkness"]], + ["title-desc", ["The Left Hand of Darkness", "The Hobbit", "A Wizard of Earthsea"]], + ["newest", ["The Left Hand of Darkness", "A Wizard of Earthsea", "The Hobbit"]], + ["oldest", ["The Hobbit", "A Wizard of Earthsea", "The Left Hand of Darkness"]], + ] as const)("sorts books with %s", (sort, expected) => { + expect(titles(sort)).toEqual(expected); + }); + + it("does not mutate the original books array", () => { + const originalBooks = [...books]; + + sortBooks(books, "title-desc"); + + expect(books).toEqual(originalBooks); + }); +}); diff --git a/src/utils/sortBooks.ts b/src/utils/sortBooks.ts new file mode 100644 index 0000000..e28407c --- /dev/null +++ b/src/utils/sortBooks.ts @@ -0,0 +1,38 @@ +import type { Book, SortOption } from "../types"; + +export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: "title-asc", label: "Title A-Z" }, + { value: "title-desc", label: "Title Z-A" }, + { value: "newest", label: "Newest first" }, + { value: "oldest", label: "Oldest first" }, +]; + +export function sortBooks(books: Book[], sort: SortOption): Book[] { + return books + .map((book, index) => ({ book, index })) + .sort((left, right) => { + const comparison = compareBooks(left.book, right.book, sort); + return comparison === 0 ? left.index - right.index : comparison; + }) + .map(({ book }) => book); +} + +function compareBooks(left: Book, right: Book, sort: SortOption): number { + if (sort === "title-asc" || sort === "title-desc") { + const comparison = left.title.localeCompare(right.title); + return sort === "title-asc" ? comparison : -comparison; + } + + if (left.firstPublishYear === null && right.firstPublishYear === null) { + return 0; + } + if (left.firstPublishYear === null) { + return 1; + } + if (right.firstPublishYear === null) { + return -1; + } + + const comparison = left.firstPublishYear - right.firstPublishYear; + return sort === "oldest" ? comparison : -comparison; +}
Loading books…
Could not load books. Please try refreshing the page.
+ No cover available +
- J. R. R. Tolkien + Ursula K. Le Guin
First published: - 1937 + 1968