diff --git a/src/App.test.tsx b/src/App.test.tsx
index b161d68..a613eef 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,12 +1,22 @@
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it } from "vitest";
import { http, HttpResponse, delay } from "msw";
-import { screen } from "@testing-library/react";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
import App from "./App";
import { withProviders } from "./test/utils";
import { server } from "./test/server";
-import { emptySearchHandler, failingSearchHandler } from "./test/handlers";
+import {
+ emptySearchHandler,
+ failingSearchHandler,
+ getSearchRequestCount,
+ resetSearchRequestCount,
+} from "./test/handlers";
describe("App", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ resetSearchRequestCount();
+ });
+
it("shows a loading indicator while the request is in flight", async () => {
server.use(
http.get("https://openlibrary.org/search.json", async () => {
@@ -37,6 +47,35 @@ describe("App", () => {
expect(await screen.findByText(/No books found/)).toBeInTheDocument();
});
+ it("fetches once more when the subject changes", async () => {
+ withProviders();
+ await screen.findByRole("heading", { name: "The Hobbit" });
+
+ fireEvent.change(screen.getByRole("combobox", { name: "Subject" }), {
+ target: { value: "mystery" },
+ });
+
+ await waitFor(() => expect(getSearchRequestCount()).toBe(2));
+ expect(JSON.parse(sessionStorage.getItem("t19.preferences") ?? "{}")).toMatchObject({
+ subject: "mystery",
+ });
+ });
+
+ it("restores the selected subject from session storage", async () => {
+ sessionStorage.setItem("t19.preferences", JSON.stringify({ subject: "mystery" }));
+ withProviders();
+
+ expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("mystery");
+ await waitFor(() => expect(getSearchRequestCount()).toBe(1));
+ });
+
+ 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");
+ });
+
it("matches the snapshot", async () => {
const { container } = withProviders();
await screen.findByRole("heading", { level: 2 });
diff --git a/src/App.tsx b/src/App.tsx
index e8394b0..f566c60 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,11 +1,12 @@
import { BookCard } from "./components/BookCard";
+import { SubjectFilter } from "./components/SubjectFilter";
import { useBooks } from "./hooks/useBooks";
+import { usePreferences } from "./hooks/usePreferences";
import "./App.css";
-const SUBJECT = "fantasy";
-
function App() {
- const { books, isLoading, isError } = useBooks(SUBJECT);
+ const { subject, setSubject } = usePreferences();
+ const { books, isLoading, isError } = useBooks(subject);
const current = books[0];
return (
@@ -15,10 +16,11 @@ function App() {
Browse reading material from OpenLibrary, one book at a time.
+
{isLoading && Loading books…
}
{isError && Could not load books. Please try refreshing the page.
}
{!isLoading && !isError && books.length === 0 && (
- No books found for the subject “{SUBJECT}”.
+ No books found for the subject “{subject}”.
)}
{current && (
diff --git a/src/__snapshots__/App.test.tsx.snap b/src/__snapshots__/App.test.tsx.snap
index 169f307..4c98b2e 100644
--- a/src/__snapshots__/App.test.tsx.snap
+++ b/src/__snapshots__/App.test.tsx.snap
@@ -18,6 +18,46 @@ exports[`App > matches the snapshot 1`] = `
+
{
+ it("renders every supported subject", () => {
+ render();
+
+ expect(screen.getAllByRole("option")).toHaveLength(SUBJECT_OPTIONS.length);
+ });
+
+ it("reports the selected subject", () => {
+ const onSubjectChange = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByRole("combobox", { name: "Subject" }), {
+ target: { value: "mystery" },
+ });
+
+ expect(onSubjectChange).toHaveBeenCalledWith("mystery");
+ });
+
+ it("matches the rendered snapshot", () => {
+ const { container } = render();
+
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/components/SubjectFilter.tsx b/src/components/SubjectFilter.tsx
new file mode 100644
index 0000000..6ee4bce
--- /dev/null
+++ b/src/components/SubjectFilter.tsx
@@ -0,0 +1,26 @@
+import { SUBJECT_OPTIONS } from "../constants/subjects";
+import "./SubjectFilter.css";
+
+interface SubjectFilterProps {
+ subject: string;
+ onSubjectChange: (subject: string) => void;
+}
+
+export function SubjectFilter({ subject, onSubjectChange }: SubjectFilterProps) {
+ return (
+
+ );
+}
diff --git a/src/components/__snapshots__/SubjectFilter.test.tsx.snap b/src/components/__snapshots__/SubjectFilter.test.tsx.snap
new file mode 100644
index 0000000..1121384
--- /dev/null
+++ b/src/components/__snapshots__/SubjectFilter.test.tsx.snap
@@ -0,0 +1,46 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SubjectFilter > matches the rendered snapshot 1`] = `
+
+
+
+`;
diff --git a/src/constants/subjects.ts b/src/constants/subjects.ts
new file mode 100644
index 0000000..817e377
--- /dev/null
+++ b/src/constants/subjects.ts
@@ -0,0 +1,15 @@
+export interface SubjectOption {
+ value: string;
+ label: string;
+}
+
+export const DEFAULT_SUBJECT = "fantasy";
+
+export const SUBJECT_OPTIONS: SubjectOption[] = [
+ { value: "fantasy", label: "Fantasy" },
+ { value: "science_fiction", label: "Science Fiction" },
+ { value: "mystery", label: "Mystery" },
+ { value: "history", label: "History" },
+ { value: "poetry", label: "Poetry" },
+ { value: "philosophy", label: "Philosophy" },
+];
diff --git a/src/hooks/usePreferences.ts b/src/hooks/usePreferences.ts
new file mode 100644
index 0000000..e753872
--- /dev/null
+++ b/src/hooks/usePreferences.ts
@@ -0,0 +1,54 @@
+import { useState } from "react";
+import { DEFAULT_SUBJECT, SUBJECT_OPTIONS } from "../constants/subjects";
+
+const PREFERENCES_KEY = "t19.preferences";
+
+interface Preferences {
+ [key: string]: unknown;
+}
+
+function isPreferences(value: unknown): value is Preferences {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function readPreferences(): Preferences {
+ try {
+ const storedPreferences = sessionStorage.getItem(PREFERENCES_KEY);
+ if (storedPreferences === null) {
+ return {};
+ }
+
+ const preferences: unknown = JSON.parse(storedPreferences);
+ return isPreferences(preferences) ? preferences : {};
+ } catch {
+ return {};
+ }
+}
+
+function isSupportedSubject(subject: unknown): subject is string {
+ return typeof subject === "string" && SUBJECT_OPTIONS.some((option) => option.value === subject);
+}
+
+function getStoredSubject(): string {
+ const { subject } = readPreferences();
+ return isSupportedSubject(subject) ? subject : DEFAULT_SUBJECT;
+}
+
+function saveSubject(subject: string) {
+ try {
+ sessionStorage.setItem(PREFERENCES_KEY, JSON.stringify({ ...readPreferences(), subject }));
+ } catch {
+ return;
+ }
+}
+
+export function usePreferences() {
+ const [subject, setStoredSubject] = useState(getStoredSubject);
+
+ function setSubject(nextSubject: string) {
+ setStoredSubject(nextSubject);
+ saveSubject(nextSubject);
+ }
+
+ return { subject, setSubject };
+}