style: drop redundant comments and apply formatting
Frontend now uses double quotes and semicolons. No behaviour change.
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user