feat: add book detail with progress updates and delete
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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 }
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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 } })
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
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 { 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)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoadError(null)
|
||||
api<Book>('GET', `/books/${id}`).then(
|
||||
(found) => {
|
||||
if (cancelled) return
|
||||
setBook(found)
|
||||
setPage(String(found.current_page))
|
||||
},
|
||||
(err) => {
|
||||
if (!cancelled) setLoadError({ notFound: err instanceof ApiError && err.status === 404, message: errorMessage(err) })
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
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">
|
||||
← 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)}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!book) return <p className="text-slate-500">Loading…</p>
|
||||
const current = book
|
||||
|
||||
async function saveProgress(currentPage: number) {
|
||||
setProgressError(null)
|
||||
try {
|
||||
const updated = await api<Book>('PATCH', `/books/${current.id}`, { current_page: currentPage })
|
||||
setBook(updated)
|
||||
setPage(String(updated.current_page))
|
||||
} catch (err) {
|
||||
setProgressError(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBook() {
|
||||
setDeleteError(null)
|
||||
try {
|
||||
await api('DELETE', `/books/${current.id}`)
|
||||
navigate('/books', { replace: true })
|
||||
} catch (err) {
|
||||
setDeleteError(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
function submitProgress(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
void saveProgress(Number(page))
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="space-y-6">
|
||||
<Link to="/books" className="text-sm text-indigo-700 underline">
|
||||
← Back to library
|
||||
</Link>
|
||||
<header className={card}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{book.title}</h1>
|
||||
<p className="text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
|
||||
</div>
|
||||
<StatusBadge status={book.status} />
|
||||
</div>
|
||||
<ProgressBar book={book} />
|
||||
<form onSubmit={submitProgress} className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<label>
|
||||
<span className={labelText}>Current page</span>
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} w-32`}
|
||||
min={0}
|
||||
max={book.total_pages}
|
||||
value={page}
|
||||
onChange={(e) => setPage(e.target.value)}
|
||||
required
|
||||
aria-invalid={progressError ? true : undefined}
|
||||
aria-describedby={progressError ? 'progress-error' : undefined}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className={primaryButton}>
|
||||
Save progress
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={secondaryButton}
|
||||
disabled={book.current_page === book.total_pages}
|
||||
onClick={() => void saveProgress(book.total_pages)}
|
||||
>
|
||||
Mark finished
|
||||
</button>
|
||||
</form>
|
||||
{progressError && (
|
||||
<p id="progress-error" role="alert" className="mt-2 text-sm text-red-700">
|
||||
{progressError}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
|
||||
Edit
|
||||
</Link>
|
||||
<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`}>
|
||||
<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()}>
|
||||
Yes, delete
|
||||
</button>
|
||||
<button type="button" className={secondaryButton} onClick={() => setConfirmingDelete(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{deleteError && (
|
||||
<p role="alert" className="mt-2 text-sm text-red-700">
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
</dialog>
|
||||
)}
|
||||
</header>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user