From 9d7b0e805c4af23665df31507d1737c0eeb0997c Mon Sep 17 00:00:00 2001 From: Malcolm Roberts Date: Fri, 2 Oct 2026 15:06:56 -0500 Subject: [PATCH] style: drop redundant comments and apply formatting Frontend now uses double quotes and semicolons. No behaviour change. --- backend/app.py | 4 - backend/auth.py | 2 +- backend/books.py | 4 +- backend/db.py | 3 +- backend/schema.sql | 2 - backend/tests/__init__.py | 3 +- backend/tests/support.py | 2 +- backend/tests/test_auth.py | 6 +- backend/tests/test_books.py | 2 +- backend/tests/test_search.py | 10 +- backend/validation.py | 1 - src/App.tsx | 46 +++++--- src/api.ts | 81 ++++++++------ src/auth/AuthContext.tsx | 58 +++++----- src/auth/LoginForm.tsx | 70 ++++++------ src/auth/RequireAuth.tsx | 14 +-- src/auth/auth.test.tsx | 158 ++++++++++++++++----------- src/books/BookDetail.test.tsx | 156 +++++++++++++++++---------- src/books/BookDetail.tsx | 148 +++++++++++++++++--------- src/books/BookForm.test.tsx | 167 +++++++++++++++++------------ src/books/BookForm.tsx | 176 +++++++++++++++++++----------- src/books/BookList.test.tsx | 132 ++++++++++++++--------- src/books/BookList.tsx | 111 +++++++++++-------- src/books/Progress.tsx | 20 ++-- src/books/useGenres.ts | 16 +-- src/main.tsx | 12 +-- src/notes/NotesJournal.test.tsx | 182 ++++++++++++++++++-------------- src/test/helpers.tsx | 82 +++++++------- src/test/setup.ts | 9 +- src/ui.ts | 14 +-- 30 files changed, 1012 insertions(+), 679 deletions(-) diff --git a/backend/app.py b/backend/app.py index 3216aec..a6bef8a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -21,8 +21,6 @@ def create_app() -> Flask: level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" ) app = Flask(__name__) - # Werkzeug buffers and parses the whole body before our validation runs, so unauthenticated - # callers could exhaust memory; 1 MiB is far above the largest legitimate payload (10,000-char note). app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 db.init_app(app) auth.init_app(app) @@ -35,8 +33,6 @@ def create_app() -> Flask: @app.before_request def require_json_for_mutations(): - # CSRF defense (ADR-0002): cross-site requests can only send this content type after a - # CORS preflight, and this API never grants CORS. if request.method in MUTATING_METHODS and not request.is_json: raise ApiError( 400, "Request body must be JSON with Content-Type: application/json" diff --git a/backend/auth.py b/backend/auth.py index 8533889..0c62fdc 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -30,7 +30,7 @@ INVALID_LOGIN = "Invalid username or password" def init_app(app: Flask) -> None: app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper") - _dummy_hash() # pay the one-time cost now so the first unknown-user login isn't measurably slower + _dummy_hash() app.register_blueprint(bp) diff --git a/backend/books.py b/backend/books.py index 0fe0afb..d3962f1 100644 --- a/backend/books.py +++ b/backend/books.py @@ -13,7 +13,7 @@ bp = Blueprint("books", __name__, url_prefix="/api") MAX_TEXT = 500 MAX_PAGES = 100_000 -MAX_GENRE_ID = 32_767 # SMALLINT +MAX_GENRE_ID = 32_767 GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}") REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages") FIELDS = set(REQUIRED_FIELDS) | {"current_page"} @@ -56,7 +56,6 @@ def to_json(row: dict) -> dict: def escape_like(term: str) -> str: - # Backslash is Postgres's default LIKE escape character. return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") @@ -147,7 +146,6 @@ def create_book(): return jsonify(to_json(owned_book(row["id"]))), 201 -# int(max=...) keeps ids within Postgres INT range: larger values 404 instead of erroring in SQL. @bp.get("/books/") @login_required def get_book(book_id: int): diff --git a/backend/db.py b/backend/db.py index aa4698c..20e7276 100644 --- a/backend/db.py +++ b/backend/db.py @@ -44,7 +44,6 @@ def _connection(): def _finish_transaction(response): - # Commit only successful responses so a 4xx/5xx never leaves partial writes behind. conn = g.get("db") if conn is not None: if response.status_code < 400: @@ -57,5 +56,5 @@ def _finish_transaction(response): def _release_connection(_exc): conn = g.pop("db", None) if conn is not None: - conn.rollback() # no-op after commit; discards work if after_request never ran + conn.rollback() current_app.extensions["db_pool"].putconn(conn) diff --git a/backend/schema.sql b/backend/schema.sql index bbd5a0c..847d870 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS genres ( name TEXT NOT NULL UNIQUE ); --- WHERE NOT EXISTS instead of ON CONFLICT: ON CONFLICT consumes a sequence value per row --- on every startup, which would eventually overflow SMALLSERIAL. INSERT INTO genres (name) SELECT seed.name FROM (VALUES ('Fiction'), ('Non-Fiction'), ('Mystery'), ('Thriller'), ('Science Fiction'), diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py index dbae783..1909558 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -13,7 +13,7 @@ TEST_DATABASE = "books_test" def _ensure_test_database() -> None: conn = psycopg2.connect(ADMIN_URL) - conn.autocommit = True # CREATE DATABASE cannot run inside a transaction + conn.autocommit = True try: with conn.cursor() as cur: cur.execute( @@ -28,6 +28,5 @@ def _ensure_test_database() -> None: _ensure_test_database() -# Assigned, never defaulted: tests TRUNCATE tables, so they must not inherit a real DATABASE_URL. os.environ["DATABASE_URL"] = ADMIN_URL.rsplit("/", 1)[0] + "/" + TEST_DATABASE os.environ["PASSWORD_PEPPER"] = "test-pepper-0123456789abcdef0123456789abcdef" diff --git a/backend/tests/support.py b/backend/tests/support.py index 5c5983c..43c401f 100644 --- a/backend/tests/support.py +++ b/backend/tests/support.py @@ -5,7 +5,7 @@ import psycopg2 from app import app -HTTPS = "https://localhost" # the session cookie is Secure +HTTPS = "https://localhost" def db_execute(sql: str, params: tuple = ()) -> list[tuple]: diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index df87fbf..e24b39c 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -70,7 +70,7 @@ class AuthTests(ApiTestCase): def test_login_is_case_insensitive_on_username(self): self.register("Alice") - self.client = app.test_client() # fresh cookie jar: signed out + self.client = app.test_client() response = self.call( "POST", "/api/auth/login", @@ -116,9 +116,7 @@ class AuthTests(ApiTestCase): self.register() token = self.session_token() self.assertEqual(self.call("POST", "/api/auth/logout").status_code, 204) - self.client.set_cookie( - "sid", token, domain="localhost", path="/api" - ) # replay the stolen cookie + self.client.set_cookie("sid", token, domain="localhost", path="/api") self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401) def test_idle_session_expires(self): diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index 380349f..dce6eac 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -17,7 +17,7 @@ class BookTests(ApiTestCase): (book["current_page"], book["total_pages"], book["status"]), (0, 412, "not_started"), ) - self.assertIn("T", book["created_at"]) # ISO 8601 + self.assertIn("T", book["created_at"]) def test_create_trims_text(self): book = self.create_book(title=" Dune ") diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py index a2ee0ff..5f9f4db 100644 --- a/backend/tests/test_search.py +++ b/backend/tests/test_search.py @@ -24,12 +24,10 @@ class SearchTests(ApiTestCase): self.assertEqual(self.titles("q=tolkien"), ["The Hobbit"]) def test_wildcards_match_literally(self): - self.assertEqual(self.titles("q=100%25"), ["100% Pure"]) # %25 is "%" - self.assertEqual(self.titles("q=e_D"), ["100% Pure"]) # literal "_" in Jane_Doe - self.assertEqual( - self.titles("q=k_s"), [] - ) # unescaped "_" would match "Back\\slash" - self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"]) # %5C is "\" + self.assertEqual(self.titles("q=100%25"), ["100% Pure"]) + self.assertEqual(self.titles("q=e_D"), ["100% Pure"]) + self.assertEqual(self.titles("q=k_s"), []) + self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"]) def test_genre_filter_and_combination(self): self.assertEqual( diff --git a/backend/validation.py b/backend/validation.py index e0d0da4..ee156c6 100644 --- a/backend/validation.py +++ b/backend/validation.py @@ -51,7 +51,6 @@ def text(body: dict, field: str, max_len: int) -> str: def integer(body: dict, field: str, low: int, high: int) -> int: value = body.get(field) - # bool is a subclass of int in Python; JSON true must not count as 1. if isinstance(value, bool) or not isinstance(value, int): raise ApiError(400, f"{field} must be a whole number", field) if not low <= value <= high: diff --git a/src/App.tsx b/src/App.tsx index 5fc21ea..4141979 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,20 @@ -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 { BookDetail } from './books/BookDetail' -import { BookForm } from './books/BookForm' -import { secondaryButton } from './ui' +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 { BookDetail } from "./books/BookDetail"; +import { BookForm } from "./books/BookForm"; +import { secondaryButton } from "./ui"; export function App() { return ( @@ -16,7 +23,7 @@ export function App() { - ) + ); } export function AppRoutes() { @@ -33,12 +40,12 @@ export function AppRoutes() { } /> - ) + ); } function Shell() { - const { user, logout } = useAuth() - const [error, setError] = useState(null) + const { user, logout } = useAuth(); + const [error, setError] = useState(null); return (
@@ -51,14 +58,19 @@ function Shell() {
{error && ( -

+

{error}

)} @@ -67,5 +79,5 @@ function Shell() { - ) + ); } diff --git a/src/api.ts b/src/api.ts index 2342afd..dad7e20 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,54 +1,69 @@ -export type User = { id: number; username: string } -export type Genre = { id: number; name: string } -export type ReadingStatus = 'not_started' | 'reading' | 'finished' +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 } + 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 + readonly status: number; + readonly field?: string; constructor(status: number, message: string, field?: string) { - super(message) - this.status = status - this.field = field + super(message); + this.status = status; + this.field = field; } } -let handleUnauthorized: () => void = () => {} +let handleUnauthorized: () => void = () => {}; export function setUnauthorizedHandler(handler: () => void) { - handleUnauthorized = handler + handleUnauthorized = handler; } -export async function api(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise { +export async function api( + method: "GET" | "POST" | "PATCH" | "DELETE", + path: string, + body?: unknown, +): Promise { 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' }, + credentials: "same-origin", + 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.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) + 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 + 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' + return error instanceof ApiError + ? error.message + : "Could not reach the server; check your connection and try again"; } diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index cd8daac..41f9874 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -1,42 +1,52 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import { api, setUnauthorizedHandler, type User } from '../api' +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 - register: (username: string, password: string) => Promise - logout: () => Promise -} + user: User | null; + loading: boolean; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; + logout: () => Promise; +}; -const AuthContext = createContext(null) +const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { - setUnauthorizedHandler(() => setUser(null)) - api('GET', '/auth/me') + setUnauthorizedHandler(() => setUser(null)); + api("GET", "/auth/me") .then(setUser, () => setUser(null)) - .finally(() => setLoading(false)) - }, []) + .finally(() => setLoading(false)); + }, []); const value: AuthValue = { user, loading, - login: async (username, password) => setUser(await api('POST', '/auth/login', { username, password })), - register: async (username, password) => setUser(await api('POST', '/auth/register', { username, password })), + login: async (username, password) => + setUser(await api("POST", "/auth/login", { username, password })), + register: async (username, password) => + setUser( + await api("POST", "/auth/register", { username, password }), + ), logout: async () => { - await api('POST', '/auth/logout') - setUser(null) + await api("POST", "/auth/logout"); + setUser(null); }, - } - return {children} + }; + return {children}; } export function useAuth(): AuthValue { - const value = useContext(AuthContext) - if (!value) throw new Error('useAuth must be used inside ') - return value + const value = useContext(AuthContext); + if (!value) throw new Error("useAuth must be used inside "); + return value; } diff --git a/src/auth/LoginForm.tsx b/src/auth/LoginForm.tsx index f1ff558..cfe08fb 100644 --- a/src/auth/LoginForm.tsx +++ b/src/auth/LoginForm.tsx @@ -1,42 +1,46 @@ -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' +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(null) - const [busy, setBusy] = useState(false) + 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(null); + const [busy, setBusy] = useState(false); if (user) { - const from = (location.state as { from?: Location } | null)?.from - return + const from = (location.state as { from?: Location } | null)?.from; + return ( + + ); } - const isLogin = mode === 'login' + const isLogin = mode === "login"; async function submit(event: FormEvent) { - event.preventDefault() - setError(null) - setBusy(true) + event.preventDefault(); + setError(null); + setBusy(true); try { - await (isLogin ? login : register)(username, password) + await (isLogin ? login : register)(username, password); } catch (err) { - setError(errorMessage(err)) + setError(errorMessage(err)); } finally { - setBusy(false) + setBusy(false); } } return (
-

