feat: add create and edit book form
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user