Files
Clarium/src/books/BookForm.tsx
T
mroberts 2512c67893 fix: cap request bodies, link login error, add book-form retry, guard double add
Limit request bodies to 1 MiB (413 JSON), wire aria-describedby on the
login form, add Retry and cancellation to the book form load, disable
Add entry while a note POST is in flight, and poll pg_isready in README.

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
2026-10-02 14:36:14 -05:00

166 lines
5.6 KiB
TypeScript

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)
const [attempt, setAttempt] = useState(0)
useEffect(() => {
if (!editing) return
let cancelled = false
setLoadError(null)
setLoading(true)
api<Book>('GET', `/books/${id}`).then(
(book) => {
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)
},
(err) => {
if (cancelled) return
setLoadError(errorMessage(err))
setLoading(false)
},
)
return () => {
cancelled = true
}
}, [editing, id, attempt])
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 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)}>
Retry
</button>
<Link to="/books" className="text-indigo-700 underline">
← Back to library
</Link>
</div>
</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 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>
<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>
)
}