From ba5982251e80058dfafa7e4eb1b6b522e83c569b Mon Sep 17 00:00:00 2001 From: Robert Andreas Kyllo Date: Wed, 9 Sep 2026 14:12:09 +0200 Subject: [PATCH 1/2] feat: add subject selection and preferences (#19) --- src/App.tsx | 10 +++--- src/components/SubjectFilter.css | 25 +++++++++++++++ src/components/SubjectFilter.tsx | 26 +++++++++++++++ src/constants/subjects.ts | 15 +++++++++ src/hooks/usePreferences.ts | 54 ++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/components/SubjectFilter.css create mode 100644 src/components/SubjectFilter.tsx create mode 100644 src/constants/subjects.ts create mode 100644 src/hooks/usePreferences.ts 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/components/SubjectFilter.css b/src/components/SubjectFilter.css new file mode 100644 index 0000000..5baffe6 --- /dev/null +++ b/src/components/SubjectFilter.css @@ -0,0 +1,25 @@ +.subject-filter { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 20rem; + margin: 0 auto 1.5rem; + color: #243b53; + font-weight: 600; +} + +.subject-filter 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; +} + +.subject-filter select:focus-visible { + outline: 0.1875rem solid #2c3e50; + outline-offset: 0.125rem; +} 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/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 }; +} From c15b3dc5cd760ff2188999c11eaf4fa1afd2bb5f Mon Sep 17 00:00:00 2001 From: Robert Andreas Kyllo Date: Wed, 9 Sep 2026 14:12:28 +0200 Subject: [PATCH 2/2] test: cover subject preferences and filtering (#19) --- src/App.test.tsx | 45 ++++++++++++++++-- src/__snapshots__/App.test.tsx.snap | 40 ++++++++++++++++ src/components/SubjectFilter.test.tsx | 29 ++++++++++++ .../__snapshots__/SubjectFilter.test.tsx.snap | 46 +++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/components/SubjectFilter.test.tsx create mode 100644 src/components/__snapshots__/SubjectFilter.test.tsx.snap 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/__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/__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`] = ` +
+ +
+`;