From 18bc74f8f9b6d9c4ce17d933811281baee6de720 Mon Sep 17 00:00:00 2001 From: Malcolm Roberts Date: Fri, 2 Oct 2026 14:26:30 -0500 Subject: [PATCH] feat: add create and edit book form Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X --- src/App.tsx | 3 + src/books/BookForm.test.tsx | 65 +++++++++++++++ src/books/BookForm.tsx | 153 ++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 src/books/BookForm.test.tsx create mode 100644 src/books/BookForm.tsx diff --git a/src/App.tsx b/src/App.tsx index f982dbf..5fc21ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { }> }> } /> + } /> + } /> } /> diff --git a/src/books/BookForm.test.tsx b/src/books/BookForm.test.tsx new file mode 100644 index 0000000..f7d0e7e --- /dev/null +++ b/src/books/BookForm.test.tsx @@ -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 }, + }) + }) +}) diff --git a/src/books/BookForm.tsx b/src/books/BookForm.tsx new file mode 100644 index 0000000..421deb9 --- /dev/null +++ b/src/books/BookForm.tsx @@ -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(EMPTY) + const [loading, setLoading] = useState(editing) + const [loadError, setLoadError] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!editing) return + api('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('PATCH', `/books/${id}`, payload) + : await api('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 ( +
+

+ {loadError} +

+ + ← Back to library + +
+ ) + } + if (loading) return

Loading…

+ + return ( +
+

{editing ? 'Edit book' : 'Add a book'}

+ + + + {genresError &&

{`Could not load genres: ${genresError}`}

} +
+ + +
+ {error && ( + + )} +
+ + + Cancel + +
+
+ ) +}