feat: add sign-in flow, routing shell, and searchable book list
Pin @testing-library/jest-dom to ~6.9.1 (6.10.0 is npm-deprecated). Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dune, renderApp, signedIn } from '../test/helpers'
|
||||
|
||||
describe('BookList', () => {
|
||||
it('shows each book with author, genre, status, and progress', async () => {
|
||||
signedIn({ 'GET /api/books': () => [200, [dune]] })
|
||||
renderApp('/books')
|
||||
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Frank Herbert · Science Fiction')).toBeInTheDocument()
|
||||
expect(screen.getByText('Reading')).toBeInTheDocument()
|
||||
expect(screen.getByText('142 / 380 (37%)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Dune/ })).toHaveAttribute('href', '/books/7')
|
||||
})
|
||||
|
||||
it('distinguishes an empty library from no matches', async () => {
|
||||
signedIn({ 'GET /api/books': () => [200, []] })
|
||||
const user = renderApp('/books')
|
||||
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
||||
await user.type(screen.getByLabelText('Search title or author'), 'zzz')
|
||||
expect(await screen.findByText('No matches. Try a different search or genre.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('debounces search into the URL and the request', async () => {
|
||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
||||
const user = renderApp('/books')
|
||||
await screen.findByRole('heading', { name: 'Dune' })
|
||||
await user.type(screen.getByLabelText('Search title or author'), 'herbert')
|
||||
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?q=herbert'))
|
||||
expect(calls.filter((c) => c.path.startsWith('/api/books?q=h')).map((c) => c.path)).toEqual(['/api/books?q=herbert'])
|
||||
})
|
||||
|
||||
it('filters by genre', async () => {
|
||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
||||
const user = renderApp('/books')
|
||||
await screen.findByRole('option', { name: 'Science Fiction' })
|
||||
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
|
||||
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?genre_id=5'))
|
||||
})
|
||||
|
||||
it('restores search and genre from the URL', async () => {
|
||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
||||
renderApp('/books?q=dune&genre=5')
|
||||
await screen.findByRole('option', { name: 'Science Fiction' })
|
||||
expect(screen.getByLabelText('Search title or author')).toHaveValue('dune')
|
||||
expect(screen.getByLabelText('Genre')).toHaveValue('5')
|
||||
expect(calls.map((c) => c.path)).toContain('/api/books?q=dune&genre_id=5')
|
||||
})
|
||||
|
||||
it('offers retry when loading fails', async () => {
|
||||
let attempts = 0
|
||||
signedIn({ 'GET /api/books': () => (++attempts === 1 ? [500, { error: 'Unexpected server error' }] : [200, [dune]]) })
|
||||
const user = renderApp('/books')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected server error')
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { api, errorMessage, type Book } from '../api'
|
||||
import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
||||
import { ProgressBar, StatusBadge } from './Progress'
|
||||
import { useGenres } from './useGenres'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 250
|
||||
|
||||
export function BookList() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const q = params.get('q') ?? ''
|
||||
const genre = params.get('genre') ?? ''
|
||||
const [search, setSearch] = useState(q)
|
||||
const [books, setBooks] = useState<Book[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const { genres, error: genresError } = useGenres()
|
||||
|
||||
function updateParam(key: string, value: string) {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (value) next.set(key, value)
|
||||
else next.delete(key)
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the input in sync when the URL changes from outside (back/forward navigation).
|
||||
useEffect(() => setSearch(q), [q])
|
||||
|
||||
useEffect(() => {
|
||||
if (search === q) return
|
||||
const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search, q])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const query = new URLSearchParams()
|
||||
if (q.trim()) query.set('q', q.trim())
|
||||
if (genre) query.set('genre_id', genre)
|
||||
api<Book[]>('GET', `/books?${query}`).then(
|
||||
(found) => {
|
||||
if (cancelled) return
|
||||
setBooks(found)
|
||||
setError(null)
|
||||
},
|
||||
(err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [q, genre, attempt])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<label className="min-w-48 grow">
|
||||
<span className={labelText}>Search title or author</span>
|
||||
<input type="search" className={inputClass} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={labelText}>Genre</span>
|
||||
<select className={inputClass} value={genre} onChange={(e) => updateParam('genre', e.target.value)}>
|
||||
<option value="">All genres</option>
|
||||
{genres.map((g) => (
|
||||
<option key={g.id} value={String(g.id)}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Link to="/books/new" className={primaryButton}>
|
||||
Add book
|
||||
</Link>
|
||||
</div>
|
||||
{genresError && (
|
||||
<p role="alert" className="mb-3 text-sm text-red-700">
|
||||
{`Could not load genres: ${genresError}`}
|
||||
</p>
|
||||
)}
|
||||
{error ? (
|
||||
<div role="alert" className="flex items-center gap-3 text-red-700">
|
||||
<span>{error}</span>
|
||||
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : books === null ? (
|
||||
<p className="text-slate-500">Loading…</p>
|
||||
) : books.length === 0 ? (
|
||||
<p className="text-slate-600">
|
||||
{q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{books.map((book) => (
|
||||
<li key={book.id}>
|
||||
<Link
|
||||
to={`/books/${book.id}`}
|
||||
className={`block ${card} hover:ring-2 hover:ring-indigo-300 focus-visible:outline-2 focus-visible:outline-indigo-600`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="font-semibold">{book.title}</h2>
|
||||
<StatusBadge status={book.status} />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
|
||||
<ProgressBar book={book} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Book, ReadingStatus } from '../api'
|
||||
|
||||
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
|
||||
not_started: { label: 'Not started', style: 'bg-slate-100 text-slate-700' },
|
||||
reading: { label: 'Reading', style: 'bg-amber-100 text-amber-800' },
|
||||
finished: { label: 'Finished', style: 'bg-emerald-100 text-emerald-800' },
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: ReadingStatus }) {
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}>
|
||||
{STATUS[status].label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressBar({ book }: { book: Book }) {
|
||||
const percent = Math.round((book.current_page / book.total_pages) * 100)
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<progress
|
||||
className="h-2 w-full accent-indigo-600"
|
||||
value={book.current_page}
|
||||
max={book.total_pages}
|
||||
aria-label={`Reading progress for ${book.title}`}
|
||||
/>
|
||||
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, errorMessage, type Genre } from '../api'
|
||||
|
||||
export function useGenres(): { genres: Genre[]; error: string | null } {
|
||||
const [genres, setGenres] = useState<Genre[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
api<Genre[]>('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
return { genres, error }
|
||||
}
|
||||
Reference in New Issue
Block a user