feat: add create and edit book form

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
2026-10-02 14:26:30 -05:00
parent da6c5c2de5
commit 18bc74f8f9
3 changed files with 221 additions and 0 deletions
+3
View File
@@ -6,6 +6,7 @@ import { LoginForm } from './auth/LoginForm'
import { RequireAuth } from './auth/RequireAuth'
import { BookList } from './books/BookList'
import { BookDetail } from './books/BookDetail'
import { BookForm } from './books/BookForm'
import { secondaryButton } from './ui'
export function App() {
@@ -25,6 +26,8 @@ export function AppRoutes() {
<Route element={<RequireAuth />}>
<Route element={<Shell />}>
<Route path="/books" element={<BookList />} />
<Route path="/books/new" element={<BookForm />} />
<Route path="/books/:id/edit" element={<BookForm />} />
<Route path="/books/:id" element={<BookDetail />} />
</Route>
</Route>
+65
View File
@@ -0,0 +1,65 @@
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 }
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' }))
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]
},
})
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 },
})
})
})
+153
View File
@@ -0,0 +1,153 @@
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 }
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)
useEffect(() => {
if (!editing) return
api<Book>('GET', `/books/${id}`).then(
(book) => {
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)
},
(err) => {
setLoadError(errorMessage(err))
setLoading(false)
},
)
}, [editing, id])
function set(name: keyof Fields) {
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' } : {}
}
async function submit(event: FormEvent) {
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}`)
} catch (err) {
setError({ message: errorMessage(err), field: err instanceof ApiError ? err.field : undefined })
setBusy(false)
}
}
if (loadError) {
return (
<div className={card}>
<p role="alert" className="text-red-700">
{loadError}
</p>
<Link to="/books" className="mt-2 inline-block text-indigo-700 underline">
← Back to library
</Link>
</div>
)
}
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>
<label className="block">
<span className={labelText}>Title</span>
<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')} />
</label>
<label className="block">
<span className={labelText}>Genre</span>
<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)}>
{g.name}
</option>
))}
</select>
</label>
{genresError && <p 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>
<input
type="number"
className={inputClass}
min={1}
max={100000}
value={fields.total_pages}
onChange={set('total_pages')}
required
{...invalid('total_pages')}
/>
</label>
<label className="block">
<span className={labelText}>Current page</span>
<input
type="number"
className={inputClass}
min={0}
max={fields.total_pages || undefined}
value={fields.current_page}
onChange={set('current_page')}
required
{...invalid('current_page')}
/>
</label>
</div>
{error && (
<p id="form-error" role="alert" className="text-sm text-red-700">
{error.message}
</p>
)}
<div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButton}>
{editing ? 'Save changes' : 'Add book'}
</button>
<Link to={editing ? `/books/${id}` : '/books'} className={secondaryButton}>
Cancel
</Link>
</div>
</form>
)
}