style: drop redundant comments and apply formatting

Frontend now uses double quotes and semicolons. No behaviour change.
This commit is contained in:
2026-10-02 15:06:56 -05:00
parent d9b824851a
commit 9d7b0e805c
30 changed files with 1012 additions and 679 deletions
+98 -58
View File
@@ -1,72 +1,112 @@
import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { Book } from '../api'
import { dune, renderApp, signedIn } from '../test/helpers'
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Book } from "../api";
import { dune, renderApp, signedIn } from "../test/helpers";
function withProgress(current_page: number): Book {
const status = current_page === 0 ? 'not_started' : current_page === dune.total_pages ? 'finished' : 'reading'
return { ...dune, current_page, status }
const status =
current_page === 0
? "not_started"
: current_page === dune.total_pages
? "finished"
: "reading";
return { ...dune, current_page, status };
}
describe('BookDetail', () => {
it('updates progress and reflects it in the bar', async () => {
describe("BookDetail", () => {
it("updates progress and reflects it in the bar", async () => {
const calls = signedIn({
'GET /api/books/7': () => [200, dune],
'PATCH /api/books/7': ({ body }) => [200, withProgress((body as { current_page: number }).current_page)],
})
const user = renderApp('/books/7')
expect(await screen.findByText('142 / 380 (37%)')).toBeInTheDocument()
"GET /api/books/7": () => [200, dune],
"PATCH /api/books/7": ({ body }) => [
200,
withProgress((body as { current_page: number }).current_page),
],
});
const user = renderApp("/books/7");
expect(await screen.findByText("142 / 380 (37%)")).toBeInTheDocument();
const input = screen.getByLabelText('Current page')
await user.clear(input)
await user.type(input, '200')
await user.click(screen.getByRole('button', { name: 'Save progress' }))
const input = screen.getByLabelText("Current page");
await user.clear(input);
await user.type(input, "200");
await user.click(screen.getByRole("button", { name: "Save progress" }));
expect(await screen.findByText('200 / 380 (53%)')).toBeInTheDocument()
expect(calls).toContainEqual({ method: 'PATCH', path: '/api/books/7', body: { current_page: 200 } })
})
expect(await screen.findByText("200 / 380 (53%)")).toBeInTheDocument();
expect(calls).toContainEqual({
method: "PATCH",
path: "/api/books/7",
body: { current_page: 200 },
});
});
it('marks a book finished', async () => {
it("marks a book finished", async () => {
signedIn({
'GET /api/books/7': () => [200, dune],
'PATCH /api/books/7': () => [200, withProgress(380)],
})
const user = renderApp('/books/7')
await user.click(await screen.findByRole('button', { name: 'Mark finished' }))
expect(await screen.findByText('380 / 380 (100%)')).toBeInTheDocument()
expect(screen.getByText('Finished')).toBeInTheDocument()
})
"GET /api/books/7": () => [200, dune],
"PATCH /api/books/7": () => [200, withProgress(380)],
});
const user = renderApp("/books/7");
await user.click(
await screen.findByRole("button", { name: "Mark finished" }),
);
expect(await screen.findByText("380 / 380 (100%)")).toBeInTheDocument();
expect(screen.getByText("Finished")).toBeInTheDocument();
});
it('shows the server message when a progress update is rejected', async () => {
it("shows the server message when a progress update is rejected", async () => {
signedIn({
'GET /api/books/7': () => [200, dune],
'PATCH /api/books/7': () => [400, { error: 'current_page must be between 0 and 100000', field: 'current_page' }],
})
const user = renderApp('/books/7')
await user.click(await screen.findByRole('button', { name: 'Save progress' }))
// findByText, not findByRole('alert'): once Task 11 mounts the journal, the page may show more than one alert.
expect(await screen.findByText('current_page must be between 0 and 100000')).toHaveAttribute('role', 'alert')
expect(screen.getByLabelText('Current page')).toHaveAttribute('aria-invalid', 'true')
})
"GET /api/books/7": () => [200, dune],
"PATCH /api/books/7": () => [
400,
{
error: "current_page must be between 0 and 100000",
field: "current_page",
},
],
});
const user = renderApp("/books/7");
await user.click(
await screen.findByRole("button", { name: "Save progress" }),
);
expect(
await screen.findByText("current_page must be between 0 and 100000"),
).toHaveAttribute("role", "alert");
expect(screen.getByLabelText("Current page")).toHaveAttribute(
"aria-invalid",
"true",
);
});
it('shows not found for a missing or foreign book', async () => {
signedIn({ 'GET /api/books/999': () => [404, { error: 'Book not found' }] })
renderApp('/books/999')
expect(await screen.findByRole('heading', { name: 'Book not found' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /back to library/i })).toHaveAttribute('href', '/books')
})
it("shows not found for a missing or foreign book", async () => {
signedIn({
"GET /api/books/999": () => [404, { error: "Book not found" }],
});
renderApp("/books/999");
expect(
await screen.findByRole("heading", { name: "Book not found" }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /back to library/i }),
).toHaveAttribute("href", "/books");
});
it('deletes after confirmation and returns to the list', async () => {
it("deletes after confirmation and returns to the list", async () => {
const calls = signedIn({
'GET /api/books/7': () => [200, dune],
'DELETE /api/books/7': () => [204],
'GET /api/books': () => [200, []],
})
const user = renderApp('/books/7')
await user.click(await screen.findByRole('button', { name: 'Delete book' }))
expect(screen.getByRole('dialog')).toHaveTextContent('Delete “Dune”?')
await user.click(screen.getByRole('button', { name: 'Yes, delete' }))
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
await waitFor(() => expect(calls.some((c) => c.method === 'DELETE' && c.path === '/api/books/7')).toBe(true))
})
})
"GET /api/books/7": () => [200, dune],
"DELETE /api/books/7": () => [204],
"GET /api/books": () => [200, []],
});
const user = renderApp("/books/7");
await user.click(
await screen.findByRole("button", { name: "Delete book" }),
);
expect(screen.getByRole("dialog")).toHaveTextContent("Delete “Dune”?");
await user.click(screen.getByRole("button", { name: "Yes, delete" }));
expect(
await screen.findByText("No books yet. Add your first one."),
).toBeInTheDocument();
await waitFor(() =>
expect(
calls.some((c) => c.method === "DELETE" && c.path === "/api/books/7"),
).toBe(true),
);
});
});
+97 -51
View File
@@ -1,86 +1,109 @@
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router'
import { api, ApiError, errorMessage, type Book } from '../api'
import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
import { NotesJournal } from '../notes/NotesJournal'
import { ProgressBar, StatusBadge } from './Progress'
import { useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { api, ApiError, errorMessage, type Book } from "../api";
import {
card,
dangerButton,
inputClass,
labelText,
primaryButton,
secondaryButton,
} from "../ui";
import { NotesJournal } from "../notes/NotesJournal";
import { ProgressBar, StatusBadge } from "./Progress";
export function BookDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [book, setBook] = useState<Book | null>(null)
const [loadError, setLoadError] = useState<{ notFound: boolean; message: string } | null>(null)
const [attempt, setAttempt] = useState(0)
const [page, setPage] = useState('')
const [progressError, setProgressError] = useState<string | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const { id } = useParams();
const navigate = useNavigate();
const [book, setBook] = useState<Book | null>(null);
const [loadError, setLoadError] = useState<{
notFound: boolean;
message: string;
} | null>(null);
const [attempt, setAttempt] = useState(0);
const [page, setPage] = useState("");
const [progressError, setProgressError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false
setLoadError(null)
api<Book>('GET', `/books/${id}`).then(
let cancelled = false;
setLoadError(null);
api<Book>("GET", `/books/${id}`).then(
(found) => {
if (cancelled) return
setBook(found)
setPage(String(found.current_page))
if (cancelled) return;
setBook(found);
setPage(String(found.current_page));
},
(err) => {
if (!cancelled) setLoadError({ notFound: err instanceof ApiError && err.status === 404, message: errorMessage(err) })
if (!cancelled)
setLoadError({
notFound: err instanceof ApiError && err.status === 404,
message: errorMessage(err),
});
},
)
);
return () => {
cancelled = true
}
}, [id, attempt])
cancelled = true;
};
}, [id, attempt]);
if (loadError?.notFound) {
return (
<div className={card}>
<h1 className="text-xl font-semibold">Book not found</h1>
<Link to="/books" className="mt-2 inline-block text-indigo-700 underline">
<Link
to="/books"
className="mt-2 inline-block text-indigo-700 underline"
>
← Back to library
</Link>
</div>
)
);
}
if (loadError) {
return (
<div role="alert" className="flex items-center gap-3 text-red-700">
<span>{loadError.message}</span>
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry
</button>
</div>
)
);
}
if (!book) return <p className="text-slate-500">Loading…</p>
const current = book
if (!book) return <p className="text-slate-500">Loading…</p>;
const current = book;
async function saveProgress(currentPage: number) {
setProgressError(null)
setProgressError(null);
try {
const updated = await api<Book>('PATCH', `/books/${current.id}`, { current_page: currentPage })
setBook(updated)
setPage(String(updated.current_page))
const updated = await api<Book>("PATCH", `/books/${current.id}`, {
current_page: currentPage,
});
setBook(updated);
setPage(String(updated.current_page));
} catch (err) {
setProgressError(errorMessage(err))
setProgressError(errorMessage(err));
}
}
async function deleteBook() {
setDeleteError(null)
setDeleteError(null);
try {
await api('DELETE', `/books/${current.id}`)
navigate('/books', { replace: true })
await api("DELETE", `/books/${current.id}`);
navigate("/books", { replace: true });
} catch (err) {
setDeleteError(errorMessage(err))
setDeleteError(errorMessage(err));
}
}
function submitProgress(event: FormEvent) {
event.preventDefault()
void saveProgress(Number(page))
event.preventDefault();
void saveProgress(Number(page));
}
return (
@@ -97,7 +120,10 @@ export function BookDetail() {
<StatusBadge status={book.status} />
</div>
<ProgressBar book={book} />
<form onSubmit={submitProgress} className="mt-4 flex flex-wrap items-end gap-2">
<form
onSubmit={submitProgress}
className="mt-4 flex flex-wrap items-end gap-2"
>
<label>
<span className={labelText}>Current page</span>
<input
@@ -109,7 +135,7 @@ export function BookDetail() {
onChange={(e) => setPage(e.target.value)}
required
aria-invalid={progressError ? true : undefined}
aria-describedby={progressError ? 'progress-error' : undefined}
aria-describedby={progressError ? "progress-error" : undefined}
/>
</label>
<button type="submit" className={primaryButton}>
@@ -125,7 +151,11 @@ export function BookDetail() {
</button>
</form>
{progressError && (
<p id="progress-error" role="alert" className="mt-2 text-sm text-red-700">
<p
id="progress-error"
role="alert"
className="mt-2 text-sm text-red-700"
>
{progressError}
</p>
)}
@@ -133,18 +163,34 @@ export function BookDetail() {
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
Edit
</Link>
<button type="button" className={dangerButton} onClick={() => setConfirmingDelete(true)}>
<button
type="button"
className={dangerButton}
onClick={() => setConfirmingDelete(true)}
>
Delete book
</button>
</div>
{confirmingDelete && (
<dialog open aria-labelledby="delete-prompt" className={`relative mt-4 ${card} ring-2 ring-red-200`}>
<dialog
open
aria-labelledby="delete-prompt"
className={`relative mt-4 ${card} ring-2 ring-red-200`}
>
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
<div className="mt-3 flex gap-2">
<button type="button" className={dangerButton} onClick={() => void deleteBook()}>
<button
type="button"
className={dangerButton}
onClick={() => void deleteBook()}
>
Yes, delete
</button>
<button type="button" className={secondaryButton} onClick={() => setConfirmingDelete(false)}>
<button
type="button"
className={secondaryButton}
onClick={() => setConfirmingDelete(false)}
>
Cancel
</button>
</div>
@@ -158,5 +204,5 @@ export function BookDetail() {
</header>
<NotesJournal bookId={book.id} />
</article>
)
);
}
+99 -68
View File
@@ -1,76 +1,107 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { dune, renderApp, signedIn } from '../test/helpers'
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { dune, renderApp, signedIn } from "../test/helpers";
const fresh = { ...dune, current_page: 0, status: 'not_started' as const }
const fresh = { ...dune, current_page: 0, status: "not_started" as const };
describe('BookForm', () => {
it('adds a book and opens it', async () => {
describe("BookForm", () => {
it("adds a book and opens it", async () => {
const calls = signedIn({
'POST /api/books': () => [201, fresh],
'GET /api/books/7': () => [200, fresh],
})
const user = renderApp('/books/new')
await screen.findByRole('option', { name: 'Science Fiction' })
await user.type(screen.getByLabelText('Title'), 'Dune')
await user.type(screen.getByLabelText('Author'), 'Frank Herbert')
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
await user.type(screen.getByLabelText('Total pages'), '380')
await user.click(screen.getByRole('button', { name: 'Add book' }))
"POST /api/books": () => [201, fresh],
"GET /api/books/7": () => [200, fresh],
});
const user = renderApp("/books/new");
await screen.findByRole("option", { name: "Science Fiction" });
await user.type(screen.getByLabelText("Title"), "Dune");
await user.type(screen.getByLabelText("Author"), "Frank Herbert");
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
await user.type(screen.getByLabelText("Total pages"), "380");
await user.click(screen.getByRole("button", { name: "Add book" }));
expect(await screen.findByRole('heading', { name: 'Dune', level: 1 })).toBeInTheDocument()
expect(
await screen.findByRole("heading", { name: "Dune", level: 1 }),
).toBeInTheDocument();
expect(calls).toContainEqual({
method: 'POST',
path: '/api/books',
body: { title: 'Dune', author: 'Frank Herbert', genre_id: 5, total_pages: 380, current_page: 0 },
})
})
it('shows a server validation error against the field', async () => {
signedIn({ 'POST /api/books': () => [400, { error: 'Unknown genre_id; see GET /api/genres', field: 'genre_id' }] })
const user = renderApp('/books/new')
await screen.findByRole('option', { name: 'Science Fiction' })
await user.type(screen.getByLabelText('Title'), 'Dune')
await user.type(screen.getByLabelText('Author'), 'Frank Herbert')
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
await user.type(screen.getByLabelText('Total pages'), '380')
await user.click(screen.getByRole('button', { name: 'Add book' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Unknown genre_id')
expect(screen.getByLabelText('Genre')).toHaveAttribute('aria-invalid', 'true')
})
it('edits an existing book', async () => {
let saved = dune
const calls = signedIn({
'GET /api/books/7': () => [200, saved],
'PATCH /api/books/7': ({ body }) => {
saved = { ...saved, ...(body as object) }
return [200, saved]
method: "POST",
path: "/api/books",
body: {
title: "Dune",
author: "Frank Herbert",
genre_id: 5,
total_pages: 380,
current_page: 0,
},
})
const user = renderApp('/books/7/edit')
const title = await screen.findByDisplayValue('Dune')
await user.clear(title)
await user.type(title, 'Dune Messiah')
await user.click(screen.getByRole('button', { name: 'Save changes' }))
});
});
expect(await screen.findByRole('heading', { name: 'Dune Messiah', level: 1 })).toBeInTheDocument()
expect(calls).toContainEqual({
method: 'PATCH',
path: '/api/books/7',
body: { title: 'Dune Messiah', author: 'Frank Herbert', genre_id: 5, total_pages: 380, current_page: 142 },
})
})
it('retries a failed load and shows the prefilled form', async () => {
let attempts = 0
it("shows a server validation error against the field", async () => {
signedIn({
'GET /api/books/7': () => (++attempts === 1 ? [500, { error: 'Database unavailable' }] : [200, dune]),
})
const user = renderApp('/books/7/edit')
expect(await screen.findByRole('alert')).toHaveTextContent('Database unavailable')
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByLabelText('Title')).toHaveValue('Dune')
})
})
"POST /api/books": () => [
400,
{ error: "Unknown genre_id; see GET /api/genres", field: "genre_id" },
],
});
const user = renderApp("/books/new");
await screen.findByRole("option", { name: "Science Fiction" });
await user.type(screen.getByLabelText("Title"), "Dune");
await user.type(screen.getByLabelText("Author"), "Frank Herbert");
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
await user.type(screen.getByLabelText("Total pages"), "380");
await user.click(screen.getByRole("button", { name: "Add book" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Unknown genre_id",
);
expect(screen.getByLabelText("Genre")).toHaveAttribute(
"aria-invalid",
"true",
);
});
it("edits an existing book", async () => {
let saved = dune;
const calls = signedIn({
"GET /api/books/7": () => [200, saved],
"PATCH /api/books/7": ({ body }) => {
saved = { ...saved, ...(body as object) };
return [200, saved];
},
});
const user = renderApp("/books/7/edit");
const title = await screen.findByDisplayValue("Dune");
await user.clear(title);
await user.type(title, "Dune Messiah");
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }),
).toBeInTheDocument();
expect(calls).toContainEqual({
method: "PATCH",
path: "/api/books/7",
body: {
title: "Dune Messiah",
author: "Frank Herbert",
genre_id: 5,
total_pages: 380,
current_page: 142,
},
});
});
it("retries a failed load and shows the prefilled form", async () => {
let attempts = 0;
signedIn({
"GET /api/books/7": () =>
++attempts === 1
? [500, { error: "Database unavailable" }]
: [200, dune],
});
const user = renderApp("/books/7/edit");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Database unavailable",
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByLabelText("Title")).toHaveValue("Dune");
});
});
+117 -59
View File
@@ -1,81 +1,105 @@
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router'
import { api, ApiError, errorMessage, type Book } from '../api'
import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
import { useGenres } from './useGenres'
import { useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { api, ApiError, errorMessage, type Book } from "../api";
import {
card,
inputClass,
labelText,
primaryButton,
secondaryButton,
} from "../ui";
import { useGenres } from "./useGenres";
type Fields = { title: string; author: string; genre_id: string; total_pages: string; current_page: string }
type FormError = { message: string; field?: string }
type Fields = {
title: string;
author: string;
genre_id: string;
total_pages: string;
current_page: string;
};
type FormError = { message: string; field?: string };
const EMPTY: Fields = { title: '', author: '', genre_id: '', total_pages: '', current_page: '0' }
const EMPTY: Fields = {
title: "",
author: "",
genre_id: "",
total_pages: "",
current_page: "0",
};
export function BookForm() {
const { id } = useParams()
const editing = id !== undefined
const navigate = useNavigate()
const { genres, error: genresError } = useGenres()
const [fields, setFields] = useState<Fields>(EMPTY)
const [loading, setLoading] = useState(editing)
const [loadError, setLoadError] = useState<string | null>(null)
const [error, setError] = useState<FormError | null>(null)
const [busy, setBusy] = useState(false)
const [attempt, setAttempt] = useState(0)
const { id } = useParams();
const editing = id !== undefined;
const navigate = useNavigate();
const { genres, error: genresError } = useGenres();
const [fields, setFields] = useState<Fields>(EMPTY);
const [loading, setLoading] = useState(editing);
const [loadError, setLoadError] = useState<string | null>(null);
const [error, setError] = useState<FormError | null>(null);
const [busy, setBusy] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!editing) return
let cancelled = false
setLoadError(null)
setLoading(true)
api<Book>('GET', `/books/${id}`).then(
if (!editing) return;
let cancelled = false;
setLoadError(null);
setLoading(true);
api<Book>("GET", `/books/${id}`).then(
(book) => {
if (cancelled) return
if (cancelled) return;
setFields({
title: book.title,
author: book.author,
genre_id: String(book.genre.id),
total_pages: String(book.total_pages),
current_page: String(book.current_page),
})
setLoading(false)
});
setLoading(false);
},
(err) => {
if (cancelled) return
setLoadError(errorMessage(err))
setLoading(false)
if (cancelled) return;
setLoadError(errorMessage(err));
setLoading(false);
},
)
);
return () => {
cancelled = true
}
}, [editing, id, attempt])
cancelled = true;
};
}, [editing, id, attempt]);
function set(name: keyof Fields) {
return (event: { target: { value: string } }) => setFields((prev) => ({ ...prev, [name]: event.target.value }))
return (event: { target: { value: string } }) =>
setFields((prev) => ({ ...prev, [name]: event.target.value }));
}
function invalid(name: keyof Fields) {
return error?.field === name ? { 'aria-invalid': true, 'aria-describedby': 'form-error' } : {}
return error?.field === name
? { "aria-invalid": true, "aria-describedby": "form-error" }
: {};
}
async function submit(event: FormEvent) {
event.preventDefault()
setError(null)
setBusy(true)
event.preventDefault();
setError(null);
setBusy(true);
const payload = {
title: fields.title,
author: fields.author,
genre_id: Number(fields.genre_id),
total_pages: Number(fields.total_pages),
current_page: Number(fields.current_page),
}
};
try {
const saved = editing
? await api<Book>('PATCH', `/books/${id}`, payload)
: await api<Book>('POST', '/books', payload)
navigate(`/books/${saved.id}`)
? await api<Book>("PATCH", `/books/${id}`, payload)
: await api<Book>("POST", "/books", payload);
navigate(`/books/${saved.id}`);
} catch (err) {
setError({ message: errorMessage(err), field: err instanceof ApiError ? err.field : undefined })
setBusy(false)
setError({
message: errorMessage(err),
field: err instanceof ApiError ? err.field : undefined,
});
setBusy(false);
}
}
@@ -84,7 +108,11 @@ export function BookForm() {
<div role="alert" className={card}>
<p className="text-red-700">{loadError}</p>
<div className="mt-2 flex items-center gap-3">
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry
</button>
<Link to="/books" className="text-indigo-700 underline">
@@ -92,24 +120,46 @@ export function BookForm() {
</Link>
</div>
</div>
)
);
}
if (loading) return <p className="text-slate-500">Loading…</p>
if (loading) return <p className="text-slate-500">Loading…</p>;
return (
<form onSubmit={submit} className={`space-y-4 ${card}`}>
<h1 className="text-2xl font-semibold">{editing ? 'Edit book' : 'Add a book'}</h1>
<h1 className="text-2xl font-semibold">
{editing ? "Edit book" : "Add a book"}
</h1>
<label className="block">
<span className={labelText}>Title</span>
<input className={inputClass} value={fields.title} onChange={set('title')} maxLength={500} required {...invalid('title')} />
<input
className={inputClass}
value={fields.title}
onChange={set("title")}
maxLength={500}
required
{...invalid("title")}
/>
</label>
<label className="block">
<span className={labelText}>Author</span>
<input className={inputClass} value={fields.author} onChange={set('author')} maxLength={500} required {...invalid('author')} />
<input
className={inputClass}
value={fields.author}
onChange={set("author")}
maxLength={500}
required
{...invalid("author")}
/>
</label>
<label className="block">
<span className={labelText}>Genre</span>
<select className={inputClass} value={fields.genre_id} onChange={set('genre_id')} required {...invalid('genre_id')}>
<select
className={inputClass}
value={fields.genre_id}
onChange={set("genre_id")}
required
{...invalid("genre_id")}
>
<option value="">Choose a genre</option>
{genres.map((g) => (
<option key={g.id} value={String(g.id)}>
@@ -118,7 +168,12 @@ export function BookForm() {
))}
</select>
</label>
{genresError && <p role="alert" className="text-sm text-red-700">{`Could not load genres: ${genresError}`}</p>}
{genresError && (
<p
role="alert"
className="text-sm text-red-700"
>{`Could not load genres: ${genresError}`}</p>
)}
<div className="flex gap-4">
<label className="block">
<span className={labelText}>Total pages</span>
@@ -128,9 +183,9 @@ export function BookForm() {
min={1}
max={100000}
value={fields.total_pages}
onChange={set('total_pages')}
onChange={set("total_pages")}
required
{...invalid('total_pages')}
{...invalid("total_pages")}
/>
</label>
<label className="block">
@@ -141,9 +196,9 @@ export function BookForm() {
min={0}
max={fields.total_pages || undefined}
value={fields.current_page}
onChange={set('current_page')}
onChange={set("current_page")}
required
{...invalid('current_page')}
{...invalid("current_page")}
/>
</label>
</div>
@@ -154,12 +209,15 @@ export function BookForm() {
)}
<div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButton}>
{editing ? 'Save changes' : 'Add book'}
{editing ? "Save changes" : "Add book"}
</button>
<Link to={editing ? `/books/${id}` : '/books'} className={secondaryButton}>
<Link
to={editing ? `/books/${id}` : "/books"}
className={secondaryButton}
>
Cancel
</Link>
</div>
</form>
)
);
}
+80 -52
View File
@@ -1,58 +1,86 @@
import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { dune, renderApp, signedIn } from '../test/helpers'
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { dune, renderApp, signedIn } from "../test/helpers";
describe('BookList', () => {
it('shows each book with author, genre, status, and progress', async () => {
signedIn({ 'GET /api/books': () => [200, [dune]] })
renderApp('/books')
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
expect(screen.getByText('Frank Herbert · Science Fiction')).toBeInTheDocument()
expect(screen.getByText('Reading')).toBeInTheDocument()
expect(screen.getByText('142 / 380 (37%)')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Dune/ })).toHaveAttribute('href', '/books/7')
})
describe("BookList", () => {
it("shows each book with author, genre, status, and progress", async () => {
signedIn({ "GET /api/books": () => [200, [dune]] });
renderApp("/books");
expect(
await screen.findByRole("heading", { name: "Dune" }),
).toBeInTheDocument();
expect(
screen.getByText("Frank Herbert · Science Fiction"),
).toBeInTheDocument();
expect(screen.getByText("Reading")).toBeInTheDocument();
expect(screen.getByText("142 / 380 (37%)")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Dune/ })).toHaveAttribute(
"href",
"/books/7",
);
});
it('distinguishes an empty library from no matches', async () => {
signedIn({ 'GET /api/books': () => [200, []] })
const user = renderApp('/books')
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
await user.type(screen.getByLabelText('Search title or author'), 'zzz')
expect(await screen.findByText('No matches. Try a different search or genre.')).toBeInTheDocument()
})
it("distinguishes an empty library from no matches", async () => {
signedIn({ "GET /api/books": () => [200, []] });
const user = renderApp("/books");
expect(
await screen.findByText("No books yet. Add your first one."),
).toBeInTheDocument();
await user.type(screen.getByLabelText("Search title or author"), "zzz");
expect(
await screen.findByText("No matches. Try a different search or genre."),
).toBeInTheDocument();
});
it('debounces search into the URL and the request', async () => {
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
const user = renderApp('/books')
await screen.findByRole('heading', { name: 'Dune' })
await user.type(screen.getByLabelText('Search title or author'), 'herbert')
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?q=herbert'))
expect(calls.filter((c) => c.path.startsWith('/api/books?q=h')).map((c) => c.path)).toEqual(['/api/books?q=herbert'])
})
it("debounces search into the URL and the request", async () => {
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
const user = renderApp("/books");
await screen.findByRole("heading", { name: "Dune" });
await user.type(screen.getByLabelText("Search title or author"), "herbert");
await waitFor(() =>
expect(calls.map((c) => c.path)).toContain("/api/books?q=herbert"),
);
expect(
calls
.filter((c) => c.path.startsWith("/api/books?q=h"))
.map((c) => c.path),
).toEqual(["/api/books?q=herbert"]);
});
it('filters by genre', async () => {
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
const user = renderApp('/books')
await screen.findByRole('option', { name: 'Science Fiction' })
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?genre_id=5'))
})
it("filters by genre", async () => {
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
const user = renderApp("/books");
await screen.findByRole("option", { name: "Science Fiction" });
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
await waitFor(() =>
expect(calls.map((c) => c.path)).toContain("/api/books?genre_id=5"),
);
});
it('restores search and genre from the URL', async () => {
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
renderApp('/books?q=dune&genre=5')
await screen.findByRole('option', { name: 'Science Fiction' })
expect(screen.getByLabelText('Search title or author')).toHaveValue('dune')
expect(screen.getByLabelText('Genre')).toHaveValue('5')
expect(calls.map((c) => c.path)).toContain('/api/books?q=dune&genre_id=5')
})
it("restores search and genre from the URL", async () => {
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
renderApp("/books?q=dune&genre=5");
await screen.findByRole("option", { name: "Science Fiction" });
expect(screen.getByLabelText("Search title or author")).toHaveValue("dune");
expect(screen.getByLabelText("Genre")).toHaveValue("5");
expect(calls.map((c) => c.path)).toContain("/api/books?q=dune&genre_id=5");
});
it('offers retry when loading fails', async () => {
let attempts = 0
signedIn({ 'GET /api/books': () => (++attempts === 1 ? [500, { error: 'Unexpected server error' }] : [200, [dune]]) })
const user = renderApp('/books')
expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected server error')
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
})
})
it("offers retry when loading fails", async () => {
let attempts = 0;
signedIn({
"GET /api/books": () =>
++attempts === 1
? [500, { error: "Unexpected server error" }]
: [200, [dune]],
});
const user = renderApp("/books");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Unexpected server error",
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(
await screen.findByRole("heading", { name: "Dune" }),
).toBeInTheDocument();
});
});
+67 -44
View File
@@ -1,73 +1,90 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { api, errorMessage, type Book } from '../api'
import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
import { ProgressBar, StatusBadge } from './Progress'
import { useGenres } from './useGenres'
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { api, errorMessage, type Book } from "../api";
import {
card,
inputClass,
labelText,
primaryButton,
secondaryButton,
} from "../ui";
import { ProgressBar, StatusBadge } from "./Progress";
import { useGenres } from "./useGenres";
const SEARCH_DEBOUNCE_MS = 250
const SEARCH_DEBOUNCE_MS = 250;
export function BookList() {
const [params, setParams] = useSearchParams()
const q = params.get('q') ?? ''
const genre = params.get('genre') ?? ''
const [search, setSearch] = useState(q)
const [books, setBooks] = useState<Book[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [attempt, setAttempt] = useState(0)
const { genres, error: genresError } = useGenres()
const [params, setParams] = useSearchParams();
const q = params.get("q") ?? "";
const genre = params.get("genre") ?? "";
const [search, setSearch] = useState(q);
const [books, setBooks] = useState<Book[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const { genres, error: genresError } = useGenres();
function updateParam(key: string, value: string) {
setParams(
(prev) => {
const next = new URLSearchParams(prev)
if (value) next.set(key, value)
else next.delete(key)
return next
const next = new URLSearchParams(prev);
if (value) next.set(key, value);
else next.delete(key);
return next;
},
{ replace: true },
)
);
}
// Keep the input in sync when the URL changes from outside (back/forward navigation).
useEffect(() => setSearch(q), [q])
useEffect(() => setSearch(q), [q]);
useEffect(() => {
if (search === q) return
const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [search, q])
if (search === q) return;
const timer = setTimeout(
() => updateParam("q", search),
SEARCH_DEBOUNCE_MS,
);
return () => clearTimeout(timer);
}, [search, q]);
useEffect(() => {
let cancelled = false
const query = new URLSearchParams()
if (q.trim()) query.set('q', q.trim())
if (genre) query.set('genre_id', genre)
api<Book[]>('GET', `/books?${query}`).then(
let cancelled = false;
const query = new URLSearchParams();
if (q.trim()) query.set("q", q.trim());
if (genre) query.set("genre_id", genre);
api<Book[]>("GET", `/books?${query}`).then(
(found) => {
if (cancelled) return
setBooks(found)
setError(null)
if (cancelled) return;
setBooks(found);
setError(null);
},
(err) => {
if (!cancelled) setError(errorMessage(err))
if (!cancelled) setError(errorMessage(err));
},
)
);
return () => {
cancelled = true
}
}, [q, genre, attempt])
cancelled = true;
};
}, [q, genre, attempt]);
return (
<section>
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="min-w-48 grow">
<span className={labelText}>Search title or author</span>
<input type="search" className={inputClass} value={search} onChange={(e) => setSearch(e.target.value)} />
<input
type="search"
className={inputClass}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<label>
<span className={labelText}>Genre</span>
<select className={inputClass} value={genre} onChange={(e) => updateParam('genre', e.target.value)}>
<select
className={inputClass}
value={genre}
onChange={(e) => updateParam("genre", e.target.value)}
>
<option value="">All genres</option>
{genres.map((g) => (
<option key={g.id} value={String(g.id)}>
@@ -88,7 +105,11 @@ export function BookList() {
{error ? (
<div role="alert" className="flex items-center gap-3 text-red-700">
<span>{error}</span>
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry
</button>
</div>
@@ -96,7 +117,9 @@ export function BookList() {
<p className="text-slate-500">Loading…</p>
) : books.length === 0 ? (
<p className="text-slate-600">
{q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'}
{q.trim() || genre
? "No matches. Try a different search or genre."
: "No books yet. Add your first one."}
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
@@ -118,5 +141,5 @@ export function BookList() {
</ul>
)}
</section>
)
);
}
+11 -9
View File
@@ -1,21 +1,23 @@
import type { Book, ReadingStatus } from '../api'
import type { Book, ReadingStatus } from "../api";
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
not_started: { label: 'Not started', style: 'bg-slate-100 text-slate-700' },
reading: { label: 'Reading', style: 'bg-amber-100 text-amber-800' },
finished: { label: 'Finished', style: 'bg-emerald-100 text-emerald-800' },
}
not_started: { label: "Not started", style: "bg-slate-100 text-slate-700" },
reading: { label: "Reading", style: "bg-amber-100 text-amber-800" },
finished: { label: "Finished", style: "bg-emerald-100 text-emerald-800" },
};
export function StatusBadge({ status }: { status: ReadingStatus }) {
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}
>
{STATUS[status].label}
</span>
)
);
}
export function ProgressBar({ book }: { book: Book }) {
const percent = Math.round((book.current_page / book.total_pages) * 100)
const percent = Math.round((book.current_page / book.total_pages) * 100);
return (
<div className="mt-3">
<progress
@@ -26,5 +28,5 @@ export function ProgressBar({ book }: { book: Book }) {
/>
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
</div>
)
);
}
+9 -7
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import { api, errorMessage, type Genre } from '../api'
import { useEffect, useState } from "react";
import { api, errorMessage, type Genre } from "../api";
export function useGenres(): { genres: Genre[]; error: string | null } {
const [genres, setGenres] = useState<Genre[]>([])
const [error, setError] = useState<string | null>(null)
const [genres, setGenres] = useState<Genre[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api<Genre[]>('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err)))
}, [])
return { genres, error }
api<Genre[]>("GET", "/genres").then(setGenres, (err) =>
setError(errorMessage(err)),
);
}, []);
return { genres, error };
}