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:
Generated
+5
-9
@@ -15,7 +15,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/dom": "^10.4.2",
|
"@testing-library/dom": "^10.4.2",
|
||||||
"@testing-library/jest-dom": "^6.10.0",
|
"@testing-library/jest-dom": "~6.9.1",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.7",
|
"@testing-library/user-event": "^14.6.7",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
@@ -1768,10 +1768,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/jest-dom": {
|
"node_modules/@testing-library/jest-dom": {
|
||||||
"version": "6.10.0",
|
"version": "6.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||||
"integrity": "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==",
|
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||||
"deprecated": "Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0.",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1783,12 +1782,9 @@
|
|||||||
"redent": "^3.0.0"
|
"redent": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22",
|
"node": ">=14",
|
||||||
"npm": ">=6",
|
"npm": ">=6",
|
||||||
"yarn": ">=1"
|
"yarn": ">=1"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@testing-library/dom": ">=10 <11"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/dom": "^10.4.2",
|
"@testing-library/dom": "^10.4.2",
|
||||||
"@testing-library/jest-dom": "^6.10.0",
|
"@testing-library/jest-dom": "~6.9.1",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.7",
|
"@testing-library/user-event": "^14.6.7",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
|||||||
+64
-1
@@ -1,3 +1,66 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from 'react-router'
|
||||||
|
import { errorMessage } from './api'
|
||||||
|
import { AuthProvider, useAuth } from './auth/AuthContext'
|
||||||
|
import { LoginForm } from './auth/LoginForm'
|
||||||
|
import { RequireAuth } from './auth/RequireAuth'
|
||||||
|
import { BookList } from './books/BookList'
|
||||||
|
import { secondaryButton } from './ui'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return <h1 className="p-6 text-2xl font-semibold">My Library</h1>
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginForm />} />
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route element={<Shell />}>
|
||||||
|
<Route path="/books" element={<BookList />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/books" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
|
<header className="border-b border-slate-200 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-4xl items-center justify-between p-4">
|
||||||
|
<Link to="/books" className="text-lg font-semibold">
|
||||||
|
My Library
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span>{user?.username}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => logout().catch((err) => setError(errorMessage(err)))}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mx-auto max-w-4xl px-4 pb-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-4xl p-4">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
export type User = { id: number; username: string }
|
||||||
|
export type Genre = { id: number; name: string }
|
||||||
|
export type ReadingStatus = 'not_started' | 'reading' | 'finished'
|
||||||
|
export type Book = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
author: string
|
||||||
|
genre: Genre
|
||||||
|
total_pages: number
|
||||||
|
current_page: number
|
||||||
|
status: ReadingStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
export type Note = { id: number; book_id: number; body: string; created_at: string; updated_at: string }
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
readonly field?: string
|
||||||
|
|
||||||
|
constructor(status: number, message: string, field?: string) {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
this.field = field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handleUnauthorized: () => void = () => {}
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(handler: () => void) {
|
||||||
|
handleUnauthorized = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T> {
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
method,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
// Always JSON: the server rejects mutations without it (CSRF defense, ADR-0002).
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (!response.ok) {
|
||||||
|
// 401s from /auth/* are expected answers (bad login, not signed in yet), not an expired session.
|
||||||
|
if (response.status === 401 && !path.startsWith('/auth/')) handleUnauthorized()
|
||||||
|
throw new ApiError(response.status, data?.error ?? `Request failed with status ${response.status}`, data?.field)
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof ApiError ? error.message : 'Could not reach the server; check your connection and try again'
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { api, setUnauthorizedHandler, type User } from '../api'
|
||||||
|
|
||||||
|
type AuthValue = {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
register: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => setUser(null))
|
||||||
|
api<User>('GET', '/auth/me')
|
||||||
|
.then(setUser, () => setUser(null))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value: AuthValue = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login: async (username, password) => setUser(await api<User>('POST', '/auth/login', { username, password })),
|
||||||
|
register: async (username, password) => setUser(await api<User>('POST', '/auth/register', { username, password })),
|
||||||
|
logout: async () => {
|
||||||
|
await api('POST', '/auth/logout')
|
||||||
|
setUser(null)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return <AuthContext value={value}>{children}</AuthContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthValue {
|
||||||
|
const value = useContext(AuthContext)
|
||||||
|
if (!value) throw new Error('useAuth must be used inside <AuthProvider>')
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Navigate, useLocation, type Location } from 'react-router'
|
||||||
|
import { errorMessage } from '../api'
|
||||||
|
import { card, inputClass, labelText, primaryButton } from '../ui'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const { user, login, register } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const from = (location.state as { from?: Location } | null)?.from
|
||||||
|
return <Navigate to={from ? from.pathname + from.search : '/books'} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLogin = mode === 'login'
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await (isLogin ? login : register)(username, password)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 px-4 pt-16">
|
||||||
|
<main className={`mx-auto max-w-sm ${card}`}>
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">{isLogin ? 'Sign in' : 'Create account'}</h1>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelText}>Username</span>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelText}>Password</span>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||||
|
minLength={isLogin ? undefined : 12}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={busy} className={`${primaryButton} w-full`}>
|
||||||
|
{isLogin ? 'Sign in' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-4 text-sm text-indigo-700 underline"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(isLogin ? 'register' : 'login')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLogin ? 'Need an account? Register' : 'Have an account? Sign in'}
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
export function RequireAuth() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
if (loading) return <p className="p-6 text-slate-500">Loading…</p>
|
||||||
|
if (!user) return <Navigate to="/login" replace state={{ from: location }} />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { alice, dune, genres, mockApi, renderApp, signedIn } from '../test/helpers'
|
||||||
|
|
||||||
|
describe('auth flow', () => {
|
||||||
|
it('sends signed-out users to sign in, then back to the page they wanted', async () => {
|
||||||
|
const calls = mockApi({
|
||||||
|
'GET /api/auth/me': () => [401, { error: 'Not signed in or session expired; log in again' }],
|
||||||
|
'POST /api/auth/login': () => [200, alice],
|
||||||
|
'GET /api/genres': () => [200, genres],
|
||||||
|
'GET /api/books': () => [200, [dune]],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books?q=dune')
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Sign in' })
|
||||||
|
await user.type(screen.getByLabelText('Username'), 'alice')
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'correct horse battery')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText('Search title or author')).toHaveValue('dune')
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: { username: 'alice', password: 'correct horse battery' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the server error for a bad login', async () => {
|
||||||
|
mockApi({
|
||||||
|
'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
|
||||||
|
'POST /api/auth/login': () => [401, { error: 'Invalid username or password' }],
|
||||||
|
})
|
||||||
|
const user = renderApp('/login')
|
||||||
|
await user.type(await screen.findByLabelText('Username'), 'alice')
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'wrong password!')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid username or password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers a new account', async () => {
|
||||||
|
const calls = mockApi({
|
||||||
|
'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
|
||||||
|
'POST /api/auth/register': () => [201, alice],
|
||||||
|
'GET /api/genres': () => [200, genres],
|
||||||
|
'GET /api/books': () => [200, []],
|
||||||
|
})
|
||||||
|
const user = renderApp('/login')
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Need an account? Register' }))
|
||||||
|
await user.type(screen.getByLabelText('Username'), 'alice')
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'correct horse battery')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create account' }))
|
||||||
|
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
||||||
|
expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/register')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns to sign in when the session expires mid-use', async () => {
|
||||||
|
signedIn({ 'GET /api/books': () => [401, { error: 'Not signed in or session expired; log in again' }] })
|
||||||
|
renderApp('/books')
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs out', async () => {
|
||||||
|
const calls = signedIn({ 'GET /api/books': () => [200, []], 'POST /api/auth/logout': () => [204] })
|
||||||
|
const user = renderApp('/books')
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Log out' }))
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||||
|
expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/logout')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import type { Book, Genre, User } from '../api'
|
||||||
|
import { AppRoutes } from '../App'
|
||||||
|
import { AuthProvider } from '../auth/AuthContext'
|
||||||
|
|
||||||
|
export const alice: User = { id: 1, username: 'alice' }
|
||||||
|
export const genres: Genre[] = [
|
||||||
|
{ id: 3, name: 'Fantasy' },
|
||||||
|
{ id: 5, name: 'Science Fiction' },
|
||||||
|
]
|
||||||
|
export const dune: Book = {
|
||||||
|
id: 7,
|
||||||
|
title: 'Dune',
|
||||||
|
author: 'Frank Herbert',
|
||||||
|
genre: { id: 5, name: 'Science Fiction' },
|
||||||
|
total_pages: 380,
|
||||||
|
current_page: 142,
|
||||||
|
status: 'reading',
|
||||||
|
created_at: '2026-10-01T10:00:00+00:00',
|
||||||
|
updated_at: '2026-10-01T10:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reply = [status: number, body?: unknown]
|
||||||
|
type Handler = (request: { body: unknown; url: URL }) => Reply
|
||||||
|
export type Call = { method: string; path: string; body: unknown }
|
||||||
|
|
||||||
|
/** Replaces fetch (the network boundary) with route handlers keyed by "METHOD /api/path". */
|
||||||
|
export function mockApi(routes: Record<string, Handler>): Call[] {
|
||||||
|
const calls: Call[] = []
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (input: string, init: RequestInit = {}) => {
|
||||||
|
const url = new URL(input, 'http://localhost')
|
||||||
|
const method = init.method ?? 'GET'
|
||||||
|
const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
|
||||||
|
calls.push({ method, path: url.pathname + url.search, body })
|
||||||
|
const handler = routes[`${method} ${url.pathname}`]
|
||||||
|
const [status, data] = handler ? handler({ body, url }) : [500, { error: `No mock for ${method} ${url.pathname}` }]
|
||||||
|
return new Response(status === 204 ? null : JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signedIn(routes: Record<string, Handler> = {}): Call[] {
|
||||||
|
return mockApi({ 'GET /api/auth/me': () => [200, alice], 'GET /api/genres': () => [200, genres], ...routes })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderApp(url: string) {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[url]}>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const card = 'rounded-lg bg-white p-4 shadow'
|
||||||
|
export const labelText = 'mb-1 block text-sm font-medium text-slate-700'
|
||||||
|
export const inputClass =
|
||||||
|
'block w-full rounded-md border border-slate-300 px-3 py-2 focus-visible:outline-2 focus-visible:outline-indigo-600 aria-invalid:border-red-600'
|
||||||
|
const button =
|
||||||
|
'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50'
|
||||||
|
export const primaryButton = `${button} bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600`
|
||||||
|
export const secondaryButton = `${button} border border-slate-300 bg-white text-slate-800 hover:bg-slate-100 focus-visible:outline-indigo-600`
|
||||||
|
export const dangerButton = `${button} bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600`
|
||||||
Reference in New Issue
Block a user