feat: add reading journal to book detail
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react'
|
|||||||
import { Link, useNavigate, useParams } from 'react-router'
|
import { Link, useNavigate, useParams } from 'react-router'
|
||||||
import { api, ApiError, errorMessage, type Book } from '../api'
|
import { api, ApiError, errorMessage, type Book } from '../api'
|
||||||
import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
||||||
|
import { NotesJournal } from '../notes/NotesJournal'
|
||||||
import { ProgressBar, StatusBadge } from './Progress'
|
import { ProgressBar, StatusBadge } from './Progress'
|
||||||
|
|
||||||
export function BookDetail() {
|
export function BookDetail() {
|
||||||
@@ -155,6 +156,7 @@ export function BookDetail() {
|
|||||||
</dialog>
|
</dialog>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<NotesJournal bookId={book.id} />
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { screen, within } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Note } from '../api'
|
||||||
|
import { dune, renderApp, signedIn } from '../test/helpers'
|
||||||
|
|
||||||
|
const older: Note = {
|
||||||
|
id: 11,
|
||||||
|
book_id: 7,
|
||||||
|
body: 'Started reading',
|
||||||
|
created_at: '2026-10-01T10:00:00+00:00',
|
||||||
|
updated_at: '2026-10-01T10:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotesJournal', () => {
|
||||||
|
it('lists entries and adds a new one at the top', async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'GET /api/books/7/notes': () => [200, [older]],
|
||||||
|
'POST /api/books/7/notes': ({ body }) => [
|
||||||
|
201,
|
||||||
|
{ ...older, id: 12, body: (body as { body: string }).body, created_at: '2026-10-02T10:00:00+00:00', updated_at: '2026-10-02T10:00:00+00:00' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
expect(await screen.findByText('Started reading')).toBeInTheDocument()
|
||||||
|
await user.type(screen.getByLabelText('New journal entry'), 'Loved chapter 3')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||||
|
|
||||||
|
await screen.findByText('Loved chapter 3')
|
||||||
|
const items = screen.getAllByRole('listitem')
|
||||||
|
expect(items[0]).toHaveTextContent('Loved chapter 3')
|
||||||
|
expect(items[1]).toHaveTextContent('Started reading')
|
||||||
|
expect(screen.getByLabelText('New journal entry')).toHaveValue('')
|
||||||
|
expect(calls).toContainEqual({ method: 'POST', path: '/api/books/7/notes', body: { body: 'Loved chapter 3' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits an entry and marks it edited', async () => {
|
||||||
|
signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'GET /api/books/7/notes': () => [200, [older]],
|
||||||
|
'PATCH /api/notes/11': ({ body }) => [
|
||||||
|
200,
|
||||||
|
{ ...older, body: (body as { body: string }).body, updated_at: '2026-10-02T10:00:00+00:00' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
const item = (await screen.findByText('Started reading')).closest('li')!
|
||||||
|
await user.click(within(item).getByRole('button', { name: 'Edit entry' }))
|
||||||
|
const editor = within(item).getByLabelText('Edit entry')
|
||||||
|
await user.clear(editor)
|
||||||
|
await user.type(editor, 'Finished part one')
|
||||||
|
await user.click(within(item).getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
expect(await within(item).findByText('Finished part one')).toBeInTheDocument()
|
||||||
|
expect(within(item).getByText(/edited/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes an entry', async () => {
|
||||||
|
signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'GET /api/books/7/notes': () => [200, [older]],
|
||||||
|
'DELETE /api/notes/11': () => [204],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Delete entry' }))
|
||||||
|
expect(await screen.findByText('No entries yet.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the server error when adding fails', async () => {
|
||||||
|
signedIn({
|
||||||
|
'GET /api/books/7': () => [200, dune],
|
||||||
|
'GET /api/books/7/notes': () => [200, []],
|
||||||
|
'POST /api/books/7/notes': () => [400, { error: 'body must be at most 10000 characters', field: 'body' }],
|
||||||
|
})
|
||||||
|
const user = renderApp('/books/7')
|
||||||
|
await user.type(await screen.findByLabelText('New journal entry'), 'x')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('body must be at most 10000 characters')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { api, errorMessage, type Note } from '../api'
|
||||||
|
import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
||||||
|
|
||||||
|
const MAX_NOTE = 10000
|
||||||
|
|
||||||
|
export function NotesJournal({ bookId }: { bookId: number }) {
|
||||||
|
const [notes, setNotes] = useState<Note[] | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const [addError, setAddError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoadError(null)
|
||||||
|
api<Note[]>('GET', `/books/${bookId}/notes`).then(
|
||||||
|
(found) => {
|
||||||
|
if (!cancelled) setNotes(found)
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (!cancelled) setLoadError(errorMessage(err))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [bookId, attempt])
|
||||||
|
|
||||||
|
async function addNote(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setAddError(null)
|
||||||
|
try {
|
||||||
|
const note = await api<Note>('POST', `/books/${bookId}/notes`, { body: draft })
|
||||||
|
setNotes((prev) => [note, ...(prev ?? [])])
|
||||||
|
setDraft('')
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="journal-heading" className={card}>
|
||||||
|
<h2 id="journal-heading" className="mb-3 text-lg font-semibold">
|
||||||
|
Reading journal
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={addNote} className="space-y-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelText}>New journal entry</span>
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
rows={3}
|
||||||
|
maxLength={MAX_NOTE}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
required
|
||||||
|
aria-describedby={addError ? 'add-note-error' : undefined}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className={primaryButton}>
|
||||||
|
Add entry
|
||||||
|
</button>
|
||||||
|
{addError && (
|
||||||
|
<p id="add-note-error" role="alert" className="text-sm text-red-700">
|
||||||
|
{addError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{loadError ? (
|
||||||
|
<div role="alert" className="mt-4 flex items-center gap-3 text-red-700">
|
||||||
|
<span>{loadError}</span>
|
||||||
|
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : notes === null ? (
|
||||||
|
<p className="mt-4 text-slate-500">Loading…</p>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="mt-4 text-slate-600">No entries yet.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="mt-4">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<NoteItem
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
onSaved={(saved) => setNotes((prev) => prev!.map((n) => (n.id === saved.id ? saved : n)))}
|
||||||
|
onDeleted={() => setNotes((prev) => prev!.filter((n) => n.id !== note.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteItem({ note, onSaved, onDeleted }: { note: Note; onSaved: (note: Note) => void; onDeleted: () => void }) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [text, setText] = useState(note.body)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const errorId = `note-${note.id}-error`
|
||||||
|
|
||||||
|
async function save(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
onSaved(await api<Note>('PATCH', `/notes/${note.id}`, { body: text }))
|
||||||
|
setEditing(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/notes/${note.id}`)
|
||||||
|
onDeleted()
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="border-t border-slate-200 py-3">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
<time dateTime={note.created_at}>{new Date(note.created_at).toLocaleString()}</time>
|
||||||
|
{note.updated_at !== note.created_at && ' · edited'}
|
||||||
|
</p>
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={save} className="mt-1 space-y-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="sr-only">Edit entry</span>
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
rows={3}
|
||||||
|
maxLength={MAX_NOTE}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
required
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className={primaryButton}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(false)
|
||||||
|
setText(note.body)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap">{note.body}</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button type="button" className={secondaryButton} onClick={() => setEditing(true)}>
|
||||||
|
Edit entry
|
||||||
|
</button>
|
||||||
|
<button type="button" className={dangerButton} onClick={() => void remove()}>
|
||||||
|
Delete entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} role="alert" className="mt-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user