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
166 lines
5.6 KiB
TypeScript
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>
|
|
)
|
|
}
|