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
45 changes: 42 additions & 3 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -37,6 +47,35 @@ describe("App", () => {
expect(await screen.findByText(/No books found/)).toBeInTheDocument();
});

it("fetches once more when the subject changes", async () => {
withProviders(<App />);
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(<App />);

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(<App />);

expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("fantasy");
});

it("matches the snapshot", async () => {
const { container } = withProviders(<App />);
await screen.findByRole("heading", { level: 2 });
Expand Down
10 changes: 6 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -15,10 +16,11 @@ function App() {
<p>Browse reading material from OpenLibrary, one book at a time.</p>
</header>
<main className="app-main">
<SubjectFilter subject={subject} onSubjectChange={setSubject} />
{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>
<p>No books found for the subject “{subject}”.</p>
)}
{current && (
<section className="book-viewer" aria-label="Book viewer">
Expand Down
40 changes: 40 additions & 0 deletions src/__snapshots__/App.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,46 @@ exports[`App > matches the snapshot 1`] = `
<main
class="app-main"
>
<label
class="subject-filter"
for="subject-filter"
>
Subject
<select
id="subject-filter"
>
<option
value="fantasy"
>
Fantasy
</option>
<option
value="science_fiction"
>
Science Fiction
</option>
<option
value="mystery"
>
Mystery
</option>
<option
value="history"
>
History
</option>
<option
value="poetry"
>
Poetry
</option>
<option
value="philosophy"
>
Philosophy
</option>
</select>
</label>
<section
aria-label="Book viewer"
class="book-viewer"
Expand Down
25 changes: 25 additions & 0 deletions src/components/SubjectFilter.css
Original file line number Diff line number Diff line change
@@ -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;
}
29 changes: 29 additions & 0 deletions src/components/SubjectFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SUBJECT_OPTIONS } from "../constants/subjects";
import { SubjectFilter } from "./SubjectFilter";

describe("SubjectFilter", () => {
it("renders every supported subject", () => {
render(<SubjectFilter subject="fantasy" onSubjectChange={vi.fn()} />);

expect(screen.getAllByRole("option")).toHaveLength(SUBJECT_OPTIONS.length);
});

it("reports the selected subject", () => {
const onSubjectChange = vi.fn();
render(<SubjectFilter subject="fantasy" onSubjectChange={onSubjectChange} />);

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

expect(onSubjectChange).toHaveBeenCalledWith("mystery");
});

it("matches the rendered snapshot", () => {
const { container } = render(<SubjectFilter subject="fantasy" onSubjectChange={vi.fn()} />);

expect(container).toMatchSnapshot();
});
});
26 changes: 26 additions & 0 deletions src/components/SubjectFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<label className="subject-filter" htmlFor="subject-filter">
Subject
<select
id="subject-filter"
value={subject}
onChange={(event) => onSubjectChange(event.target.value)}
>
{SUBJECT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
46 changes: 46 additions & 0 deletions src/components/__snapshots__/SubjectFilter.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`SubjectFilter > matches the rendered snapshot 1`] = `
<div>
<label
class="subject-filter"
for="subject-filter"
>
Subject
<select
id="subject-filter"
>
<option
value="fantasy"
>
Fantasy
</option>
<option
value="science_fiction"
>
Science Fiction
</option>
<option
value="mystery"
>
Mystery
</option>
<option
value="history"
>
History
</option>
<option
value="poetry"
>
Poetry
</option>
<option
value="philosophy"
>
Philosophy
</option>
</select>
</label>
</div>
`;
15 changes: 15 additions & 0 deletions src/constants/subjects.ts
Original file line number Diff line number Diff line change
@@ -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" },
];
54 changes: 54 additions & 0 deletions src/hooks/usePreferences.ts
Original file line number Diff line number Diff line change
@@ -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 };
}