style: drop redundant comments and apply formatting
Frontend now uses double quotes and semicolons. No behaviour change.
This commit is contained in:
@@ -21,8 +21,6 @@ def create_app() -> Flask:
|
|||||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
)
|
)
|
||||||
app = Flask(__name__)
|
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
|
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
auth.init_app(app)
|
auth.init_app(app)
|
||||||
@@ -35,8 +33,6 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def require_json_for_mutations():
|
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:
|
if request.method in MUTATING_METHODS and not request.is_json:
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
400, "Request body must be JSON with Content-Type: application/json"
|
400, "Request body must be JSON with Content-Type: application/json"
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ INVALID_LOGIN = "Invalid username or password"
|
|||||||
|
|
||||||
def init_app(app: Flask) -> None:
|
def init_app(app: Flask) -> None:
|
||||||
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
|
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)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -13,7 +13,7 @@ bp = Blueprint("books", __name__, url_prefix="/api")
|
|||||||
|
|
||||||
MAX_TEXT = 500
|
MAX_TEXT = 500
|
||||||
MAX_PAGES = 100_000
|
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}")
|
GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}")
|
||||||
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
|
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
|
||||||
FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
|
FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
|
||||||
@@ -56,7 +56,6 @@ def to_json(row: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def escape_like(term: str) -> str:
|
def escape_like(term: str) -> str:
|
||||||
# Backslash is Postgres's default LIKE escape character.
|
|
||||||
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +146,6 @@ def create_book():
|
|||||||
return jsonify(to_json(owned_book(row["id"]))), 201
|
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/<int(max=2147483647):book_id>")
|
@bp.get("/books/<int(max=2147483647):book_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def get_book(book_id: int):
|
def get_book(book_id: int):
|
||||||
|
|||||||
+1
-2
@@ -44,7 +44,6 @@ def _connection():
|
|||||||
|
|
||||||
|
|
||||||
def _finish_transaction(response):
|
def _finish_transaction(response):
|
||||||
# Commit only successful responses so a 4xx/5xx never leaves partial writes behind.
|
|
||||||
conn = g.get("db")
|
conn = g.get("db")
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
if response.status_code < 400:
|
if response.status_code < 400:
|
||||||
@@ -57,5 +56,5 @@ def _finish_transaction(response):
|
|||||||
def _release_connection(_exc):
|
def _release_connection(_exc):
|
||||||
conn = g.pop("db", None)
|
conn = g.pop("db", None)
|
||||||
if conn is not 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)
|
current_app.extensions["db_pool"].putconn(conn)
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS genres (
|
|||||||
name TEXT NOT NULL UNIQUE
|
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)
|
INSERT INTO genres (name)
|
||||||
SELECT seed.name
|
SELECT seed.name
|
||||||
FROM (VALUES ('Fiction'), ('Non-Fiction'), ('Mystery'), ('Thriller'), ('Science Fiction'),
|
FROM (VALUES ('Fiction'), ('Non-Fiction'), ('Mystery'), ('Thriller'), ('Science Fiction'),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ TEST_DATABASE = "books_test"
|
|||||||
|
|
||||||
def _ensure_test_database() -> None:
|
def _ensure_test_database() -> None:
|
||||||
conn = psycopg2.connect(ADMIN_URL)
|
conn = psycopg2.connect(ADMIN_URL)
|
||||||
conn.autocommit = True # CREATE DATABASE cannot run inside a transaction
|
conn.autocommit = True
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -28,6 +28,5 @@ def _ensure_test_database() -> None:
|
|||||||
|
|
||||||
|
|
||||||
_ensure_test_database()
|
_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["DATABASE_URL"] = ADMIN_URL.rsplit("/", 1)[0] + "/" + TEST_DATABASE
|
||||||
os.environ["PASSWORD_PEPPER"] = "test-pepper-0123456789abcdef0123456789abcdef"
|
os.environ["PASSWORD_PEPPER"] = "test-pepper-0123456789abcdef0123456789abcdef"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import psycopg2
|
|||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
|
|
||||||
HTTPS = "https://localhost" # the session cookie is Secure
|
HTTPS = "https://localhost"
|
||||||
|
|
||||||
|
|
||||||
def db_execute(sql: str, params: tuple = ()) -> list[tuple]:
|
def db_execute(sql: str, params: tuple = ()) -> list[tuple]:
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class AuthTests(ApiTestCase):
|
|||||||
|
|
||||||
def test_login_is_case_insensitive_on_username(self):
|
def test_login_is_case_insensitive_on_username(self):
|
||||||
self.register("Alice")
|
self.register("Alice")
|
||||||
self.client = app.test_client() # fresh cookie jar: signed out
|
self.client = app.test_client()
|
||||||
response = self.call(
|
response = self.call(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
@@ -116,9 +116,7 @@ class AuthTests(ApiTestCase):
|
|||||||
self.register()
|
self.register()
|
||||||
token = self.session_token()
|
token = self.session_token()
|
||||||
self.assertEqual(self.call("POST", "/api/auth/logout").status_code, 204)
|
self.assertEqual(self.call("POST", "/api/auth/logout").status_code, 204)
|
||||||
self.client.set_cookie(
|
self.client.set_cookie("sid", token, domain="localhost", path="/api")
|
||||||
"sid", token, domain="localhost", path="/api"
|
|
||||||
) # replay the stolen cookie
|
|
||||||
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
|
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
|
||||||
|
|
||||||
def test_idle_session_expires(self):
|
def test_idle_session_expires(self):
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class BookTests(ApiTestCase):
|
|||||||
(book["current_page"], book["total_pages"], book["status"]),
|
(book["current_page"], book["total_pages"], book["status"]),
|
||||||
(0, 412, "not_started"),
|
(0, 412, "not_started"),
|
||||||
)
|
)
|
||||||
self.assertIn("T", book["created_at"]) # ISO 8601
|
self.assertIn("T", book["created_at"])
|
||||||
|
|
||||||
def test_create_trims_text(self):
|
def test_create_trims_text(self):
|
||||||
book = self.create_book(title=" Dune ")
|
book = self.create_book(title=" Dune ")
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ class SearchTests(ApiTestCase):
|
|||||||
self.assertEqual(self.titles("q=tolkien"), ["The Hobbit"])
|
self.assertEqual(self.titles("q=tolkien"), ["The Hobbit"])
|
||||||
|
|
||||||
def test_wildcards_match_literally(self):
|
def test_wildcards_match_literally(self):
|
||||||
self.assertEqual(self.titles("q=100%25"), ["100% Pure"]) # %25 is "%"
|
self.assertEqual(self.titles("q=100%25"), ["100% Pure"])
|
||||||
self.assertEqual(self.titles("q=e_D"), ["100% Pure"]) # literal "_" in Jane_Doe
|
self.assertEqual(self.titles("q=e_D"), ["100% Pure"])
|
||||||
self.assertEqual(
|
self.assertEqual(self.titles("q=k_s"), [])
|
||||||
self.titles("q=k_s"), []
|
self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"])
|
||||||
) # unescaped "_" would match "Back\\slash"
|
|
||||||
self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"]) # %5C is "\"
|
|
||||||
|
|
||||||
def test_genre_filter_and_combination(self):
|
def test_genre_filter_and_combination(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -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:
|
def integer(body: dict, field: str, low: int, high: int) -> int:
|
||||||
value = body.get(field)
|
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):
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
raise ApiError(400, f"{field} must be a whole number", field)
|
raise ApiError(400, f"{field} must be a whole number", field)
|
||||||
if not low <= value <= high:
|
if not low <= value <= high:
|
||||||
|
|||||||
+29
-17
@@ -1,13 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from 'react-router'
|
import {
|
||||||
import { errorMessage } from './api'
|
BrowserRouter,
|
||||||
import { AuthProvider, useAuth } from './auth/AuthContext'
|
Link,
|
||||||
import { LoginForm } from './auth/LoginForm'
|
Navigate,
|
||||||
import { RequireAuth } from './auth/RequireAuth'
|
Outlet,
|
||||||
import { BookList } from './books/BookList'
|
Route,
|
||||||
import { BookDetail } from './books/BookDetail'
|
Routes,
|
||||||
import { BookForm } from './books/BookForm'
|
} from "react-router";
|
||||||
import { secondaryButton } from './ui'
|
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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +23,7 @@ export function App() {
|
|||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
@@ -33,12 +40,12 @@ export function AppRoutes() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/books" replace />} />
|
<Route path="*" element={<Navigate to="/books" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
<header className="border-b border-slate-200 bg-white">
|
<header className="border-b border-slate-200 bg-white">
|
||||||
@@ -51,14 +58,19 @@ function Shell() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={secondaryButton}
|
className={secondaryButton}
|
||||||
onClick={() => logout().catch((err) => setError(errorMessage(err)))}
|
onClick={() =>
|
||||||
|
logout().catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mx-auto max-w-4xl px-4 pb-2 text-sm text-red-700">
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mx-auto max-w-4xl px-4 pb-2 text-sm text-red-700"
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -67,5 +79,5 @@ function Shell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-33
@@ -1,54 +1,69 @@
|
|||||||
export type User = { id: number; username: string }
|
export type User = { id: number; username: string };
|
||||||
export type Genre = { id: number; name: string }
|
export type Genre = { id: number; name: string };
|
||||||
export type ReadingStatus = 'not_started' | 'reading' | 'finished'
|
export type ReadingStatus = "not_started" | "reading" | "finished";
|
||||||
export type Book = {
|
export type Book = {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
author: string
|
author: string;
|
||||||
genre: Genre
|
genre: Genre;
|
||||||
total_pages: number
|
total_pages: number;
|
||||||
current_page: number
|
current_page: number;
|
||||||
status: ReadingStatus
|
status: ReadingStatus;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
}
|
};
|
||||||
export type Note = { id: number; book_id: number; body: string; 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 {
|
export class ApiError extends Error {
|
||||||
readonly status: number
|
readonly status: number;
|
||||||
readonly field?: string
|
readonly field?: string;
|
||||||
|
|
||||||
constructor(status: number, message: string, field?: string) {
|
constructor(status: number, message: string, field?: string) {
|
||||||
super(message)
|
super(message);
|
||||||
this.status = status
|
this.status = status;
|
||||||
this.field = field
|
this.field = field;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let handleUnauthorized: () => void = () => {}
|
let handleUnauthorized: () => void = () => {};
|
||||||
|
|
||||||
export function setUnauthorizedHandler(handler: () => void) {
|
export function setUnauthorizedHandler(handler: () => void) {
|
||||||
handleUnauthorized = handler
|
handleUnauthorized = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T> {
|
export async function api<T>(
|
||||||
|
method: "GET" | "POST" | "PATCH" | "DELETE",
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
const response = await fetch(`/api${path}`, {
|
const response = await fetch(`/api${path}`, {
|
||||||
method,
|
method,
|
||||||
credentials: 'same-origin',
|
credentials: "same-origin",
|
||||||
// Always JSON: the server rejects mutations without it (CSRF defense, ADR-0002).
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
})
|
});
|
||||||
if (response.status === 204) return undefined as T
|
if (response.status === 204) return undefined as T;
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null);
|
||||||
if (!response.ok) {
|
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/"))
|
||||||
if (response.status === 401 && !path.startsWith('/auth/')) handleUnauthorized()
|
handleUnauthorized();
|
||||||
throw new ApiError(response.status, data?.error ?? `Request failed with status ${response.status}`, data?.field)
|
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 {
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-24
@@ -1,42 +1,52 @@
|
|||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import {
|
||||||
import { api, setUnauthorizedHandler, type User } from '../api'
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { api, setUnauthorizedHandler, type User } from "../api";
|
||||||
|
|
||||||
type AuthValue = {
|
type AuthValue = {
|
||||||
user: User | null
|
user: User | null;
|
||||||
loading: boolean
|
loading: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<void>;
|
||||||
register: (username: string, password: string) => Promise<void>
|
register: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>;
|
||||||
}
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthValue | null>(null)
|
const AuthContext = createContext<AuthValue | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnauthorizedHandler(() => setUser(null))
|
setUnauthorizedHandler(() => setUser(null));
|
||||||
api<User>('GET', '/auth/me')
|
api<User>("GET", "/auth/me")
|
||||||
.then(setUser, () => setUser(null))
|
.then(setUser, () => setUser(null))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false));
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const value: AuthValue = {
|
const value: AuthValue = {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
login: async (username, password) => setUser(await api<User>('POST', '/auth/login', { username, password })),
|
login: async (username, password) =>
|
||||||
register: async (username, password) => setUser(await api<User>('POST', '/auth/register', { username, password })),
|
setUser(await api<User>("POST", "/auth/login", { username, password })),
|
||||||
|
register: async (username, password) =>
|
||||||
|
setUser(
|
||||||
|
await api<User>("POST", "/auth/register", { username, password }),
|
||||||
|
),
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await api('POST', '/auth/logout')
|
await api("POST", "/auth/logout");
|
||||||
setUser(null)
|
setUser(null);
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
return <AuthContext value={value}>{children}</AuthContext>
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth(): AuthValue {
|
export function useAuth(): AuthValue {
|
||||||
const value = useContext(AuthContext)
|
const value = useContext(AuthContext);
|
||||||
if (!value) throw new Error('useAuth must be used inside <AuthProvider>')
|
if (!value) throw new Error("useAuth must be used inside <AuthProvider>");
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-31
@@ -1,42 +1,46 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from "react";
|
||||||
import { Navigate, useLocation, type Location } from 'react-router'
|
import { Navigate, useLocation, type Location } from "react-router";
|
||||||
import { errorMessage } from '../api'
|
import { errorMessage } from "../api";
|
||||||
import { card, inputClass, labelText, primaryButton } from '../ui'
|
import { card, inputClass, labelText, primaryButton } from "../ui";
|
||||||
import { useAuth } from './AuthContext'
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { user, login, register } = useAuth()
|
const { user, login, register } = useAuth();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const from = (location.state as { from?: Location } | null)?.from
|
const from = (location.state as { from?: Location } | null)?.from;
|
||||||
return <Navigate to={from ? from.pathname + from.search : '/books'} replace />
|
return (
|
||||||
|
<Navigate to={from ? from.pathname + from.search : "/books"} replace />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLogin = mode === 'login'
|
const isLogin = mode === "login";
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
setError(null)
|
setError(null);
|
||||||
setBusy(true)
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await (isLogin ? login : register)(username, password)
|
await (isLogin ? login : register)(username, password);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err))
|
setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 px-4 pt-16">
|
<div className="min-h-screen bg-slate-50 px-4 pt-16">
|
||||||
<main className={`mx-auto max-w-sm ${card}`}>
|
<main className={`mx-auto max-w-sm ${card}`}>
|
||||||
<h1 className="mb-4 text-2xl font-semibold">{isLogin ? 'Sign in' : 'Create account'}</h1>
|
<h1 className="mb-4 text-2xl font-semibold">
|
||||||
|
{isLogin ? "Sign in" : "Create account"}
|
||||||
|
</h1>
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="space-y-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={labelText}>Username</span>
|
<span className={labelText}>Username</span>
|
||||||
@@ -46,7 +50,7 @@ export function LoginForm() {
|
|||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
aria-invalid={error ? true : undefined}
|
aria-invalid={error ? true : undefined}
|
||||||
aria-describedby={error ? 'login-error' : undefined}
|
aria-describedby={error ? "login-error" : undefined}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -57,10 +61,10 @@ export function LoginForm() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||||
minLength={isLogin ? undefined : 12}
|
minLength={isLogin ? undefined : 12}
|
||||||
aria-invalid={error ? true : undefined}
|
aria-invalid={error ? true : undefined}
|
||||||
aria-describedby={error ? 'login-error' : undefined}
|
aria-describedby={error ? "login-error" : undefined}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -69,21 +73,25 @@ export function LoginForm() {
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button type="submit" disabled={busy} className={`${primaryButton} w-full`}>
|
<button
|
||||||
{isLogin ? 'Sign in' : 'Create account'}
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className={`${primaryButton} w-full`}
|
||||||
|
>
|
||||||
|
{isLogin ? "Sign in" : "Create account"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-4 text-sm text-indigo-700 underline"
|
className="mt-4 text-sm text-indigo-700 underline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode(isLogin ? 'register' : 'login')
|
setMode(isLogin ? "register" : "login");
|
||||||
setError(null)
|
setError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLogin ? 'Need an account? Register' : 'Have an account? Sign in'}
|
{isLogin ? "Need an account? Register" : "Have an account? Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
import { Navigate, Outlet, useLocation } from "react-router";
|
||||||
import { useAuth } from './AuthContext'
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
export function RequireAuth() {
|
export function RequireAuth() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
if (loading) return <p className="p-6 text-slate-500">Loading…</p>
|
if (loading) return <p className="p-6 text-slate-500">Loading…</p>;
|
||||||
if (!user) return <Navigate to="/login" replace state={{ from: location }} />
|
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
return <Outlet />
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-59
@@ -1,70 +1,110 @@
|
|||||||
import { screen } from '@testing-library/react'
|
import { screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import { alice, dune, genres, mockApi, renderApp, signedIn } from '../test/helpers'
|
import {
|
||||||
|
alice,
|
||||||
|
dune,
|
||||||
|
genres,
|
||||||
|
mockApi,
|
||||||
|
renderApp,
|
||||||
|
signedIn,
|
||||||
|
} from "../test/helpers";
|
||||||
|
|
||||||
describe('auth flow', () => {
|
describe("auth flow", () => {
|
||||||
it('sends signed-out users to sign in, then back to the page they wanted', async () => {
|
it("sends signed-out users to sign in, then back to the page they wanted", async () => {
|
||||||
const calls = mockApi({
|
const calls = mockApi({
|
||||||
'GET /api/auth/me': () => [401, { error: 'Not signed in or session expired; log in again' }],
|
"GET /api/auth/me": () => [
|
||||||
'POST /api/auth/login': () => [200, alice],
|
401,
|
||||||
'GET /api/genres': () => [200, genres],
|
{ error: "Not signed in or session expired; log in again" },
|
||||||
'GET /api/books': () => [200, [dune]],
|
],
|
||||||
})
|
"POST /api/auth/login": () => [200, alice],
|
||||||
const user = renderApp('/books?q=dune')
|
"GET /api/genres": () => [200, genres],
|
||||||
|
"GET /api/books": () => [200, [dune]],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books?q=dune");
|
||||||
|
|
||||||
await screen.findByRole('heading', { name: 'Sign in' })
|
await screen.findByRole("heading", { name: "Sign in" });
|
||||||
await user.type(screen.getByLabelText('Username'), 'alice')
|
await user.type(screen.getByLabelText("Username"), "alice");
|
||||||
await user.type(screen.getByLabelText('Password'), 'correct horse battery')
|
await user.type(screen.getByLabelText("Password"), "correct horse battery");
|
||||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
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({
|
expect(calls).toContainEqual({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
path: '/api/auth/login',
|
path: "/api/auth/login",
|
||||||
body: { username: 'alice', password: 'correct horse battery' },
|
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({
|
mockApi({
|
||||||
'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
|
"GET /api/auth/me": () => [401, { error: "Not signed in" }],
|
||||||
'POST /api/auth/login': () => [401, { error: 'Invalid username or password' }],
|
"POST /api/auth/login": () => [
|
||||||
})
|
401,
|
||||||
const user = renderApp('/login')
|
{ error: "Invalid username or password" },
|
||||||
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' }))
|
const user = renderApp("/login");
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid username or password')
|
await user.type(await screen.findByLabelText("Username"), "alice");
|
||||||
expect(screen.getByLabelText('Password')).toHaveAttribute('aria-describedby', 'login-error')
|
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({
|
const calls = mockApi({
|
||||||
'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
|
"GET /api/auth/me": () => [401, { error: "Not signed in" }],
|
||||||
'POST /api/auth/register': () => [201, alice],
|
"POST /api/auth/register": () => [201, alice],
|
||||||
'GET /api/genres': () => [200, genres],
|
"GET /api/genres": () => [200, genres],
|
||||||
'GET /api/books': () => [200, []],
|
"GET /api/books": () => [200, []],
|
||||||
})
|
});
|
||||||
const user = renderApp('/login')
|
const user = renderApp("/login");
|
||||||
await user.click(await screen.findByRole('button', { name: 'Need an account? Register' }))
|
await user.click(
|
||||||
await user.type(screen.getByLabelText('Username'), 'alice')
|
await screen.findByRole("button", { name: "Need an account? Register" }),
|
||||||
await user.type(screen.getByLabelText('Password'), 'correct horse battery')
|
);
|
||||||
await user.click(screen.getByRole('button', { name: 'Create account' }))
|
await user.type(screen.getByLabelText("Username"), "alice");
|
||||||
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
await user.type(screen.getByLabelText("Password"), "correct horse battery");
|
||||||
expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/register')).toBe(true)
|
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 () => {
|
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' }] })
|
signedIn({
|
||||||
renderApp('/books')
|
"GET /api/books": () => [
|
||||||
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
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 () => {
|
it("logs out", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, []], 'POST /api/auth/logout': () => [204] })
|
const calls = signedIn({
|
||||||
const user = renderApp('/books')
|
"GET /api/books": () => [200, []],
|
||||||
await user.click(await screen.findByRole('button', { name: 'Log out' }))
|
"POST /api/auth/logout": () => [204],
|
||||||
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
});
|
||||||
expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/logout')).toBe(true)
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,72 +1,112 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react'
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Book } from '../api'
|
import type { Book } from "../api";
|
||||||
import { dune, renderApp, signedIn } from '../test/helpers'
|
import { dune, renderApp, signedIn } from "../test/helpers";
|
||||||
|
|
||||||
function withProgress(current_page: number): Book {
|
function withProgress(current_page: number): Book {
|
||||||
const status = current_page === 0 ? 'not_started' : current_page === dune.total_pages ? 'finished' : 'reading'
|
const status =
|
||||||
return { ...dune, current_page, status }
|
current_page === 0
|
||||||
|
? "not_started"
|
||||||
|
: current_page === dune.total_pages
|
||||||
|
? "finished"
|
||||||
|
: "reading";
|
||||||
|
return { ...dune, current_page, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('BookDetail', () => {
|
describe("BookDetail", () => {
|
||||||
it('updates progress and reflects it in the bar', async () => {
|
it("updates progress and reflects it in the bar", async () => {
|
||||||
const calls = signedIn({
|
const calls = signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'PATCH /api/books/7': ({ body }) => [200, withProgress((body as { current_page: number }).current_page)],
|
"PATCH /api/books/7": ({ body }) => [
|
||||||
})
|
200,
|
||||||
const user = renderApp('/books/7')
|
withProgress((body as { current_page: number }).current_page),
|
||||||
expect(await screen.findByText('142 / 380 (37%)')).toBeInTheDocument()
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
expect(await screen.findByText("142 / 380 (37%)")).toBeInTheDocument();
|
||||||
|
|
||||||
const input = screen.getByLabelText('Current page')
|
const input = screen.getByLabelText("Current page");
|
||||||
await user.clear(input)
|
await user.clear(input);
|
||||||
await user.type(input, '200')
|
await user.type(input, "200");
|
||||||
await user.click(screen.getByRole('button', { name: 'Save progress' }))
|
await user.click(screen.getByRole("button", { name: "Save progress" }));
|
||||||
|
|
||||||
expect(await screen.findByText('200 / 380 (53%)')).toBeInTheDocument()
|
expect(await screen.findByText("200 / 380 (53%)")).toBeInTheDocument();
|
||||||
expect(calls).toContainEqual({ method: 'PATCH', path: '/api/books/7', body: { current_page: 200 } })
|
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({
|
signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'PATCH /api/books/7': () => [200, withProgress(380)],
|
"PATCH /api/books/7": () => [200, withProgress(380)],
|
||||||
})
|
});
|
||||||
const user = renderApp('/books/7')
|
const user = renderApp("/books/7");
|
||||||
await user.click(await screen.findByRole('button', { name: 'Mark finished' }))
|
await user.click(
|
||||||
expect(await screen.findByText('380 / 380 (100%)')).toBeInTheDocument()
|
await screen.findByRole("button", { name: "Mark finished" }),
|
||||||
expect(screen.getByText('Finished')).toBeInTheDocument()
|
);
|
||||||
})
|
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({
|
signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'PATCH /api/books/7': () => [400, { error: 'current_page must be between 0 and 100000', field: 'current_page' }],
|
"PATCH /api/books/7": () => [
|
||||||
})
|
400,
|
||||||
const user = renderApp('/books/7')
|
{
|
||||||
await user.click(await screen.findByRole('button', { name: 'Save progress' }))
|
error: "current_page must be between 0 and 100000",
|
||||||
// findByText, not findByRole('alert'): once Task 11 mounts the journal, the page may show more than one alert.
|
field: "current_page",
|
||||||
expect(await screen.findByText('current_page must be between 0 and 100000')).toHaveAttribute('role', 'alert')
|
},
|
||||||
expect(screen.getByLabelText('Current page')).toHaveAttribute('aria-invalid', 'true')
|
],
|
||||||
})
|
});
|
||||||
|
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 () => {
|
it("shows not found for a missing or foreign book", async () => {
|
||||||
signedIn({ 'GET /api/books/999': () => [404, { error: 'Book not found' }] })
|
signedIn({
|
||||||
renderApp('/books/999')
|
"GET /api/books/999": () => [404, { error: "Book not found" }],
|
||||||
expect(await screen.findByRole('heading', { name: 'Book not found' })).toBeInTheDocument()
|
});
|
||||||
expect(screen.getByRole('link', { name: /back to library/i })).toHaveAttribute('href', '/books')
|
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({
|
const calls = signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'DELETE /api/books/7': () => [204],
|
"DELETE /api/books/7": () => [204],
|
||||||
'GET /api/books': () => [200, []],
|
"GET /api/books": () => [200, []],
|
||||||
})
|
});
|
||||||
const user = renderApp('/books/7')
|
const user = renderApp("/books/7");
|
||||||
await user.click(await screen.findByRole('button', { name: 'Delete book' }))
|
await user.click(
|
||||||
expect(screen.getByRole('dialog')).toHaveTextContent('Delete “Dune”?')
|
await screen.findByRole("button", { name: "Delete book" }),
|
||||||
await user.click(screen.getByRole('button', { name: 'Yes, delete' }))
|
);
|
||||||
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
expect(screen.getByRole("dialog")).toHaveTextContent("Delete “Dune”?");
|
||||||
await waitFor(() => expect(calls.some((c) => c.method === 'DELETE' && c.path === '/api/books/7')).toBe(true))
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+97
-51
@@ -1,86 +1,109 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
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 {
|
||||||
import { NotesJournal } from '../notes/NotesJournal'
|
card,
|
||||||
import { ProgressBar, StatusBadge } from './Progress'
|
dangerButton,
|
||||||
|
inputClass,
|
||||||
|
labelText,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
} from "../ui";
|
||||||
|
import { NotesJournal } from "../notes/NotesJournal";
|
||||||
|
import { ProgressBar, StatusBadge } from "./Progress";
|
||||||
|
|
||||||
export function BookDetail() {
|
export function BookDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [book, setBook] = useState<Book | null>(null)
|
const [book, setBook] = useState<Book | null>(null);
|
||||||
const [loadError, setLoadError] = useState<{ notFound: boolean; message: string } | null>(null)
|
const [loadError, setLoadError] = useState<{
|
||||||
const [attempt, setAttempt] = useState(0)
|
notFound: boolean;
|
||||||
const [page, setPage] = useState('')
|
message: string;
|
||||||
const [progressError, setProgressError] = useState<string | null>(null)
|
} | null>(null);
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
const [attempt, setAttempt] = useState(0);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
const [page, setPage] = useState("");
|
||||||
|
const [progressError, setProgressError] = useState<string | null>(null);
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false;
|
||||||
setLoadError(null)
|
setLoadError(null);
|
||||||
api<Book>('GET', `/books/${id}`).then(
|
api<Book>("GET", `/books/${id}`).then(
|
||||||
(found) => {
|
(found) => {
|
||||||
if (cancelled) return
|
if (cancelled) return;
|
||||||
setBook(found)
|
setBook(found);
|
||||||
setPage(String(found.current_page))
|
setPage(String(found.current_page));
|
||||||
},
|
},
|
||||||
(err) => {
|
(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 () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [id, attempt])
|
}, [id, attempt]);
|
||||||
|
|
||||||
if (loadError?.notFound) {
|
if (loadError?.notFound) {
|
||||||
return (
|
return (
|
||||||
<div className={card}>
|
<div className={card}>
|
||||||
<h1 className="text-xl font-semibold">Book not found</h1>
|
<h1 className="text-xl font-semibold">Book not found</h1>
|
||||||
<Link to="/books" className="mt-2 inline-block text-indigo-700 underline">
|
<Link
|
||||||
|
to="/books"
|
||||||
|
className="mt-2 inline-block text-indigo-700 underline"
|
||||||
|
>
|
||||||
← Back to library
|
← Back to library
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="flex items-center gap-3 text-red-700">
|
<div role="alert" className="flex items-center gap-3 text-red-700">
|
||||||
<span>{loadError.message}</span>
|
<span>{loadError.message}</span>
|
||||||
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setAttempt((n) => n + 1)}
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (!book) return <p className="text-slate-500">Loading…</p>
|
if (!book) return <p className="text-slate-500">Loading…</p>;
|
||||||
const current = book
|
const current = book;
|
||||||
|
|
||||||
async function saveProgress(currentPage: number) {
|
async function saveProgress(currentPage: number) {
|
||||||
setProgressError(null)
|
setProgressError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await api<Book>('PATCH', `/books/${current.id}`, { current_page: currentPage })
|
const updated = await api<Book>("PATCH", `/books/${current.id}`, {
|
||||||
setBook(updated)
|
current_page: currentPage,
|
||||||
setPage(String(updated.current_page))
|
});
|
||||||
|
setBook(updated);
|
||||||
|
setPage(String(updated.current_page));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setProgressError(errorMessage(err))
|
setProgressError(errorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteBook() {
|
async function deleteBook() {
|
||||||
setDeleteError(null)
|
setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
await api('DELETE', `/books/${current.id}`)
|
await api("DELETE", `/books/${current.id}`);
|
||||||
navigate('/books', { replace: true })
|
navigate("/books", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteError(errorMessage(err))
|
setDeleteError(errorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitProgress(event: FormEvent) {
|
function submitProgress(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
void saveProgress(Number(page))
|
void saveProgress(Number(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,7 +120,10 @@ export function BookDetail() {
|
|||||||
<StatusBadge status={book.status} />
|
<StatusBadge status={book.status} />
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar book={book} />
|
<ProgressBar book={book} />
|
||||||
<form onSubmit={submitProgress} className="mt-4 flex flex-wrap items-end gap-2">
|
<form
|
||||||
|
onSubmit={submitProgress}
|
||||||
|
className="mt-4 flex flex-wrap items-end gap-2"
|
||||||
|
>
|
||||||
<label>
|
<label>
|
||||||
<span className={labelText}>Current page</span>
|
<span className={labelText}>Current page</span>
|
||||||
<input
|
<input
|
||||||
@@ -109,7 +135,7 @@ export function BookDetail() {
|
|||||||
onChange={(e) => setPage(e.target.value)}
|
onChange={(e) => setPage(e.target.value)}
|
||||||
required
|
required
|
||||||
aria-invalid={progressError ? true : undefined}
|
aria-invalid={progressError ? true : undefined}
|
||||||
aria-describedby={progressError ? 'progress-error' : undefined}
|
aria-describedby={progressError ? "progress-error" : undefined}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className={primaryButton}>
|
<button type="submit" className={primaryButton}>
|
||||||
@@ -125,7 +151,11 @@ export function BookDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{progressError && (
|
{progressError && (
|
||||||
<p id="progress-error" role="alert" className="mt-2 text-sm text-red-700">
|
<p
|
||||||
|
id="progress-error"
|
||||||
|
role="alert"
|
||||||
|
className="mt-2 text-sm text-red-700"
|
||||||
|
>
|
||||||
{progressError}
|
{progressError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -133,18 +163,34 @@ export function BookDetail() {
|
|||||||
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
|
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
<button type="button" className={dangerButton} onClick={() => setConfirmingDelete(true)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={dangerButton}
|
||||||
|
onClick={() => setConfirmingDelete(true)}
|
||||||
|
>
|
||||||
Delete book
|
Delete book
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{confirmingDelete && (
|
{confirmingDelete && (
|
||||||
<dialog open aria-labelledby="delete-prompt" className={`relative mt-4 ${card} ring-2 ring-red-200`}>
|
<dialog
|
||||||
|
open
|
||||||
|
aria-labelledby="delete-prompt"
|
||||||
|
className={`relative mt-4 ${card} ring-2 ring-red-200`}
|
||||||
|
>
|
||||||
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
|
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button type="button" className={dangerButton} onClick={() => void deleteBook()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={dangerButton}
|
||||||
|
onClick={() => void deleteBook()}
|
||||||
|
>
|
||||||
Yes, delete
|
Yes, delete
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={secondaryButton} onClick={() => setConfirmingDelete(false)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setConfirmingDelete(false)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,5 +204,5 @@ export function BookDetail() {
|
|||||||
</header>
|
</header>
|
||||||
<NotesJournal bookId={book.id} />
|
<NotesJournal bookId={book.id} />
|
||||||
</article>
|
</article>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-68
@@ -1,76 +1,107 @@
|
|||||||
import { screen } from '@testing-library/react'
|
import { screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import { dune, renderApp, signedIn } from '../test/helpers'
|
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', () => {
|
describe("BookForm", () => {
|
||||||
it('adds a book and opens it', async () => {
|
it("adds a book and opens it", async () => {
|
||||||
const calls = signedIn({
|
const calls = signedIn({
|
||||||
'POST /api/books': () => [201, fresh],
|
"POST /api/books": () => [201, fresh],
|
||||||
'GET /api/books/7': () => [200, fresh],
|
"GET /api/books/7": () => [200, fresh],
|
||||||
})
|
});
|
||||||
const user = renderApp('/books/new')
|
const user = renderApp("/books/new");
|
||||||
await screen.findByRole('option', { name: 'Science Fiction' })
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
await user.type(screen.getByLabelText('Title'), 'Dune')
|
await user.type(screen.getByLabelText("Title"), "Dune");
|
||||||
await user.type(screen.getByLabelText('Author'), 'Frank Herbert')
|
await user.type(screen.getByLabelText("Author"), "Frank Herbert");
|
||||||
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
|
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
|
||||||
await user.type(screen.getByLabelText('Total pages'), '380')
|
await user.type(screen.getByLabelText("Total pages"), "380");
|
||||||
await user.click(screen.getByRole('button', { name: 'Add book' }))
|
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({
|
expect(calls).toContainEqual({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
path: '/api/books',
|
path: "/api/books",
|
||||||
body: { title: 'Dune', author: 'Frank Herbert', genre_id: 5, total_pages: 380, current_page: 0 },
|
body: {
|
||||||
})
|
title: "Dune",
|
||||||
})
|
author: "Frank Herbert",
|
||||||
|
genre_id: 5,
|
||||||
it('shows a server validation error against the field', async () => {
|
total_pages: 380,
|
||||||
signedIn({ 'POST /api/books': () => [400, { error: 'Unknown genre_id; see GET /api/genres', field: 'genre_id' }] })
|
current_page: 0,
|
||||||
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()
|
it("shows a server validation error against the field", async () => {
|
||||||
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({
|
signedIn({
|
||||||
'GET /api/books/7': () => (++attempts === 1 ? [500, { error: 'Database unavailable' }] : [200, dune]),
|
"POST /api/books": () => [
|
||||||
})
|
400,
|
||||||
const user = renderApp('/books/7/edit')
|
{ error: "Unknown genre_id; see GET /api/genres", field: "genre_id" },
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Database unavailable')
|
],
|
||||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
});
|
||||||
expect(await screen.findByLabelText('Title')).toHaveValue('Dune')
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+117
-59
@@ -1,81 +1,105 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
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, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
import {
|
||||||
import { useGenres } from './useGenres'
|
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 Fields = {
|
||||||
type FormError = { message: string; field?: string }
|
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() {
|
export function BookForm() {
|
||||||
const { id } = useParams()
|
const { id } = useParams();
|
||||||
const editing = id !== undefined
|
const editing = id !== undefined;
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { genres, error: genresError } = useGenres()
|
const { genres, error: genresError } = useGenres();
|
||||||
const [fields, setFields] = useState<Fields>(EMPTY)
|
const [fields, setFields] = useState<Fields>(EMPTY);
|
||||||
const [loading, setLoading] = useState(editing)
|
const [loading, setLoading] = useState(editing);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<FormError | null>(null)
|
const [error, setError] = useState<FormError | null>(null);
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false);
|
||||||
const [attempt, setAttempt] = useState(0)
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) return
|
if (!editing) return;
|
||||||
let cancelled = false
|
let cancelled = false;
|
||||||
setLoadError(null)
|
setLoadError(null);
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
api<Book>('GET', `/books/${id}`).then(
|
api<Book>("GET", `/books/${id}`).then(
|
||||||
(book) => {
|
(book) => {
|
||||||
if (cancelled) return
|
if (cancelled) return;
|
||||||
setFields({
|
setFields({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
genre_id: String(book.genre.id),
|
genre_id: String(book.genre.id),
|
||||||
total_pages: String(book.total_pages),
|
total_pages: String(book.total_pages),
|
||||||
current_page: String(book.current_page),
|
current_page: String(book.current_page),
|
||||||
})
|
});
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
if (cancelled) return
|
if (cancelled) return;
|
||||||
setLoadError(errorMessage(err))
|
setLoadError(errorMessage(err));
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [editing, id, attempt])
|
}, [editing, id, attempt]);
|
||||||
|
|
||||||
function set(name: keyof Fields) {
|
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) {
|
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) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
setError(null)
|
setError(null);
|
||||||
setBusy(true)
|
setBusy(true);
|
||||||
const payload = {
|
const payload = {
|
||||||
title: fields.title,
|
title: fields.title,
|
||||||
author: fields.author,
|
author: fields.author,
|
||||||
genre_id: Number(fields.genre_id),
|
genre_id: Number(fields.genre_id),
|
||||||
total_pages: Number(fields.total_pages),
|
total_pages: Number(fields.total_pages),
|
||||||
current_page: Number(fields.current_page),
|
current_page: Number(fields.current_page),
|
||||||
}
|
};
|
||||||
try {
|
try {
|
||||||
const saved = editing
|
const saved = editing
|
||||||
? await api<Book>('PATCH', `/books/${id}`, payload)
|
? await api<Book>("PATCH", `/books/${id}`, payload)
|
||||||
: await api<Book>('POST', '/books', payload)
|
: await api<Book>("POST", "/books", payload);
|
||||||
navigate(`/books/${saved.id}`)
|
navigate(`/books/${saved.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError({ message: errorMessage(err), field: err instanceof ApiError ? err.field : undefined })
|
setError({
|
||||||
setBusy(false)
|
message: errorMessage(err),
|
||||||
|
field: err instanceof ApiError ? err.field : undefined,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +108,11 @@ export function BookForm() {
|
|||||||
<div role="alert" className={card}>
|
<div role="alert" className={card}>
|
||||||
<p className="text-red-700">{loadError}</p>
|
<p className="text-red-700">{loadError}</p>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
<div className="mt-2 flex items-center gap-3">
|
||||||
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setAttempt((n) => n + 1)}
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
<Link to="/books" className="text-indigo-700 underline">
|
<Link to="/books" className="text-indigo-700 underline">
|
||||||
@@ -92,24 +120,46 @@ export function BookForm() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (loading) return <p className="text-slate-500">Loading…</p>
|
if (loading) return <p className="text-slate-500">Loading…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className={`space-y-4 ${card}`}>
|
<form onSubmit={submit} className={`space-y-4 ${card}`}>
|
||||||
<h1 className="text-2xl font-semibold">{editing ? 'Edit book' : 'Add a book'}</h1>
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{editing ? "Edit book" : "Add a book"}
|
||||||
|
</h1>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={labelText}>Title</span>
|
<span className={labelText}>Title</span>
|
||||||
<input className={inputClass} value={fields.title} onChange={set('title')} maxLength={500} required {...invalid('title')} />
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={fields.title}
|
||||||
|
onChange={set("title")}
|
||||||
|
maxLength={500}
|
||||||
|
required
|
||||||
|
{...invalid("title")}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={labelText}>Author</span>
|
<span className={labelText}>Author</span>
|
||||||
<input className={inputClass} value={fields.author} onChange={set('author')} maxLength={500} required {...invalid('author')} />
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={fields.author}
|
||||||
|
onChange={set("author")}
|
||||||
|
maxLength={500}
|
||||||
|
required
|
||||||
|
{...invalid("author")}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={labelText}>Genre</span>
|
<span className={labelText}>Genre</span>
|
||||||
<select className={inputClass} value={fields.genre_id} onChange={set('genre_id')} required {...invalid('genre_id')}>
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={fields.genre_id}
|
||||||
|
onChange={set("genre_id")}
|
||||||
|
required
|
||||||
|
{...invalid("genre_id")}
|
||||||
|
>
|
||||||
<option value="">Choose a genre</option>
|
<option value="">Choose a genre</option>
|
||||||
{genres.map((g) => (
|
{genres.map((g) => (
|
||||||
<option key={g.id} value={String(g.id)}>
|
<option key={g.id} value={String(g.id)}>
|
||||||
@@ -118,7 +168,12 @@ export function BookForm() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{genresError && <p role="alert" className="text-sm text-red-700">{`Could not load genres: ${genresError}`}</p>}
|
{genresError && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-sm text-red-700"
|
||||||
|
>{`Could not load genres: ${genresError}`}</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={labelText}>Total pages</span>
|
<span className={labelText}>Total pages</span>
|
||||||
@@ -128,9 +183,9 @@ export function BookForm() {
|
|||||||
min={1}
|
min={1}
|
||||||
max={100000}
|
max={100000}
|
||||||
value={fields.total_pages}
|
value={fields.total_pages}
|
||||||
onChange={set('total_pages')}
|
onChange={set("total_pages")}
|
||||||
required
|
required
|
||||||
{...invalid('total_pages')}
|
{...invalid("total_pages")}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -141,9 +196,9 @@ export function BookForm() {
|
|||||||
min={0}
|
min={0}
|
||||||
max={fields.total_pages || undefined}
|
max={fields.total_pages || undefined}
|
||||||
value={fields.current_page}
|
value={fields.current_page}
|
||||||
onChange={set('current_page')}
|
onChange={set("current_page")}
|
||||||
required
|
required
|
||||||
{...invalid('current_page')}
|
{...invalid("current_page")}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,12 +209,15 @@ export function BookForm() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={busy} className={primaryButton}>
|
<button type="submit" disabled={busy} className={primaryButton}>
|
||||||
{editing ? 'Save changes' : 'Add book'}
|
{editing ? "Save changes" : "Add book"}
|
||||||
</button>
|
</button>
|
||||||
<Link to={editing ? `/books/${id}` : '/books'} className={secondaryButton}>
|
<Link
|
||||||
|
to={editing ? `/books/${id}` : "/books"}
|
||||||
|
className={secondaryButton}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-52
@@ -1,58 +1,86 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react'
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import { dune, renderApp, signedIn } from '../test/helpers'
|
import { dune, renderApp, signedIn } from "../test/helpers";
|
||||||
|
|
||||||
describe('BookList', () => {
|
describe("BookList", () => {
|
||||||
it('shows each book with author, genre, status, and progress', async () => {
|
it("shows each book with author, genre, status, and progress", async () => {
|
||||||
signedIn({ 'GET /api/books': () => [200, [dune]] })
|
signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
renderApp('/books')
|
renderApp("/books");
|
||||||
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
|
expect(
|
||||||
expect(screen.getByText('Frank Herbert · Science Fiction')).toBeInTheDocument()
|
await screen.findByRole("heading", { name: "Dune" }),
|
||||||
expect(screen.getByText('Reading')).toBeInTheDocument()
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('142 / 380 (37%)')).toBeInTheDocument()
|
expect(
|
||||||
expect(screen.getByRole('link', { name: /Dune/ })).toHaveAttribute('href', '/books/7')
|
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 () => {
|
it("distinguishes an empty library from no matches", async () => {
|
||||||
signedIn({ 'GET /api/books': () => [200, []] })
|
signedIn({ "GET /api/books": () => [200, []] });
|
||||||
const user = renderApp('/books')
|
const user = renderApp("/books");
|
||||||
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
expect(
|
||||||
await user.type(screen.getByLabelText('Search title or author'), 'zzz')
|
await screen.findByText("No books yet. Add your first one."),
|
||||||
expect(await screen.findByText('No matches. Try a different search or genre.')).toBeInTheDocument()
|
).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 () => {
|
it("debounces search into the URL and the request", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
const user = renderApp('/books')
|
const user = renderApp("/books");
|
||||||
await screen.findByRole('heading', { name: 'Dune' })
|
await screen.findByRole("heading", { name: "Dune" });
|
||||||
await user.type(screen.getByLabelText('Search title or author'), 'herbert')
|
await user.type(screen.getByLabelText("Search title or author"), "herbert");
|
||||||
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?q=herbert'))
|
await waitFor(() =>
|
||||||
expect(calls.filter((c) => c.path.startsWith('/api/books?q=h')).map((c) => c.path)).toEqual(['/api/books?q=herbert'])
|
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 () => {
|
it("filters by genre", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
const user = renderApp('/books')
|
const user = renderApp("/books");
|
||||||
await screen.findByRole('option', { name: 'Science Fiction' })
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
|
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
|
||||||
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?genre_id=5'))
|
await waitFor(() =>
|
||||||
})
|
expect(calls.map((c) => c.path)).toContain("/api/books?genre_id=5"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('restores search and genre from the URL', async () => {
|
it("restores search and genre from the URL", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
renderApp('/books?q=dune&genre=5')
|
renderApp("/books?q=dune&genre=5");
|
||||||
await screen.findByRole('option', { name: 'Science Fiction' })
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
expect(screen.getByLabelText('Search title or author')).toHaveValue('dune')
|
expect(screen.getByLabelText("Search title or author")).toHaveValue("dune");
|
||||||
expect(screen.getByLabelText('Genre')).toHaveValue('5')
|
expect(screen.getByLabelText("Genre")).toHaveValue("5");
|
||||||
expect(calls.map((c) => c.path)).toContain('/api/books?q=dune&genre_id=5')
|
expect(calls.map((c) => c.path)).toContain("/api/books?q=dune&genre_id=5");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('offers retry when loading fails', async () => {
|
it("offers retry when loading fails", async () => {
|
||||||
let attempts = 0
|
let attempts = 0;
|
||||||
signedIn({ 'GET /api/books': () => (++attempts === 1 ? [500, { error: 'Unexpected server error' }] : [200, [dune]]) })
|
signedIn({
|
||||||
const user = renderApp('/books')
|
"GET /api/books": () =>
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected server error')
|
++attempts === 1
|
||||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
? [500, { error: "Unexpected server error" }]
|
||||||
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
|
: [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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+67
-44
@@ -1,73 +1,90 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useSearchParams } from 'react-router'
|
import { Link, useSearchParams } from "react-router";
|
||||||
import { api, errorMessage, type Book } from '../api'
|
import { api, errorMessage, type Book } from "../api";
|
||||||
import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
import {
|
||||||
import { ProgressBar, StatusBadge } from './Progress'
|
card,
|
||||||
import { useGenres } from './useGenres'
|
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() {
|
export function BookList() {
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams();
|
||||||
const q = params.get('q') ?? ''
|
const q = params.get("q") ?? "";
|
||||||
const genre = params.get('genre') ?? ''
|
const genre = params.get("genre") ?? "";
|
||||||
const [search, setSearch] = useState(q)
|
const [search, setSearch] = useState(q);
|
||||||
const [books, setBooks] = useState<Book[] | null>(null)
|
const [books, setBooks] = useState<Book[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [attempt, setAttempt] = useState(0)
|
const [attempt, setAttempt] = useState(0);
|
||||||
const { genres, error: genresError } = useGenres()
|
const { genres, error: genresError } = useGenres();
|
||||||
|
|
||||||
function updateParam(key: string, value: string) {
|
function updateParam(key: string, value: string) {
|
||||||
setParams(
|
setParams(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
const next = new URLSearchParams(prev)
|
const next = new URLSearchParams(prev);
|
||||||
if (value) next.set(key, value)
|
if (value) next.set(key, value);
|
||||||
else next.delete(key)
|
else next.delete(key);
|
||||||
return next
|
return next;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ 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(() => {
|
useEffect(() => {
|
||||||
if (search === q) return
|
if (search === q) return;
|
||||||
const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS)
|
const timer = setTimeout(
|
||||||
return () => clearTimeout(timer)
|
() => updateParam("q", search),
|
||||||
}, [search, q])
|
SEARCH_DEBOUNCE_MS,
|
||||||
|
);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search, q]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false;
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams();
|
||||||
if (q.trim()) query.set('q', q.trim())
|
if (q.trim()) query.set("q", q.trim());
|
||||||
if (genre) query.set('genre_id', genre)
|
if (genre) query.set("genre_id", genre);
|
||||||
api<Book[]>('GET', `/books?${query}`).then(
|
api<Book[]>("GET", `/books?${query}`).then(
|
||||||
(found) => {
|
(found) => {
|
||||||
if (cancelled) return
|
if (cancelled) return;
|
||||||
setBooks(found)
|
setBooks(found);
|
||||||
setError(null)
|
setError(null);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
if (!cancelled) setError(errorMessage(err))
|
if (!cancelled) setError(errorMessage(err));
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [q, genre, attempt])
|
}, [q, genre, attempt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||||
<label className="min-w-48 grow">
|
<label className="min-w-48 grow">
|
||||||
<span className={labelText}>Search title or author</span>
|
<span className={labelText}>Search title or author</span>
|
||||||
<input type="search" className={inputClass} value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input
|
||||||
|
type="search"
|
||||||
|
className={inputClass}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span className={labelText}>Genre</span>
|
<span className={labelText}>Genre</span>
|
||||||
<select className={inputClass} value={genre} onChange={(e) => updateParam('genre', e.target.value)}>
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={genre}
|
||||||
|
onChange={(e) => updateParam("genre", e.target.value)}
|
||||||
|
>
|
||||||
<option value="">All genres</option>
|
<option value="">All genres</option>
|
||||||
{genres.map((g) => (
|
{genres.map((g) => (
|
||||||
<option key={g.id} value={String(g.id)}>
|
<option key={g.id} value={String(g.id)}>
|
||||||
@@ -88,7 +105,11 @@ export function BookList() {
|
|||||||
{error ? (
|
{error ? (
|
||||||
<div role="alert" className="flex items-center gap-3 text-red-700">
|
<div role="alert" className="flex items-center gap-3 text-red-700">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setAttempt((n) => n + 1)}
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +117,9 @@ export function BookList() {
|
|||||||
<p className="text-slate-500">Loading…</p>
|
<p className="text-slate-500">Loading…</p>
|
||||||
) : books.length === 0 ? (
|
) : books.length === 0 ? (
|
||||||
<p className="text-slate-600">
|
<p className="text-slate-600">
|
||||||
{q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'}
|
{q.trim() || genre
|
||||||
|
? "No matches. Try a different search or genre."
|
||||||
|
: "No books yet. Add your first one."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-3 sm:grid-cols-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -118,5 +141,5 @@ export function BookList() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-9
@@ -1,21 +1,23 @@
|
|||||||
import type { Book, ReadingStatus } from '../api'
|
import type { Book, ReadingStatus } from "../api";
|
||||||
|
|
||||||
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
|
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
|
||||||
not_started: { label: 'Not started', style: 'bg-slate-100 text-slate-700' },
|
not_started: { label: "Not started", style: "bg-slate-100 text-slate-700" },
|
||||||
reading: { label: 'Reading', style: 'bg-amber-100 text-amber-800' },
|
reading: { label: "Reading", style: "bg-amber-100 text-amber-800" },
|
||||||
finished: { label: 'Finished', style: 'bg-emerald-100 text-emerald-800' },
|
finished: { label: "Finished", style: "bg-emerald-100 text-emerald-800" },
|
||||||
}
|
};
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: ReadingStatus }) {
|
export function StatusBadge({ status }: { status: ReadingStatus }) {
|
||||||
return (
|
return (
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}>
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}
|
||||||
|
>
|
||||||
{STATUS[status].label}
|
{STATUS[status].label}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressBar({ book }: { book: Book }) {
|
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 (
|
return (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<progress
|
<progress
|
||||||
@@ -26,5 +28,5 @@ export function ProgressBar({ book }: { book: Book }) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
|
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage, type Genre } from '../api'
|
import { api, errorMessage, type Genre } from "../api";
|
||||||
|
|
||||||
export function useGenres(): { genres: Genre[]; error: string | null } {
|
export function useGenres(): { genres: Genre[]; error: string | null } {
|
||||||
const [genres, setGenres] = useState<Genre[]>([])
|
const [genres, setGenres] = useState<Genre[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Genre[]>('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err)))
|
api<Genre[]>("GET", "/genres").then(setGenres, (err) =>
|
||||||
}, [])
|
setError(errorMessage(err)),
|
||||||
return { genres, error }
|
);
|
||||||
|
}, []);
|
||||||
|
return { genres, error };
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from './App'
|
import { App } from "./App";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
+105
-77
@@ -1,98 +1,126 @@
|
|||||||
import { fireEvent, screen, within } from '@testing-library/react'
|
import { fireEvent, screen, within } from "@testing-library/react";
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Note } from '../api'
|
import type { Note } from "../api";
|
||||||
import { dune, renderApp, signedIn } from '../test/helpers'
|
import { dune, renderApp, signedIn } from "../test/helpers";
|
||||||
|
|
||||||
const older: Note = {
|
const older: Note = {
|
||||||
id: 11,
|
id: 11,
|
||||||
book_id: 7,
|
book_id: 7,
|
||||||
body: 'Started reading',
|
body: "Started reading",
|
||||||
created_at: '2026-10-01T10:00:00+00:00',
|
created_at: "2026-10-01T10:00:00+00:00",
|
||||||
updated_at: '2026-10-01T10:00:00+00:00',
|
updated_at: "2026-10-01T10:00:00+00:00",
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('NotesJournal', () => {
|
describe("NotesJournal", () => {
|
||||||
it('lists entries and adds a new one at the top', async () => {
|
it("lists entries and adds a new one at the top", async () => {
|
||||||
const calls = signedIn({
|
const calls = signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'GET /api/books/7/notes': () => [200, [older]],
|
"GET /api/books/7/notes": () => [200, [older]],
|
||||||
'POST /api/books/7/notes': ({ body }) => [
|
"POST /api/books/7/notes": ({ body }) => [
|
||||||
201,
|
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')
|
const user = renderApp("/books/7");
|
||||||
expect(await screen.findByText('Started reading')).toBeInTheDocument()
|
expect(await screen.findByText("Started reading")).toBeInTheDocument();
|
||||||
await user.type(screen.getByLabelText('New journal entry'), 'Loved chapter 3')
|
await user.type(
|
||||||
await user.click(screen.getByRole('button', { name: 'Add entry' }))
|
screen.getByLabelText("New journal entry"),
|
||||||
|
"Loved chapter 3",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add entry" }));
|
||||||
|
|
||||||
await screen.findByText('Loved chapter 3')
|
await screen.findByText("Loved chapter 3");
|
||||||
const items = screen.getAllByRole('listitem')
|
const items = screen.getAllByRole("listitem");
|
||||||
expect(items[0]).toHaveTextContent('Loved chapter 3')
|
expect(items[0]).toHaveTextContent("Loved chapter 3");
|
||||||
expect(items[1]).toHaveTextContent('Started reading')
|
expect(items[1]).toHaveTextContent("Started reading");
|
||||||
expect(screen.getByLabelText('New journal entry')).toHaveValue('')
|
expect(screen.getByLabelText("New journal entry")).toHaveValue("");
|
||||||
expect(calls).toContainEqual({ method: 'POST', path: '/api/books/7/notes', body: { body: 'Loved chapter 3' } })
|
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({
|
signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'GET /api/books/7/notes': () => [200, [older]],
|
"GET /api/books/7/notes": () => [200, [older]],
|
||||||
'PATCH /api/notes/11': ({ body }) => [
|
"PATCH /api/notes/11": ({ body }) => [
|
||||||
200,
|
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 user = renderApp("/books/7");
|
||||||
const item = (await screen.findByText('Started reading')).closest('li')!
|
const item = (await screen.findByText("Started reading")).closest("li")!;
|
||||||
await user.click(within(item).getByRole('button', { name: 'Edit entry' }))
|
await user.click(within(item).getByRole("button", { name: "Edit entry" }));
|
||||||
const editor = within(item).getByLabelText('Edit entry')
|
const editor = within(item).getByLabelText("Edit entry");
|
||||||
await user.clear(editor)
|
await user.clear(editor);
|
||||||
await user.type(editor, 'Finished part one')
|
await user.type(editor, "Finished part one");
|
||||||
await user.click(within(item).getByRole('button', { name: 'Save' }))
|
await user.click(within(item).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
expect(await within(item).findByText('Finished part one')).toBeInTheDocument()
|
expect(
|
||||||
expect(within(item).getByText(/edited/)).toBeInTheDocument()
|
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({
|
signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'GET /api/books/7/notes': () => [200, [older]],
|
"GET /api/books/7/notes": () => [200, [older]],
|
||||||
'DELETE /api/notes/11': () => [204],
|
"DELETE /api/notes/11": () => [204],
|
||||||
})
|
});
|
||||||
const user = renderApp('/books/7')
|
const user = renderApp("/books/7");
|
||||||
await user.click(await screen.findByRole('button', { name: 'Delete entry' }))
|
await user.click(
|
||||||
expect(await screen.findByText('No entries yet.')).toBeInTheDocument()
|
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({
|
signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'GET /api/books/7/notes': () => [200, []],
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
'POST /api/books/7/notes': () => [400, { error: 'body must be at most 10000 characters', field: 'body' }],
|
"POST /api/books/7/notes": () => [
|
||||||
})
|
400,
|
||||||
const user = renderApp('/books/7')
|
{ error: "body must be at most 10000 characters", field: "body" },
|
||||||
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')
|
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({
|
const calls = signedIn({
|
||||||
'GET /api/books/7': () => [200, dune],
|
"GET /api/books/7": () => [200, dune],
|
||||||
'GET /api/books/7/notes': () => [200, []],
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
'POST /api/books/7/notes': ({ body }) => [201, { ...older, id: 12, body: (body as { body: string }).body }],
|
"POST /api/books/7/notes": ({ body }) => [
|
||||||
})
|
201,
|
||||||
const user = renderApp('/books/7')
|
{ ...older, id: 12, body: (body as { body: string }).body },
|
||||||
await screen.findByText('No entries yet.')
|
],
|
||||||
await user.type(screen.getByLabelText('New journal entry'), 'Once only')
|
});
|
||||||
const add = screen.getByRole('button', { name: 'Add entry' })
|
const user = renderApp("/books/7");
|
||||||
// Two clicks before the pending POST settles; user.dblClick would wait for it and mask the bug.
|
await screen.findByText("No entries yet.");
|
||||||
fireEvent.click(add)
|
await user.type(screen.getByLabelText("New journal entry"), "Once only");
|
||||||
fireEvent.click(add)
|
const add = screen.getByRole("button", { name: "Add entry" });
|
||||||
|
fireEvent.click(add);
|
||||||
|
fireEvent.click(add);
|
||||||
|
|
||||||
await screen.findByText('Once only')
|
await screen.findByText("Once only");
|
||||||
expect(calls.filter((c) => c.method === 'POST')).toHaveLength(1)
|
expect(calls.filter((c) => c.method === "POST")).toHaveLength(1);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
+44
-38
@@ -1,65 +1,71 @@
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from "@testing-library/react";
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from 'react-router'
|
import { MemoryRouter } from "react-router";
|
||||||
import { vi } from 'vitest'
|
import { vi } from "vitest";
|
||||||
import type { Book, Genre, User } from '../api'
|
import type { Book, Genre, User } from "../api";
|
||||||
import { AppRoutes } from '../App'
|
import { AppRoutes } from "../App";
|
||||||
import { AuthProvider } from '../auth/AuthContext'
|
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[] = [
|
export const genres: Genre[] = [
|
||||||
{ id: 3, name: 'Fantasy' },
|
{ id: 3, name: "Fantasy" },
|
||||||
{ id: 5, name: 'Science Fiction' },
|
{ id: 5, name: "Science Fiction" },
|
||||||
]
|
];
|
||||||
export const dune: Book = {
|
export const dune: Book = {
|
||||||
id: 7,
|
id: 7,
|
||||||
title: 'Dune',
|
title: "Dune",
|
||||||
author: 'Frank Herbert',
|
author: "Frank Herbert",
|
||||||
genre: { id: 5, name: 'Science Fiction' },
|
genre: { id: 5, name: "Science Fiction" },
|
||||||
total_pages: 380,
|
total_pages: 380,
|
||||||
current_page: 142,
|
current_page: 142,
|
||||||
status: 'reading',
|
status: "reading",
|
||||||
created_at: '2026-10-01T10:00:00+00:00',
|
created_at: "2026-10-01T10:00:00+00:00",
|
||||||
updated_at: '2026-10-01T10:00:00+00:00',
|
updated_at: "2026-10-01T10:00:00+00:00",
|
||||||
}
|
};
|
||||||
|
|
||||||
type Reply = [status: number, body?: unknown]
|
type Reply = [status: number, body?: unknown];
|
||||||
type Handler = (request: { body: unknown; url: URL }) => Reply
|
type Handler = (request: { body: unknown; url: URL }) => Reply;
|
||||||
export type Call = { method: string; path: string; body: unknown }
|
export type Call = { method: string; path: string; body: unknown };
|
||||||
|
|
||||||
/** Replaces fetch (the network boundary) with route handlers keyed by "METHOD /api/path". */
|
|
||||||
export function mockApi(routes: Record<string, Handler>): Call[] {
|
export function mockApi(routes: Record<string, Handler>): Call[] {
|
||||||
const calls: Call[] = []
|
const calls: Call[] = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
"fetch",
|
||||||
vi.fn(async (input: string, init: RequestInit = {}) => {
|
vi.fn(async (input: string, init: RequestInit = {}) => {
|
||||||
const url = new URL(input, 'http://localhost')
|
const url = new URL(input, "http://localhost");
|
||||||
const method = init.method ?? 'GET'
|
const method = init.method ?? "GET";
|
||||||
const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
|
const body =
|
||||||
calls.push({ method, path: url.pathname + url.search, body })
|
typeof init.body === "string" ? JSON.parse(init.body) : undefined;
|
||||||
const handler = routes[`${method} ${url.pathname}`]
|
calls.push({ method, path: url.pathname + url.search, body });
|
||||||
const [status, data] = handler ? handler({ body, url }) : [500, { error: `No mock for ${method} ${url.pathname}` }]
|
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), {
|
return new Response(status === 204 ? null : JSON.stringify(data), {
|
||||||
status,
|
status,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
});
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
return calls
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signedIn(routes: Record<string, Handler> = {}): Call[] {
|
export function signedIn(routes: Record<string, Handler> = {}): 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) {
|
export function renderApp(url: string) {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[url]}>
|
<MemoryRouter initialEntries={[url]}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
)
|
);
|
||||||
return user
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -1,6 +1,5 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach } from 'vitest'
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
// Vitest globals are off, so Testing Library cannot register its own cleanup.
|
afterEach(() => cleanup());
|
||||||
afterEach(() => cleanup())
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const card = 'rounded-lg bg-white p-4 shadow'
|
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 labelText = "mb-1 block text-sm font-medium text-slate-700";
|
||||||
export const inputClass =
|
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 =
|
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'
|
"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 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 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`
|
export const dangerButton = `${button} bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600`;
|
||||||
|
|||||||
Reference in New Issue
Block a user