{isLogin ? 'Sign in' : 'Create account'}

+

+ {isLogin ? "Sign in" : "Create account"} +

@@ -57,10 +61,10 @@ export function LoginForm() { type="password" value={password} onChange={(e) => setPassword(e.target.value)} - autoComplete={isLogin ? 'current-password' : 'new-password'} + autoComplete={isLogin ? "current-password" : "new-password"} minLength={isLogin ? undefined : 12} aria-invalid={error ? true : undefined} - aria-describedby={error ? 'login-error' : undefined} + aria-describedby={error ? "login-error" : undefined} required /> @@ -69,21 +73,25 @@ export function LoginForm() { {error}

)} -
- ) + ); } diff --git a/src/auth/RequireAuth.tsx b/src/auth/RequireAuth.tsx index af640df..e360240 100644 --- a/src/auth/RequireAuth.tsx +++ b/src/auth/RequireAuth.tsx @@ -1,10 +1,10 @@ -import { Navigate, Outlet, useLocation } from 'react-router' -import { useAuth } from './AuthContext' +import { Navigate, Outlet, useLocation } from "react-router"; +import { useAuth } from "./AuthContext"; export function RequireAuth() { - const { user, loading } = useAuth() - const location = useLocation() - if (loading) return

Loading…

- if (!user) return - return + const { user, loading } = useAuth(); + const location = useLocation(); + if (loading) return

Loading…

; + if (!user) return ; + return ; } diff --git a/src/auth/auth.test.tsx b/src/auth/auth.test.tsx index 3f51feb..1ce9af1 100644 --- a/src/auth/auth.test.tsx +++ b/src/auth/auth.test.tsx @@ -1,70 +1,110 @@ -import { screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { alice, dune, genres, mockApi, renderApp, signedIn } from '../test/helpers' +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 () => { +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') + "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' })) + 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(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' }, - }) - }) + method: "POST", + path: "/api/auth/login", + body: { username: "alice", password: "correct horse battery" }, + }); + }); - it('shows the server error for a bad login', async () => { + 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') - expect(screen.getByLabelText('Password')).toHaveAttribute('aria-describedby', 'login-error') - }) + "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", + ); + expect(screen.getByLabelText("Password")).toHaveAttribute( + "aria-describedby", + "login-error", + ); + }); - it('registers a new account', async () => { + 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) - }) + "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("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) - }) -}) + 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); + }); +}); diff --git a/src/books/BookDetail.test.tsx b/src/books/BookDetail.test.tsx index a4c63db..2370204 100644 --- a/src/books/BookDetail.test.tsx +++ b/src/books/BookDetail.test.tsx @@ -1,72 +1,112 @@ -import { screen, waitFor } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import type { Book } from '../api' -import { dune, renderApp, signedIn } from '../test/helpers' +import { screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { Book } from "../api"; +import { dune, renderApp, signedIn } from "../test/helpers"; function withProgress(current_page: number): Book { - const status = current_page === 0 ? 'not_started' : current_page === dune.total_pages ? 'finished' : 'reading' - return { ...dune, current_page, status } + const status = + current_page === 0 + ? "not_started" + : current_page === dune.total_pages + ? "finished" + : "reading"; + return { ...dune, current_page, status }; } -describe('BookDetail', () => { - it('updates progress and reflects it in the bar', async () => { +describe("BookDetail", () => { + it("updates progress and reflects it in the bar", async () => { const calls = signedIn({ - 'GET /api/books/7': () => [200, dune], - 'PATCH /api/books/7': ({ body }) => [200, withProgress((body as { current_page: number }).current_page)], - }) - const user = renderApp('/books/7') - expect(await screen.findByText('142 / 380 (37%)')).toBeInTheDocument() + "GET /api/books/7": () => [200, dune], + "PATCH /api/books/7": ({ body }) => [ + 200, + withProgress((body as { current_page: number }).current_page), + ], + }); + const user = renderApp("/books/7"); + expect(await screen.findByText("142 / 380 (37%)")).toBeInTheDocument(); - const input = screen.getByLabelText('Current page') - await user.clear(input) - await user.type(input, '200') - await user.click(screen.getByRole('button', { name: 'Save progress' })) + const input = screen.getByLabelText("Current page"); + await user.clear(input); + await user.type(input, "200"); + await user.click(screen.getByRole("button", { name: "Save progress" })); - expect(await screen.findByText('200 / 380 (53%)')).toBeInTheDocument() - expect(calls).toContainEqual({ method: 'PATCH', path: '/api/books/7', body: { current_page: 200 } }) - }) + expect(await screen.findByText("200 / 380 (53%)")).toBeInTheDocument(); + expect(calls).toContainEqual({ + method: "PATCH", + path: "/api/books/7", + body: { current_page: 200 }, + }); + }); - it('marks a book finished', async () => { + it("marks a book finished", async () => { signedIn({ - 'GET /api/books/7': () => [200, dune], - 'PATCH /api/books/7': () => [200, withProgress(380)], - }) - const user = renderApp('/books/7') - await user.click(await screen.findByRole('button', { name: 'Mark finished' })) - expect(await screen.findByText('380 / 380 (100%)')).toBeInTheDocument() - expect(screen.getByText('Finished')).toBeInTheDocument() - }) + "GET /api/books/7": () => [200, dune], + "PATCH /api/books/7": () => [200, withProgress(380)], + }); + const user = renderApp("/books/7"); + await user.click( + await screen.findByRole("button", { name: "Mark finished" }), + ); + expect(await screen.findByText("380 / 380 (100%)")).toBeInTheDocument(); + expect(screen.getByText("Finished")).toBeInTheDocument(); + }); - it('shows the server message when a progress update is rejected', async () => { + it("shows the server message when a progress update is rejected", async () => { signedIn({ - 'GET /api/books/7': () => [200, dune], - 'PATCH /api/books/7': () => [400, { error: 'current_page must be between 0 and 100000', field: 'current_page' }], - }) - const user = renderApp('/books/7') - await user.click(await screen.findByRole('button', { name: 'Save progress' })) - // findByText, not findByRole('alert'): once Task 11 mounts the journal, the page may show more than one alert. - expect(await screen.findByText('current_page must be between 0 and 100000')).toHaveAttribute('role', 'alert') - expect(screen.getByLabelText('Current page')).toHaveAttribute('aria-invalid', 'true') - }) + "GET /api/books/7": () => [200, dune], + "PATCH /api/books/7": () => [ + 400, + { + error: "current_page must be between 0 and 100000", + field: "current_page", + }, + ], + }); + const user = renderApp("/books/7"); + await user.click( + await screen.findByRole("button", { name: "Save progress" }), + ); + expect( + await screen.findByText("current_page must be between 0 and 100000"), + ).toHaveAttribute("role", "alert"); + expect(screen.getByLabelText("Current page")).toHaveAttribute( + "aria-invalid", + "true", + ); + }); - it('shows not found for a missing or foreign book', async () => { - signedIn({ 'GET /api/books/999': () => [404, { error: 'Book not found' }] }) - renderApp('/books/999') - expect(await screen.findByRole('heading', { name: 'Book not found' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /back to library/i })).toHaveAttribute('href', '/books') - }) + it("shows not found for a missing or foreign book", async () => { + signedIn({ + "GET /api/books/999": () => [404, { error: "Book not found" }], + }); + renderApp("/books/999"); + expect( + await screen.findByRole("heading", { name: "Book not found" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /back to library/i }), + ).toHaveAttribute("href", "/books"); + }); - it('deletes after confirmation and returns to the list', async () => { + it("deletes after confirmation and returns to the list", async () => { const calls = signedIn({ - 'GET /api/books/7': () => [200, dune], - 'DELETE /api/books/7': () => [204], - 'GET /api/books': () => [200, []], - }) - const user = renderApp('/books/7') - await user.click(await screen.findByRole('button', { name: 'Delete book' })) - expect(screen.getByRole('dialog')).toHaveTextContent('Delete “Dune”?') - await user.click(screen.getByRole('button', { name: 'Yes, delete' })) - expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument() - await waitFor(() => expect(calls.some((c) => c.method === 'DELETE' && c.path === '/api/books/7')).toBe(true)) - }) -}) + "GET /api/books/7": () => [200, dune], + "DELETE /api/books/7": () => [204], + "GET /api/books": () => [200, []], + }); + const user = renderApp("/books/7"); + await user.click( + await screen.findByRole("button", { name: "Delete book" }), + ); + expect(screen.getByRole("dialog")).toHaveTextContent("Delete “Dune”?"); + await user.click(screen.getByRole("button", { name: "Yes, delete" })); + expect( + await screen.findByText("No books yet. Add your first one."), + ).toBeInTheDocument(); + await waitFor(() => + expect( + calls.some((c) => c.method === "DELETE" && c.path === "/api/books/7"), + ).toBe(true), + ); + }); +}); diff --git a/src/books/BookDetail.tsx b/src/books/BookDetail.tsx index a923019..756ab1f 100644 --- a/src/books/BookDetail.tsx +++ b/src/books/BookDetail.tsx @@ -1,86 +1,109 @@ -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' +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() { - const { id } = useParams() - const navigate = useNavigate() - const [book, setBook] = useState(null) - const [loadError, setLoadError] = useState<{ notFound: boolean; message: string } | null>(null) - const [attempt, setAttempt] = useState(0) - const [page, setPage] = useState('') - const [progressError, setProgressError] = useState(null) - const [confirmingDelete, setConfirmingDelete] = useState(false) - const [deleteError, setDeleteError] = useState(null) + const { id } = useParams(); + const navigate = useNavigate(); + const [book, setBook] = useState(null); + const [loadError, setLoadError] = useState<{ + notFound: boolean; + message: string; + } | null>(null); + const [attempt, setAttempt] = useState(0); + const [page, setPage] = useState(""); + const [progressError, setProgressError] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleteError, setDeleteError] = useState(null); useEffect(() => { - let cancelled = false - setLoadError(null) - api('GET', `/books/${id}`).then( + let cancelled = false; + setLoadError(null); + api("GET", `/books/${id}`).then( (found) => { - if (cancelled) return - setBook(found) - setPage(String(found.current_page)) + if (cancelled) return; + setBook(found); + setPage(String(found.current_page)); }, (err) => { - if (!cancelled) setLoadError({ notFound: err instanceof ApiError && err.status === 404, message: errorMessage(err) }) + if (!cancelled) + setLoadError({ + notFound: err instanceof ApiError && err.status === 404, + message: errorMessage(err), + }); }, - ) + ); return () => { - cancelled = true - } - }, [id, attempt]) + cancelled = true; + }; + }, [id, attempt]); if (loadError?.notFound) { return (

Book not found

- + ← Back to library
- ) + ); } if (loadError) { return (
{loadError.message} -
- ) + ); } - if (!book) return

Loading…

- const current = book + if (!book) return

Loading…

; + const current = book; async function saveProgress(currentPage: number) { - setProgressError(null) + setProgressError(null); try { - const updated = await api('PATCH', `/books/${current.id}`, { current_page: currentPage }) - setBook(updated) - setPage(String(updated.current_page)) + const updated = await api("PATCH", `/books/${current.id}`, { + current_page: currentPage, + }); + setBook(updated); + setPage(String(updated.current_page)); } catch (err) { - setProgressError(errorMessage(err)) + setProgressError(errorMessage(err)); } } async function deleteBook() { - setDeleteError(null) + setDeleteError(null); try { - await api('DELETE', `/books/${current.id}`) - navigate('/books', { replace: true }) + await api("DELETE", `/books/${current.id}`); + navigate("/books", { replace: true }); } catch (err) { - setDeleteError(errorMessage(err)) + setDeleteError(errorMessage(err)); } } function submitProgress(event: FormEvent) { - event.preventDefault() - void saveProgress(Number(page)) + event.preventDefault(); + void saveProgress(Number(page)); } return ( @@ -97,7 +120,10 @@ export function BookDetail() { -
+
{progressError && ( - )} @@ -133,18 +163,34 @@ export function BookDetail() { Edit - {confirmingDelete && ( - +

{`Delete “${book.title}”? This also deletes its reading journal.`}

- -
@@ -158,5 +204,5 @@ export function BookDetail() { - ) + ); } diff --git a/src/books/BookForm.test.tsx b/src/books/BookForm.test.tsx index dcd99ca..043625a 100644 --- a/src/books/BookForm.test.tsx +++ b/src/books/BookForm.test.tsx @@ -1,76 +1,107 @@ -import { screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { dune, renderApp, signedIn } from '../test/helpers' +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { dune, renderApp, signedIn } from "../test/helpers"; -const fresh = { ...dune, current_page: 0, status: 'not_started' as const } +const fresh = { ...dune, current_page: 0, status: "not_started" as const }; -describe('BookForm', () => { - it('adds a book and opens it', async () => { +describe("BookForm", () => { + it("adds a book and opens it", async () => { const calls = signedIn({ - 'POST /api/books': () => [201, fresh], - 'GET /api/books/7': () => [200, fresh], - }) - const user = renderApp('/books/new') - await screen.findByRole('option', { name: 'Science Fiction' }) - await user.type(screen.getByLabelText('Title'), 'Dune') - await user.type(screen.getByLabelText('Author'), 'Frank Herbert') - await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction') - await user.type(screen.getByLabelText('Total pages'), '380') - await user.click(screen.getByRole('button', { name: 'Add book' })) + "POST /api/books": () => [201, fresh], + "GET /api/books/7": () => [200, fresh], + }); + const user = renderApp("/books/new"); + await screen.findByRole("option", { name: "Science Fiction" }); + await user.type(screen.getByLabelText("Title"), "Dune"); + await user.type(screen.getByLabelText("Author"), "Frank Herbert"); + await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction"); + await user.type(screen.getByLabelText("Total pages"), "380"); + await user.click(screen.getByRole("button", { name: "Add book" })); - expect(await screen.findByRole('heading', { name: 'Dune', level: 1 })).toBeInTheDocument() + expect( + await screen.findByRole("heading", { name: "Dune", level: 1 }), + ).toBeInTheDocument(); expect(calls).toContainEqual({ - method: 'POST', - path: '/api/books', - body: { title: 'Dune', author: 'Frank Herbert', genre_id: 5, total_pages: 380, current_page: 0 }, - }) - }) - - it('shows a server validation error against the field', async () => { - signedIn({ 'POST /api/books': () => [400, { error: 'Unknown genre_id; see GET /api/genres', field: 'genre_id' }] }) - const user = renderApp('/books/new') - await screen.findByRole('option', { name: 'Science Fiction' }) - await user.type(screen.getByLabelText('Title'), 'Dune') - await user.type(screen.getByLabelText('Author'), 'Frank Herbert') - await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction') - await user.type(screen.getByLabelText('Total pages'), '380') - await user.click(screen.getByRole('button', { name: 'Add book' })) - - expect(await screen.findByRole('alert')).toHaveTextContent('Unknown genre_id') - expect(screen.getByLabelText('Genre')).toHaveAttribute('aria-invalid', 'true') - }) - - it('edits an existing book', async () => { - let saved = dune - const calls = signedIn({ - 'GET /api/books/7': () => [200, saved], - 'PATCH /api/books/7': ({ body }) => { - saved = { ...saved, ...(body as object) } - return [200, saved] + method: "POST", + path: "/api/books", + body: { + title: "Dune", + author: "Frank Herbert", + genre_id: 5, + total_pages: 380, + current_page: 0, }, - }) - const user = renderApp('/books/7/edit') - const title = await screen.findByDisplayValue('Dune') - await user.clear(title) - await user.type(title, 'Dune Messiah') - await user.click(screen.getByRole('button', { name: 'Save changes' })) + }); + }); - expect(await screen.findByRole('heading', { name: 'Dune Messiah', level: 1 })).toBeInTheDocument() - expect(calls).toContainEqual({ - method: 'PATCH', - path: '/api/books/7', - body: { title: 'Dune Messiah', author: 'Frank Herbert', genre_id: 5, total_pages: 380, current_page: 142 }, - }) - }) - - it('retries a failed load and shows the prefilled form', async () => { - let attempts = 0 + it("shows a server validation error against the field", async () => { signedIn({ - 'GET /api/books/7': () => (++attempts === 1 ? [500, { error: 'Database unavailable' }] : [200, dune]), - }) - const user = renderApp('/books/7/edit') - expect(await screen.findByRole('alert')).toHaveTextContent('Database unavailable') - await user.click(screen.getByRole('button', { name: 'Retry' })) - expect(await screen.findByLabelText('Title')).toHaveValue('Dune') - }) -}) + "POST /api/books": () => [ + 400, + { error: "Unknown genre_id; see GET /api/genres", field: "genre_id" }, + ], + }); + const user = renderApp("/books/new"); + await screen.findByRole("option", { name: "Science Fiction" }); + await user.type(screen.getByLabelText("Title"), "Dune"); + await user.type(screen.getByLabelText("Author"), "Frank Herbert"); + await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction"); + await user.type(screen.getByLabelText("Total pages"), "380"); + await user.click(screen.getByRole("button", { name: "Add book" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Unknown genre_id", + ); + expect(screen.getByLabelText("Genre")).toHaveAttribute( + "aria-invalid", + "true", + ); + }); + + it("edits an existing book", async () => { + let saved = dune; + const calls = signedIn({ + "GET /api/books/7": () => [200, saved], + "PATCH /api/books/7": ({ body }) => { + saved = { ...saved, ...(body as object) }; + return [200, saved]; + }, + }); + const user = renderApp("/books/7/edit"); + const title = await screen.findByDisplayValue("Dune"); + await user.clear(title); + await user.type(title, "Dune Messiah"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }), + ).toBeInTheDocument(); + expect(calls).toContainEqual({ + method: "PATCH", + path: "/api/books/7", + body: { + title: "Dune Messiah", + author: "Frank Herbert", + genre_id: 5, + total_pages: 380, + current_page: 142, + }, + }); + }); + + it("retries a failed load and shows the prefilled form", async () => { + let attempts = 0; + signedIn({ + "GET /api/books/7": () => + ++attempts === 1 + ? [500, { error: "Database unavailable" }] + : [200, dune], + }); + const user = renderApp("/books/7/edit"); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Database unavailable", + ); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByLabelText("Title")).toHaveValue("Dune"); + }); +}); diff --git a/src/books/BookForm.tsx b/src/books/BookForm.tsx index 2c301a9..ca351db 100644 --- a/src/books/BookForm.tsx +++ b/src/books/BookForm.tsx @@ -1,81 +1,105 @@ -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' +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 } +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' } +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(EMPTY) - const [loading, setLoading] = useState(editing) - const [loadError, setLoadError] = useState(null) - const [error, setError] = useState(null) - const [busy, setBusy] = useState(false) - const [attempt, setAttempt] = useState(0) + const { id } = useParams(); + const editing = id !== undefined; + const navigate = useNavigate(); + const { genres, error: genresError } = useGenres(); + const [fields, setFields] = useState(EMPTY); + const [loading, setLoading] = useState(editing); + const [loadError, setLoadError] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [attempt, setAttempt] = useState(0); useEffect(() => { - if (!editing) return - let cancelled = false - setLoadError(null) - setLoading(true) - api('GET', `/books/${id}`).then( + if (!editing) return; + let cancelled = false; + setLoadError(null); + setLoading(true); + api("GET", `/books/${id}`).then( (book) => { - if (cancelled) return + if (cancelled) return; 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) + }); + setLoading(false); }, (err) => { - if (cancelled) return - setLoadError(errorMessage(err)) - setLoading(false) + if (cancelled) return; + setLoadError(errorMessage(err)); + setLoading(false); }, - ) + ); return () => { - cancelled = true - } - }, [editing, id, attempt]) + cancelled = true; + }; + }, [editing, id, attempt]); function set(name: keyof Fields) { - return (event: { target: { value: string } }) => setFields((prev) => ({ ...prev, [name]: event.target.value })) + 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' } : {} + return error?.field === name + ? { "aria-invalid": true, "aria-describedby": "form-error" } + : {}; } async function submit(event: FormEvent) { - event.preventDefault() - setError(null) - setBusy(true) + 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('PATCH', `/books/${id}`, payload) - : await api('POST', '/books', payload) - navigate(`/books/${saved.id}`) + ? await api("PATCH", `/books/${id}`, payload) + : await api("POST", "/books", payload); + navigate(`/books/${saved.id}`); } catch (err) { - setError({ message: errorMessage(err), field: err instanceof ApiError ? err.field : undefined }) - setBusy(false) + setError({ + message: errorMessage(err), + field: err instanceof ApiError ? err.field : undefined, + }); + setBusy(false); } } @@ -84,7 +108,11 @@ export function BookForm() {

{loadError}

- @@ -92,24 +120,46 @@ export function BookForm() {
- ) + ); } - if (loading) return

