diff --git a/src/books/BookDetail.tsx b/src/books/BookDetail.tsx index 8875c2a..a923019 100644 --- a/src/books/BookDetail.tsx +++ b/src/books/BookDetail.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react' import { Link, useNavigate, useParams } from 'react-router' import { api, ApiError, errorMessage, type Book } from '../api' import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui' +import { NotesJournal } from '../notes/NotesJournal' import { ProgressBar, StatusBadge } from './Progress' export function BookDetail() { @@ -155,6 +156,7 @@ export function BookDetail() { )} + ) } diff --git a/src/notes/NotesJournal.test.tsx b/src/notes/NotesJournal.test.tsx new file mode 100644 index 0000000..67b31c8 --- /dev/null +++ b/src/notes/NotesJournal.test.tsx @@ -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') + }) +}) diff --git a/src/notes/NotesJournal.tsx b/src/notes/NotesJournal.tsx new file mode 100644 index 0000000..9ddf9ea --- /dev/null +++ b/src/notes/NotesJournal.tsx @@ -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(null) + const [loadError, setLoadError] = useState(null) + const [attempt, setAttempt] = useState(0) + const [draft, setDraft] = useState('') + const [addError, setAddError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoadError(null) + api('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('POST', `/books/${bookId}/notes`, { body: draft }) + setNotes((prev) => [note, ...(prev ?? [])]) + setDraft('') + } catch (err) { + setAddError(errorMessage(err)) + } + } + + return ( + + + Reading journal + + + + New journal entry + setDraft(e.target.value)} + required + aria-describedby={addError ? 'add-note-error' : undefined} + /> + + + Add entry + + {addError && ( + + {addError} + + )} + + {loadError ? ( + + {loadError} + setAttempt((n) => n + 1)}> + Retry + + + ) : notes === null ? ( + Loading… + ) : notes.length === 0 ? ( + No entries yet. + ) : ( + + {notes.map((note) => ( + setNotes((prev) => prev!.map((n) => (n.id === saved.id ? saved : n)))} + onDeleted={() => setNotes((prev) => prev!.filter((n) => n.id !== note.id))} + /> + ))} + + )} + + ) +} + +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(null) + const errorId = `note-${note.id}-error` + + async function save(event: FormEvent) { + event.preventDefault() + setError(null) + try { + onSaved(await api('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 ( + + + {new Date(note.created_at).toLocaleString()} + {note.updated_at !== note.created_at && ' · edited'} + + {editing ? ( + + + Edit entry + setText(e.target.value)} + required + aria-describedby={error ? errorId : undefined} + /> + + + + Save + + { + setEditing(false) + setText(note.body) + }} + > + Cancel + + + + ) : ( + <> + {note.body} + + setEditing(true)}> + Edit entry + + void remove()}> + Delete entry + + + > + )} + {error && ( + + {error} + + )} + + ) +}
+ {addError} +
Loading…
No entries yet.
+ {new Date(note.created_at).toLocaleString()} + {note.updated_at !== note.created_at && ' · edited'} +
{note.body}
+ {error} +