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'} + + Title + + + + Author + + + + Genre + + Choose a genre + {genres.map((g) => ( + + {g.name} + + ))} + + + {genresError && {`Could not load genres: ${genresError}`}} + + + Total pages + + + + Current page + + + + {error && ( + + {error.message} + + )} + + + {editing ? 'Save changes' : 'Add book'} + + + Cancel + + + + ) +}
+ {loadError} +
Loading…
{`Could not load genres: ${genresError}`}
+ {error.message} +