Loading…

+ if (loading) return

Loading…

; return (
-

{editing ? 'Edit book' : 'Add a book'}

+

+ {editing ? "Edit book" : "Add a book"} +

- {genresError &&

{`Could not load genres: ${genresError}`}

} + {genresError && ( +

{`Could not load genres: ${genresError}`}

+ )}
@@ -154,12 +209,15 @@ export function BookForm() { )}
- + Cancel
- ) + ); } diff --git a/src/books/BookList.test.tsx b/src/books/BookList.test.tsx index 5471507..215266c 100644 --- a/src/books/BookList.test.tsx +++ b/src/books/BookList.test.tsx @@ -1,58 +1,86 @@ -import { screen, waitFor } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { dune, renderApp, signedIn } from '../test/helpers' +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') - }) +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("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("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("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("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() - }) -}) + 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(); + }); +}); diff --git a/src/books/BookList.tsx b/src/books/BookList.tsx index df5543d..70bf1b5 100644 --- a/src/books/BookList.tsx +++ b/src/books/BookList.tsx @@ -1,73 +1,90 @@ -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' +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 +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(null) - const [error, setError] = useState(null) - const [attempt, setAttempt] = useState(0) - const { genres, error: genresError } = useGenres() + const [params, setParams] = useSearchParams(); + const q = params.get("q") ?? ""; + const genre = params.get("genre") ?? ""; + const [search, setSearch] = useState(q); + const [books, setBooks] = useState(null); + const [error, setError] = useState(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 + 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(() => setSearch(q), [q]); useEffect(() => { - if (search === q) return - const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS) - return () => clearTimeout(timer) - }, [search, q]) + 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('GET', `/books?${query}`).then( + let cancelled = false; + const query = new URLSearchParams(); + if (q.trim()) query.set("q", q.trim()); + if (genre) query.set("genre_id", genre); + api("GET", `/books?${query}`).then( (found) => { - if (cancelled) return - setBooks(found) - setError(null) + if (cancelled) return; + setBooks(found); + setError(null); }, (err) => { - if (!cancelled) setError(errorMessage(err)) + if (!cancelled) setError(errorMessage(err)); }, - ) + ); return () => { - cancelled = true - } - }, [q, genre, attempt]) + cancelled = true; + }; + }, [q, genre, attempt]); return (
- ) + ); } diff --git a/src/books/Progress.tsx b/src/books/Progress.tsx index bba1a2d..5ca6e43 100644 --- a/src/books/Progress.tsx +++ b/src/books/Progress.tsx @@ -1,21 +1,23 @@ -import type { Book, ReadingStatus } from '../api' +import type { Book, ReadingStatus } from "../api"; const STATUS: Record = { - 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' }, -} + 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 ( - + {STATUS[status].label} - ) + ); } export function ProgressBar({ book }: { book: Book }) { - const percent = Math.round((book.current_page / book.total_pages) * 100) + const percent = Math.round((book.current_page / book.total_pages) * 100); return (

{`${book.current_page} / ${book.total_pages} (${percent}%)`}

- ) + ); } diff --git a/src/books/useGenres.ts b/src/books/useGenres.ts index c07fec4..c0a624f 100644 --- a/src/books/useGenres.ts +++ b/src/books/useGenres.ts @@ -1,11 +1,13 @@ -import { useEffect, useState } from 'react' -import { api, errorMessage, type Genre } from '../api' +import { useEffect, useState } from "react"; +import { api, errorMessage, type Genre } from "../api"; export function useGenres(): { genres: Genre[]; error: string | null } { - const [genres, setGenres] = useState([]) - const [error, setError] = useState(null) + const [genres, setGenres] = useState([]); + const [error, setError] = useState(null); useEffect(() => { - api('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err))) - }, []) - return { genres, error } + api("GET", "/genres").then(setGenres, (err) => + setError(errorMessage(err)), + ); + }, []); + return { genres, error }; } diff --git a/src/main.tsx b/src/main.tsx index a633310..2d79d5f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { App } from './App' -import './index.css' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./index.css"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/src/notes/NotesJournal.test.tsx b/src/notes/NotesJournal.test.tsx index bdf67b1..430c78a 100644 --- a/src/notes/NotesJournal.test.tsx +++ b/src/notes/NotesJournal.test.tsx @@ -1,98 +1,126 @@ -import { fireEvent, screen, within } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import type { Note } from '../api' -import { dune, renderApp, signedIn } from '../test/helpers' +import { fireEvent, 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', -} + 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 () => { +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 }) => [ + "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' }, + { + ...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' })) + }); + 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' } }) - }) + 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 () => { + 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 }) => [ + "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' }, + { + ...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' })) + }); + 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() - }) + expect( + await within(item).findByText("Finished part one"), + ).toBeInTheDocument(); + expect(within(item).getByText(/edited/)).toBeInTheDocument(); + }); - it('deletes an entry', async () => { + 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() - }) + "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 () => { + 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') - }) + "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", + ); + }); - it('posts a double-clicked entry only once', async () => { + it("posts a double-clicked entry only once", async () => { const calls = signedIn({ - 'GET /api/books/7': () => [200, dune], - 'GET /api/books/7/notes': () => [200, []], - 'POST /api/books/7/notes': ({ body }) => [201, { ...older, id: 12, body: (body as { body: string }).body }], - }) - const user = renderApp('/books/7') - await screen.findByText('No entries yet.') - await user.type(screen.getByLabelText('New journal entry'), 'Once only') - const add = screen.getByRole('button', { name: 'Add entry' }) - // Two clicks before the pending POST settles; user.dblClick would wait for it and mask the bug. - fireEvent.click(add) - fireEvent.click(add) + "GET /api/books/7": () => [200, dune], + "GET /api/books/7/notes": () => [200, []], + "POST /api/books/7/notes": ({ body }) => [ + 201, + { ...older, id: 12, body: (body as { body: string }).body }, + ], + }); + const user = renderApp("/books/7"); + await screen.findByText("No entries yet."); + await user.type(screen.getByLabelText("New journal entry"), "Once only"); + const add = screen.getByRole("button", { name: "Add entry" }); + fireEvent.click(add); + fireEvent.click(add); - await screen.findByText('Once only') - expect(calls.filter((c) => c.method === 'POST')).toHaveLength(1) - }) -}) + await screen.findByText("Once only"); + expect(calls.filter((c) => c.method === "POST")).toHaveLength(1); + }); +}); diff --git a/src/test/helpers.tsx b/src/test/helpers.tsx index 6793f1b..1bcb60f 100644 --- a/src/test/helpers.tsx +++ b/src/test/helpers.tsx @@ -1,65 +1,71 @@ -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' +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 alice: User = { id: 1, username: "alice" }; export const genres: Genre[] = [ - { id: 3, name: 'Fantasy' }, - { id: 5, name: 'Science Fiction' }, -] + { 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' }, + 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', -} + 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 } +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): Call[] { - const calls: Call[] = [] + const calls: Call[] = []; vi.stubGlobal( - 'fetch', + "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}` }] + 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' }, - }) + headers: { "Content-Type": "application/json" }, + }); }), - ) - return calls + ); + return calls; } export function signedIn(routes: Record = {}): Call[] { - return mockApi({ 'GET /api/auth/me': () => [200, alice], 'GET /api/genres': () => [200, genres], ...routes }) + return mockApi({ + "GET /api/auth/me": () => [200, alice], + "GET /api/genres": () => [200, genres], + ...routes, + }); } export function renderApp(url: string) { - const user = userEvent.setup() + const user = userEvent.setup(); render( , - ) - return user + ); + return user; } diff --git a/src/test/setup.ts b/src/test/setup.ts index 57dce13..0ecbed3 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,6 +1,5 @@ -import '@testing-library/jest-dom/vitest' -import { cleanup } from '@testing-library/react' -import { afterEach } from 'vitest' +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; -// Vitest globals are off, so Testing Library cannot register its own cleanup. -afterEach(() => cleanup()) +afterEach(() => cleanup()); diff --git a/src/ui.ts b/src/ui.ts index f56cdd4..1b4fd78 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,9 +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 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' + "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` + "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`;