From a177b02145969bb086cec8d2a10be5f56f8ff670 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Wed, 3 Jun 2026 21:56:54 +0200 Subject: [PATCH] =?UTF-8?q?docs(plan):=20frontend=20SPA=20milestone=201=20?= =?UTF-8?q?=E2=80=94=20task-by-task=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-03-frontend-spa-milestone-1.md | 1675 +++++++++++++++++ 1 file changed, 1675 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md diff --git a/docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md b/docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md new file mode 100644 index 0000000..54817c7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-frontend-spa-milestone-1.md @@ -0,0 +1,1675 @@ +# Frontend SPA — Milestone 1 (Foundation Slice) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A working `web/` React SPA that lets an admin log in, browse a paginated object list, read a record (two-pane master–detail), switch sv/en, and log out — embeddable into the `server` binary via `memory-serve`. + +**Architecture:** Vite + React + TypeScript SPA in `web/`, consuming the existing utoipa OpenAPI through a generated typed client (`openapi-typescript` types + `openapi-fetch`). Server state via TanStack Query; UI via shadcn/ui (Tailwind); i18n via react-i18next. Tests run in Vitest with React Testing Library and MSW (typed mock handlers). In dev, Vite proxies `/api` to the Rust server; in release, `pnpm build` output is embedded into the `server` binary behind a `embed-web` cargo feature and served at `/` with SPA fallback. + +**Tech Stack:** pnpm, Vite 6, React 19, TypeScript 5, Tailwind 4 + shadcn/ui, @tanstack/react-query 5, openapi-typescript + openapi-fetch, react-i18next, Vitest + @testing-library/react + msw, Rust `memory-serve` (server crate). + +**Reference spec:** `docs/superpowers/specs/2026-06-03-frontend-spa-milestone-1-design.md` + +**Backend contract (verified, already shipped):** +- `POST /api/admin/login` body `{email,password}` → `204` + session cookie; bad creds → `401`. +- `POST /api/admin/logout` → `204`. +- `GET /api/admin/me` → `200 {id,email,role}` (`UserView`); no session → `401`. +- `GET /api/admin/objects?limit&offset` → `200 {items:[AdminObjectView],total,limit,offset}` (`ViewInternal`). +- `GET /api/admin/objects/{id}` → `200 AdminObjectView` | `404`. +- `GET /api/admin/field-definitions` → `200 [FieldDefinitionView]` (`{key,data_type,vocabulary_id?,authority_kind?,required,group?,labels:[{lang,label}]}`). +- `AdminObjectView` = `{id, object_number, object_name, number_of_objects, brief_description?, current_location?, current_owner?, recorder?, recording_date?, visibility:"draft"|"internal"|"public", fields: object}`. +- OpenAPI JSON served at `GET /api-docs/openapi.json`. + +**Prerequisites for the implementer:** +- A running backend for `gen:api` and manual checks: `DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev cargo run -p server` (server on `:8080`). Create a test admin: `BOOTSTRAP_PASSWORD=pw-admin-123 cargo run -p server -- create-user --email admin@example.com --role admin`. +- Node ≥ 20, `pnpm` ≥ 9. All web commands run from `web/`. +- The codename "biggus"/"dickus" must appear NOWHERE in code, config, or text. + +--- + +## Task 1: Scaffold `web/` (Vite + React + TS) and a green test runner + +**Files:** +- Create: `web/package.json`, `web/pnpm-lock.yaml`, `web/tsconfig.json`, `web/tsconfig.node.json`, `web/vite.config.ts`, `web/index.html`, `web/src/main.tsx`, `web/src/app.tsx`, `web/src/vite-env.d.ts` +- Create test: `web/src/app.test.tsx` +- Modify: `.gitignore` (add `web/node_modules`, `web/dist`) + +- [ ] **Step 1: Scaffold with Vite (non-interactive), then pin scripts** + +Run from repo root: +```bash +pnpm create vite@latest web -- --template react-ts +cd web +pnpm install +pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom +``` + +- [ ] **Step 2: Replace `web/package.json` scripts** so the toolchain is explicit + +```json +{ + "name": "web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -b --noEmit", + "lint": "eslint .", + "gen:api": "openapi-typescript http://localhost:8080/api-docs/openapi.json -o src/api/schema.d.ts" + } +} +``` +(Keep the `dependencies`/`devDependencies` blocks Vite generated; later tasks add to them.) + +- [ ] **Step 3: Configure Vite — dev proxy + Vitest** + +Replace `web/vite.config.ts`: +```ts +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:8080", + "/api-docs": "http://localhost:8080", + "/health": "http://localhost:8080", + }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + }, +}); +``` +Create `web/src/test/setup.ts` (filled out in Task 4; for now just jest-dom): +```ts +import "@testing-library/jest-dom/vitest"; +``` + +- [ ] **Step 4: Write the failing test** `web/src/app.test.tsx` + +```tsx +import { render, screen } from "@testing-library/react"; +import { App } from "./app"; + +test("renders the app placeholder", () => { + render(); + expect(screen.getByRole("heading", { name: /collection/i })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 5: Run the test to verify it fails** + +Run: `pnpm test` +Expected: FAIL — `./app` has no `App` export (Vite scaffolded `App.tsx`, not `app.tsx`). + +- [ ] **Step 6: Replace the scaffold with a minimal app** + +Delete `web/src/App.tsx`, `web/src/App.css`, `web/src/index.css`, `web/src/assets` (Vite demo cruft). + +Create `web/src/app.tsx`: +```tsx +export function App() { + return

Collection

; +} +``` +Replace `web/src/main.tsx`: +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./app"; + +createRoot(document.getElementById("root")!).render( + + + , +); +``` +Set `web/index.html` `` to `Collection` and keep `<div id="root"></div>` + the `main.tsx` module script. + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `pnpm test` +Expected: PASS (1 test). +Run: `pnpm build` +Expected: typechecks and builds to `web/dist`. + +- [ ] **Step 8: Ignore build artifacts and commit** + +Append to repo-root `.gitignore`: +``` +web/node_modules/ +web/dist/ +``` +```bash +cd .. +git add web .gitignore +git commit -m "feat(web): scaffold Vite + React + TS SPA with Vitest" +``` + +--- + +## Task 2: Tailwind 4 + shadcn/ui + ESLint + +**Files:** +- Create: `web/src/index.css`, `web/components.json`, `web/eslint.config.js`, `web/src/lib/utils.ts`, `web/src/components/ui/button.tsx` +- Modify: `web/vite.config.ts`, `web/src/main.tsx`, `web/tsconfig.json`, `web/package.json` + +- [ ] **Step 1: Install Tailwind 4 + the Vite plugin + path alias tooling** + +```bash +cd web +pnpm add -D tailwindcss @tailwindcss/vite @types/node +pnpm add class-variance-authority clsx tailwind-merge lucide-react +``` + +- [ ] **Step 2: Wire Tailwind into Vite and add the `@` path alias** + +In `web/vite.config.ts` add the Tailwind plugin and resolve alias: +```ts +import tailwindcss from "@tailwindcss/vite"; +import path from "node:path"; +// plugins: [react(), tailwindcss()], +// add after plugins: +// resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, +``` +In `web/tsconfig.json` add under `compilerOptions`: +```json +"baseUrl": ".", +"paths": { "@/*": ["./src/*"] } +``` +Create `web/src/index.css`: +```css +@import "tailwindcss"; +``` +Import it in `web/src/main.tsx` (add `import "./index.css";` at the top). + +- [ ] **Step 3: Initialise shadcn/ui (non-interactive) and add Button** + +```bash +pnpm dlx shadcn@latest init -d -b neutral +pnpm dlx shadcn@latest add button +``` +This creates `components.json`, `src/lib/utils.ts`, and `src/components/ui/button.tsx`. If `init` prompts despite `-d`, accept: style "new-york", base color "neutral", CSS variables yes. + +- [ ] **Step 4: Add ESLint flat config** + +```bash +pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-react-refresh globals +``` +Create `web/eslint.config.js`: +```js +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "src/components/ui", "src/api/schema.d.ts"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { ecmaVersion: 2022, globals: globals.browser }, + plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh }, + rules: { ...reactHooks.configs.recommended.rules }, + }, +); +``` + +- [ ] **Step 5: Verify build, lint, and the Task 1 test still pass** + +Run: `pnpm lint` → Expected: no errors. +Run: `pnpm test` → Expected: PASS. +Run: `pnpm build` → Expected: builds (Tailwind processed). + +- [ ] **Step 6: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): Tailwind 4 + shadcn/ui + ESLint" +``` + +--- + +## Task 3: Generated OpenAPI types + typed `openapi-fetch` client (401 redirect) + +**Files:** +- Create: `web/src/api/schema.d.ts` (generated, committed), `web/src/api/client.ts`, `web/src/api/auth-redirect.ts` +- Test: `web/src/api/client.test.ts` +- Modify: `web/package.json` + +- [ ] **Step 1: Install the client libraries and generate types from the running server** + +```bash +cd web +pnpm add openapi-fetch +pnpm add -D openapi-typescript +``` +Start the backend in another terminal (see Prerequisites), then: +```bash +pnpm gen:api +``` +Expected: `src/api/schema.d.ts` is written with `paths` including `/api/admin/objects`, `/api/admin/me`, etc. Commit this generated file (do not gitignore it). + +- [ ] **Step 2: Write the failing test** `web/src/api/client.test.ts` + +```ts +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { server } from "../test/server"; +import { http, HttpResponse } from "msw"; +import { api } from "./client"; +import * as redirect from "./auth-redirect"; + +describe("api client", () => { + beforeEach(() => server.resetHandlers()); + + test("returns typed data on success", async () => { + server.use( + http.get("/api/admin/me", () => + HttpResponse.json({ id: "u1", email: "a@b.se", role: "admin" }), + ), + ); + const { data, error } = await api.GET("/api/admin/me"); + expect(error).toBeUndefined(); + expect(data?.email).toBe("a@b.se"); + }); + + test("a 401 triggers the auth redirect", async () => { + const spy = vi.spyOn(redirect, "redirectToLogin").mockImplementation(() => {}); + server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 }))); + await api.GET("/api/admin/me"); + expect(spy).toHaveBeenCalledOnce(); + }); +}); +``` +(`../test/server` is created in Task 4; this test will not run until then.) + +- [ ] **Step 3: Implement the client + redirect helper** + +Create `web/src/api/auth-redirect.ts`: +```ts +/** Hard-navigate to login. Isolated so it can be spied/mocked in tests and swapped + * for a router navigation if needed. */ +export function redirectToLogin(): void { + if (window.location.pathname !== "/login") { + window.location.assign("/login"); + } +} +``` +Create `web/src/api/client.ts`: +```ts +import createClient, { type Middleware } from "openapi-fetch"; +import type { paths } from "./schema"; +import { redirectToLogin } from "./auth-redirect"; + +const onUnauthorized: Middleware = { + async onResponse({ response }) { + if (response.status === 401) { + redirectToLogin(); + } + return response; + }, +}; + +export const api = createClient<paths>({ credentials: "include" }); +api.use(onUnauthorized); +``` + +- [ ] **Step 4: Run the test (after Task 4 wires MSW)** + +This task's test depends on Task 4's `src/test/server.ts` + setup. Proceed to Task 4, then run: +Run: `pnpm test src/api/client.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): generated OpenAPI types + typed openapi-fetch client with 401 redirect" +``` + +--- + +## Task 4: MSW test harness (typed mock handlers) + +**Files:** +- Create: `web/src/test/server.ts`, `web/src/test/handlers.ts`, `web/src/test/fixtures.ts` +- Modify: `web/src/test/setup.ts` +- Test: this task makes Task 3's `client.test.ts` runnable and adds a handler smoke test in `web/src/test/handlers.test.ts` + +- [ ] **Step 1: Install MSW** + +```bash +cd web +pnpm add -D msw +``` + +- [ ] **Step 2: Create typed fixtures** `web/src/test/fixtures.ts` + +```ts +import type { components } from "../api/schema"; + +export type AdminObjectView = components["schemas"]["AdminObjectView"]; +export type AdminObjectPage = components["schemas"]["AdminObjectPage"]; + +export const amphora: AdminObjectView = { + id: "11111111-1111-1111-1111-111111111111", + object_number: "LM-0042", + object_name: "Amphora", + number_of_objects: 1, + brief_description: "Storage jar", + current_location: "Vault 3", + current_owner: null, + recorder: null, + recording_date: null, + visibility: "public", + fields: { material: "Bronze" }, +}; + +export const fibula: AdminObjectView = { + ...amphora, + id: "22222222-2222-2222-2222-222222222222", + object_number: "LM-0043", + object_name: "Bronze fibula", + visibility: "internal", + fields: {}, +}; + +export const objectsPage: AdminObjectPage = { + items: [amphora, fibula], + total: 2, + limit: 50, + offset: 0, +}; +``` +(If `components["schemas"]["AdminObjectView"]` is absent, open `schema.d.ts` and use the exact generated schema name; utoipa names schemas after the Rust type, so `AdminObjectView`/`AdminObjectPage` should exist.) + +- [ ] **Step 3: Create default handlers** `web/src/test/handlers.ts` + +```ts +import { http, HttpResponse } from "msw"; +import { amphora, fibula, objectsPage } from "./fixtures"; + +export const handlers = [ + http.get("/api/admin/me", () => + HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }), + ), + http.get("/api/admin/objects", () => HttpResponse.json(objectsPage)), + http.get("/api/admin/objects/:id", ({ params }) => { + const found = [amphora, fibula].find((o) => o.id === params.id); + return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 }); + }), + http.get("/api/admin/field-definitions", () => + HttpResponse.json([ + { + key: "material", + data_type: "term", + vocabulary_id: "v1", + authority_kind: null, + required: false, + group: null, + labels: [{ lang: "en", label: "Material" }], + }, + ]), + ), + http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })), + http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })), +]; +``` + +- [ ] **Step 4: Create the server** `web/src/test/server.ts` + +```ts +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); +``` + +- [ ] **Step 5: Wire MSW lifecycle into the global setup** — replace `web/src/test/setup.ts` + +```ts +import "@testing-library/jest-dom/vitest"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./server"; + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +``` + +- [ ] **Step 6: Add a handler smoke test** `web/src/test/handlers.test.ts` + +```ts +import { expect, test } from "vitest"; +import { api } from "../api/client"; + +test("default handler serves the objects page", async () => { + const { data } = await api.GET("/api/admin/objects", { params: { query: {} } }); + expect(data?.total).toBe(2); + expect(data?.items[0].object_number).toBe("LM-0042"); +}); +``` + +- [ ] **Step 7: Run all tests (now Task 3's client test runs too)** + +Run: `pnpm test` +Expected: PASS — `client.test.ts` (2) + `handlers.test.ts` (1) + `app.test.tsx` (1). + +- [ ] **Step 8: Commit** + +```bash +cd .. +git add web +git commit -m "test(web): MSW harness with typed handlers and fixtures" +``` + +--- + +## Task 5: i18n (react-i18next, sv/en) + +**Files:** +- Create: `web/src/i18n/index.ts`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/i18n/use-locale.ts` +- Test: `web/src/i18n/i18n.test.tsx` +- Modify: `web/src/main.tsx` + +- [ ] **Step 1: Install** + +```bash +cd web +pnpm add i18next react-i18next +``` + +- [ ] **Step 2: Translation resources** — `web/src/i18n/en.json` + +```json +{ + "app": { "name": "Collection" }, + "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "soon": "Coming soon" }, + "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" }, + "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" }, + "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" }, + "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" } +} +``` +`web/src/i18n/sv.json` (same keys, Swedish): +```json +{ + "app": { "name": "Samling" }, + "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "soon": "Kommer snart" }, + "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" }, + "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" }, + "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" }, + "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" } +} +``` + +- [ ] **Step 3: Init i18next** — `web/src/i18n/index.ts` + +```ts +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import en from "./en.json"; +import sv from "./sv.json"; + +export const LOCALE_KEY = "locale"; +const stored = localStorage.getItem(LOCALE_KEY); +const fallback = "en"; + +void i18n.use(initReactI18next).init({ + resources: { en: { translation: en }, sv: { translation: sv } }, + lng: stored ?? fallback, + fallbackLng: fallback, + interpolation: { escapeValue: false }, +}); + +export default i18n; +``` +`web/src/i18n/use-locale.ts`: +```ts +import { useTranslation } from "react-i18next"; +import i18n, { LOCALE_KEY } from "./index"; + +export function useLocale() { + const { i18n: instance } = useTranslation(); + const setLocale = (lng: "en" | "sv") => { + localStorage.setItem(LOCALE_KEY, lng); + void i18n.changeLanguage(lng); + }; + return { locale: instance.language, setLocale }; +} +``` +Add `import "./i18n";` to `web/src/main.tsx` (top, after `index.css`). + +- [ ] **Step 4: Write the failing test** `web/src/i18n/i18n.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { useTranslation } from "react-i18next"; +import "./index"; +import { useLocale } from "./use-locale"; + +function Probe() { + const { t } = useTranslation(); + const { setLocale } = useLocale(); + return ( + <div> + <span data-testid="title">{t("objects.title")}</span> + <button onClick={() => setLocale("sv")}>sv</button> + </div> + ); +} + +test("switches language at runtime", async () => { + render(<Probe />); + expect(screen.getByTestId("title")).toHaveTextContent("Objects"); + await act(async () => { + screen.getByRole("button", { name: "sv" }).click(); + }); + expect(screen.getByTestId("title")).toHaveTextContent("Föremål"); +}); +``` + +- [ ] **Step 5: Run → fail → (already implemented in Step 3) → pass** + +Run: `pnpm test src/i18n/i18n.test.tsx` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): i18n with react-i18next (sv/en)" +``` + +--- + +## Task 6: TanStack Query + `useMe` + session guard + +**Files:** +- Create: `web/src/api/queries.ts`, `web/src/auth/require-auth.tsx`, `web/src/test/render.tsx` +- Test: `web/src/auth/require-auth.test.tsx` +- Modify: `web/src/main.tsx` + +- [ ] **Step 1: Install TanStack Query + the router** + +```bash +cd web +pnpm add @tanstack/react-query react-router-dom +``` + +- [ ] **Step 2: Query hooks** — `web/src/api/queries.ts` + +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "./client"; +import type { components } from "./schema"; + +type UserView = components["schemas"]["UserView"]; + +export function useMe() { + return useQuery({ + queryKey: ["me"], + queryFn: async (): Promise<UserView | null> => { + const { data, response } = await api.GET("/api/admin/me"); + if (response.status === 401) return null; + if (!data) throw new Error("failed to load session"); + return data; + }, + retry: false, + }); +} + +export function useObjectsPage(limit: number, offset: number) { + return useQuery({ + queryKey: ["objects", { limit, offset }], + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/objects", { + params: { query: { limit, offset } }, + }); + if (error || !data) throw new Error("failed to load objects"); + return data; + }, + }); +} + +export function useObject(id: string) { + return useQuery({ + queryKey: ["object", id], + queryFn: async () => { + const { data, response } = await api.GET("/api/admin/objects/{id}", { + params: { path: { id } }, + }); + if (response.status === 404) return null; + if (!data) throw new Error("failed to load object"); + return data; + }, + }); +} + +export function useFieldDefinitions() { + return useQuery({ + queryKey: ["field-definitions"], + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/field-definitions"); + if (error || !data) throw new Error("failed to load field definitions"); + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +export function useLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { email: string; password: string }) => { + const { response } = await api.POST("/api/admin/login", { body }); + if (response.status !== 204) { + throw new Error(response.status === 401 ? "invalid" : "network"); + } + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }), + }); +} + +export function useLogout() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await api.POST("/api/admin/logout"); + }, + onSuccess: () => qc.setQueryData(["me"], null), + }); +} +``` +(Confirm the path-param style `/api/admin/objects/{id}` matches the generated `paths` keys in `schema.d.ts`; openapi-typescript keeps the `{id}` template literally.) + +- [ ] **Step 3: A test render helper** — `web/src/test/render.tsx` + +```tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { render } from "@testing-library/react"; +import type { ReactElement } from "react"; +import "../i18n"; + +export function renderApp(ui: ReactElement, { route = "/" } = {}) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + <QueryClientProvider client={qc}> + <MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter> + </QueryClientProvider>, + ); +} +``` + +- [ ] **Step 4: Write the failing test** `web/src/auth/require-auth.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { RequireAuth } from "./require-auth"; + +function tree() { + return ( + <Routes> + <Route path="/login" element={<div>login page</div>} /> + <Route element={<RequireAuth />}> + <Route path="/objects" element={<div>secret objects</div>} /> + </Route> + </Routes> + ); +} + +test("renders children when authenticated", async () => { + renderApp(tree(), { route: "/objects" }); + expect(await screen.findByText("secret objects")).toBeInTheDocument(); +}); + +test("redirects to /login when unauthenticated", async () => { + server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 }))); + renderApp(tree(), { route: "/objects" }); + await waitFor(() => expect(screen.getByText("login page")).toBeInTheDocument()); +}); +``` + +- [ ] **Step 5: Implement** — `web/src/auth/require-auth.tsx` + +```tsx +import { Navigate, Outlet } from "react-router-dom"; +import { useMe } from "../api/queries"; + +export function RequireAuth() { + const { data: user, isLoading } = useMe(); + if (isLoading) return <div role="status" aria-label="loading" />; + if (!user) return <Navigate to="/login" replace />; + return <Outlet />; +} +``` + +- [ ] **Step 6: Run the test** + +Run: `pnpm test src/auth/require-auth.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 7: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): TanStack Query hooks + session-guarded routes" +``` + +--- + +## Task 7: Login page (sign in / sign out) + +**Files:** +- Create: `web/src/auth/login-page.tsx` +- Test: `web/src/auth/login-page.test.tsx` +- May add: shadcn `input`, `label`, `card` (`pnpm dlx shadcn@latest add input label card`) + +- [ ] **Step 1: Add the shadcn primitives the form uses** + +```bash +cd web +pnpm dlx shadcn@latest add input label card +``` + +- [ ] **Step 2: Write the failing test** `web/src/auth/login-page.test.tsx` + +```tsx +import { expect, test, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { LoginPage } from "./login-page"; + +function tree() { + return ( + <Routes> + <Route path="/login" element={<LoginPage />} /> + <Route path="/objects" element={<div>objects landing</div>} /> + </Routes> + ); +} + +test("successful login navigates to /objects", async () => { + renderApp(tree(), { route: "/login" }); + await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText("objects landing")).toBeInTheDocument(); +}); + +test("invalid credentials show an inline error", async () => { + server.use(http.post("/api/admin/login", () => new HttpResponse(null, { status: 401 }))); + renderApp(tree(), { route: "/login" }); + await userEvent.type(screen.getByLabelText(/email/i), "x@y.se"); + await userEvent.type(screen.getByLabelText(/password/i), "wrong"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + await waitFor(() => + expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(), + ); +}); +``` + +- [ ] **Step 3: Implement** — `web/src/auth/login-page.tsx` + +```tsx +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useLogin } from "../api/queries"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function LoginPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const login = useLogin(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + login.mutate( + { email, password }, + { onSuccess: () => navigate("/objects", { replace: true }) }, + ); + }; + + const errorKey = login.error + ? login.error.message === "invalid" + ? "auth.invalid" + : "auth.networkError" + : null; + + return ( + <div className="flex min-h-screen items-center justify-center p-4"> + <form onSubmit={onSubmit} className="w-full max-w-sm space-y-4"> + <h1 className="text-2xl font-semibold">{t("app.name")}</h1> + <div className="space-y-2"> + <Label htmlFor="email">{t("auth.email")}</Label> + <Input id="email" type="email" value={email} + onChange={(e) => setEmail(e.target.value)} autoComplete="username" /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">{t("auth.password")}</Label> + <Input id="password" type="password" value={password} + onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" /> + </div> + {errorKey && <p role="alert" className="text-sm text-red-600">{t(errorKey)}</p>} + <Button type="submit" className="w-full" disabled={login.isPending}> + {t("auth.signIn")} + </Button> + </form> + </div> + ); +} +``` + +- [ ] **Step 4: Run the test** + +Run: `pnpm test src/auth/login-page.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): login page with inline error handling" +``` + +--- + +## Task 8: App shell (sidebar + top bar + language/logout) + +**Files:** +- Create: `web/src/shell/app-shell.tsx`, `web/src/shell/lang-switch.tsx` +- Test: `web/src/shell/app-shell.test.tsx` + +- [ ] **Step 1: Write the failing test** `web/src/shell/app-shell.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Routes, Route } from "react-router-dom"; +import { renderApp } from "../test/render"; +import { AppShell } from "./app-shell"; + +function tree() { + return ( + <Routes> + <Route element={<AppShell />}> + <Route path="/objects" element={<div>objects outlet</div>} /> + </Route> + <Route path="/login" element={<div>login page</div>} /> + </Routes> + ); +} + +test("shows active and disabled nav and renders the outlet", async () => { + renderApp(tree(), { route: "/objects" }); + expect(await screen.findByText("objects outlet")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument(); + // later milestones are present but disabled + expect(screen.getByRole("button", { name: /search/i })).toBeDisabled(); +}); + +test("language switch toggles to Swedish", async () => { + renderApp(tree(), { route: "/objects" }); + await userEvent.click(await screen.findByRole("button", { name: "SV" })); + await waitFor(() => expect(screen.getByText("Föremål")).toBeInTheDocument()); +}); +``` + +- [ ] **Step 2: Implement the language switch** — `web/src/shell/lang-switch.tsx` + +```tsx +import { useLocale } from "../i18n/use-locale"; + +export function LangSwitch() { + const { locale, setLocale } = useLocale(); + const base = locale.startsWith("sv") ? "sv" : "en"; + return ( + <div className="flex gap-1 text-xs"> + {(["sv", "en"] as const).map((lng) => ( + <button key={lng} onClick={() => setLocale(lng)} + aria-pressed={base === lng} + className={base === lng ? "font-bold" : "text-neutral-400"}> + {lng.toUpperCase()} + </button> + ))} + </div> + ); +} +``` + +- [ ] **Step 3: Implement the shell** — `web/src/shell/app-shell.tsx` + +```tsx +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useLogout } from "../api/queries"; +import { LangSwitch } from "./lang-switch"; +import { Button } from "@/components/ui/button"; + +const FUTURE = ["vocabularies", "authorities", "fields", "search"] as const; + +export function AppShell() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const logout = useLogout(); + + const onSignOut = () => + logout.mutate(undefined, { onSuccess: () => navigate("/login", { replace: true }) }); + + return ( + <div className="flex min-h-screen"> + <aside className="w-44 shrink-0 border-r bg-neutral-50 p-3"> + <div className="mb-4 font-semibold">{t("app.name")}</div> + <nav className="space-y-1 text-sm"> + <NavLink to="/objects" + className={({ isActive }) => + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`}> + {t("nav.objects")} + </NavLink> + {FUTURE.map((key) => ( + <button key={key} disabled title={t("nav.soon")} + className="block w-full cursor-not-allowed rounded px-2 py-1 text-left text-neutral-400"> + {t(`nav.${key}`)} + </button> + ))} + </nav> + </aside> + <div className="flex flex-1 flex-col"> + <header className="flex items-center gap-4 border-b px-4 py-2"> + <div className="flex-1" /> + <LangSwitch /> + <Button variant="ghost" size="sm" onClick={onSignOut}> + {t("auth.signOut")} + </Button> + </header> + <main className="flex-1 overflow-hidden"> + <Outlet /> + </main> + </div> + </div> + ); +} +``` + +- [ ] **Step 4: Run the test** + +Run: `pnpm test src/shell/app-shell.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): app shell with sidebar nav, language switch, sign out" +``` + +--- + +## Task 9: Objects list (left pane) + visibility badge + +**Files:** +- Create: `web/src/objects/visibility-badge.tsx`, `web/src/objects/object-list.tsx` +- Test: `web/src/objects/object-list.test.tsx` +- May add shadcn `badge`, `skeleton` (`pnpm dlx shadcn@latest add badge skeleton`) + +- [ ] **Step 1: Add primitives** + +```bash +cd web +pnpm dlx shadcn@latest add badge skeleton +``` + +- [ ] **Step 2: Write the failing test** `web/src/objects/object-list.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { ObjectList } from "./object-list"; + +function tree() { + return ( + <Routes> + <Route path="/objects" element={<ObjectList />} /> + <Route path="/objects/:id" element={<ObjectList />} /> + </Routes> + ); +} + +test("renders object rows with number, name and visibility", async () => { + renderApp(tree(), { route: "/objects" }); + expect(await screen.findByText("LM-0042")).toBeInTheDocument(); + expect(screen.getByText("Amphora")).toBeInTheDocument(); + expect(screen.getByText("Public")).toBeInTheDocument(); +}); + +test("shows an empty state when there are no objects", async () => { + server.use( + http.get("/api/admin/objects", () => + HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }), + ), + ); + renderApp(tree(), { route: "/objects" }); + expect(await screen.findByText(/no objects yet/i)).toBeInTheDocument(); +}); + +test("shows an error state on failure", async () => { + server.use( + http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })), + ); + renderApp(tree(), { route: "/objects" }); + expect(await screen.findByText(/could not load objects/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Implement the badge** — `web/src/objects/visibility-badge.tsx` + +```tsx +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; + +const STYLES: Record<string, string> = { + draft: "bg-neutral-100 text-neutral-600", + internal: "bg-amber-100 text-amber-800", + public: "bg-green-100 text-green-800", +}; + +export function VisibilityBadge({ visibility }: { visibility: string }) { + const { t } = useTranslation(); + return ( + <Badge variant="outline" className={STYLES[visibility] ?? ""}> + {t(`visibility.${visibility}`)} + </Badge> + ); +} +``` + +- [ ] **Step 4: Implement the list** — `web/src/objects/object-list.tsx` + +```tsx +import { NavLink, useParams } from "react-router-dom"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useObjectsPage } from "../api/queries"; +import { VisibilityBadge } from "./visibility-badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; + +const LIMIT = 50; + +export function ObjectList() { + const { t } = useTranslation(); + const { id: selectedId } = useParams(); + const [offset, setOffset] = useState(0); + const { data, isLoading, isError } = useObjectsPage(LIMIT, offset); + + if (isLoading) { + return ( + <div className="space-y-2 p-3"> + {Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)} + </div> + ); + } + if (isError) return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>; + if (!data || data.items.length === 0) { + return <p className="p-4 text-sm text-neutral-500">{t("objects.empty")}</p>; + } + + const from = data.total === 0 ? 0 : offset + 1; + const to = Math.min(offset + LIMIT, data.total); + + return ( + <div className="flex h-full flex-col"> + <ul className="flex-1 overflow-auto"> + {data.items.map((object) => ( + <li key={object.id}> + <NavLink to={`/objects/${object.id}`} + className={`flex items-center justify-between gap-2 border-b px-3 py-2 text-sm ${ + object.id === selectedId ? "bg-indigo-50" : "hover:bg-neutral-50"}`}> + <span className="truncate"> + <span className="text-neutral-500">{object.object_number}</span>{" "} + {object.object_name} + </span> + <VisibilityBadge visibility={object.visibility} /> + </NavLink> + </li> + ))} + </ul> + <div className="flex items-center justify-between border-t px-3 py-2 text-xs text-neutral-500"> + <span>{from}–{to} {t("objects.of")} {data.total}</span> + <span className="flex gap-2"> + <Button variant="ghost" size="sm" disabled={offset === 0} + onClick={() => setOffset(Math.max(0, offset - LIMIT))}> + {t("objects.prev")} + </Button> + <Button variant="ghost" size="sm" disabled={to >= data.total} + onClick={() => setOffset(offset + LIMIT)}> + {t("objects.next")} + </Button> + </span> + </div> + </div> + ); +} +``` + +- [ ] **Step 5: Run the test** + +Run: `pnpm test src/objects/object-list.test.tsx` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): paginated object list with visibility badges and states" +``` + +--- + +## Task 10: Object detail (right pane) + two-pane page + routing + +**Files:** +- Create: `web/src/objects/object-detail.tsx`, `web/src/objects/objects-page.tsx` +- Test: `web/src/objects/object-detail.test.tsx`, `web/src/objects/objects-page.test.tsx` + +- [ ] **Step 1: Write the failing detail test** `web/src/objects/object-detail.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { ObjectDetail } from "./object-detail"; + +function tree() { + return ( + <Routes> + <Route path="/objects/:id" element={<ObjectDetail />} /> + </Routes> + ); +} + +test("renders inventory-minimum fields, flexible values and visibility", async () => { + renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" }); + expect(await screen.findByText("Amphora")).toBeInTheDocument(); + expect(screen.getByText("Vault 3")).toBeInTheDocument(); + expect(screen.getByText("Bronze")).toBeInTheDocument(); // flexible field value + expect(screen.getByText("Public")).toBeInTheDocument(); +}); + +test("shows a not-found state for a missing object", async () => { + server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 404 }))); + renderApp(tree(), { route: "/objects/does-not-exist" }); + expect(await screen.findByText(/object not found/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Implement the detail** — `web/src/objects/object-detail.tsx` + +```tsx +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useObject, useFieldDefinitions } from "../api/queries"; +import { VisibilityBadge } from "./visibility-badge"; +import { Skeleton } from "@/components/ui/skeleton"; + +function Field({ label, value }: { label: string; value: string | number | null | undefined }) { + if (value === null || value === undefined || value === "") return null; + return ( + <div className="border-b py-2"> + <div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div> + <div className="text-sm text-neutral-900">{value}</div> + </div> + ); +} + +export function ObjectDetail() { + const { t } = useTranslation(); + const { id } = useParams(); + const { data: object, isLoading, isError } = useObject(id!); + const { data: definitions } = useFieldDefinitions(); + + if (isLoading) return <div className="p-4"><Skeleton className="h-40 w-full" /></div>; + if (isError) return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>; + if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>; + + const labelFor = (key: string) => + definitions?.find((d) => d.key === key)?.labels.find((l) => l.lang === "en")?.label ?? key; + const flexible = Object.entries(object.fields ?? {}); + + return ( + <div className="overflow-auto p-4"> + <div className="mb-4 flex items-center gap-3"> + <h2 className="text-xl font-semibold">{object.object_name}</h2> + <VisibilityBadge visibility={object.visibility} /> + </div> + <Field label={t("fieldsLabels.objectNumber")} value={object.object_number} /> + <Field label={t("fieldsLabels.count")} value={object.number_of_objects} /> + <Field label={t("fieldsLabels.briefDescription")} value={object.brief_description} /> + <Field label={t("fieldsLabels.currentLocation")} value={object.current_location} /> + <Field label={t("fieldsLabels.currentOwner")} value={object.current_owner} /> + <Field label={t("fieldsLabels.recorder")} value={object.recorder} /> + <Field label={t("fieldsLabels.recordingDate")} value={object.recording_date} /> + {flexible.length > 0 && ( + <div className="mt-4"> + <div className="mb-1 text-xs font-medium uppercase text-neutral-500"> + {t("fieldsLabels.flexible")} + </div> + {flexible.map(([key, value]) => ( + <Field key={key} label={labelFor(key)} + value={typeof value === "object" ? JSON.stringify(value) : String(value)} /> + ))} + </div> + )} + </div> + ); +} +``` + +- [ ] **Step 3: Write the failing two-pane test** `web/src/objects/objects-page.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Routes, Route } from "react-router-dom"; +import { renderApp } from "../test/render"; +import { ObjectsPage } from "./objects-page"; + +function tree() { + return ( + <Routes> + <Route path="/objects" element={<ObjectsPage />} /> + <Route path="/objects/:id" element={<ObjectsPage />} /> + </Routes> + ); +} + +test("selecting a row shows its detail in the right pane", async () => { + renderApp(tree(), { route: "/objects" }); + expect(await screen.findByText(/select an object/i)).toBeInTheDocument(); + await userEvent.click(screen.getByText("Amphora")); + expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 4: Implement the two-pane page** — `web/src/objects/objects-page.tsx` + +```tsx +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ObjectList } from "./object-list"; +import { ObjectDetail } from "./object-detail"; + +export function ObjectsPage() { + const { t } = useTranslation(); + const { id } = useParams(); + return ( + <div className="grid h-full grid-cols-[20rem_1fr]"> + <div className="overflow-hidden border-r"> + <ObjectList /> + </div> + <div className="overflow-hidden"> + {id ? ( + <ObjectDetail /> + ) : ( + <div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400"> + {t("objects.selectPrompt")} + </div> + )} + </div> + </div> + ); +} +``` + +- [ ] **Step 5: Wire the router** — replace `web/src/app.tsx` + +```tsx +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { RequireAuth } from "./auth/require-auth"; +import { LoginPage } from "./auth/login-page"; +import { AppShell } from "./shell/app-shell"; +import { ObjectsPage } from "./objects/objects-page"; + +export function App() { + return ( + <BrowserRouter> + <Routes> + <Route path="/login" element={<LoginPage />} /> + <Route element={<RequireAuth />}> + <Route element={<AppShell />}> + <Route path="/objects" element={<ObjectsPage />} /> + <Route path="/objects/:id" element={<ObjectsPage />} /> + <Route path="/" element={<Navigate to="/objects" replace />} /> + </Route> + </Route> + <Route path="*" element={<Navigate to="/objects" replace />} /> + </Routes> + </BrowserRouter> + ); +} +``` +Update `web/src/main.tsx` to wrap `<App/>` in `QueryClientProvider`: +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { App } from "./app"; +import "./index.css"; +import "./i18n"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, +}); + +createRoot(document.getElementById("root")!).render( + <StrictMode> + <QueryClientProvider client={queryClient}> + <App /> + </QueryClientProvider> + </StrictMode>, +); +``` +Update `web/src/app.test.tsx` (the Task 1 smoke test) — `App` now renders the router and immediately fetches `/me`; assert the login or objects landing instead: +```tsx +import { render, screen } from "@testing-library/react"; +import { App } from "./app"; +import "./i18n"; + +test("mounts and routes to a known screen", async () => { + render(<App />); + expect(await screen.findByText(/object|föremål|sign in|logga in/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 6: Run the full suite + typecheck + lint + build** + +Run: `pnpm test` → Expected: all PASS. +Run: `pnpm typecheck` → Expected: clean. +Run: `pnpm lint` → Expected: clean. +Run: `pnpm build` → Expected: builds to `web/dist`. + +- [ ] **Step 7: Manual smoke against the real server (optional but recommended)** + +With the backend running and a user created, run `pnpm dev`, open `http://localhost:5173`, log in, browse, open a record, switch language, sign out. + +- [ ] **Step 8: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): object detail + two-pane page + app routing" +``` + +--- + +## Task 11: Embed the SPA into the server binary (`memory-serve`, feature-gated) + +**Files:** +- Modify: `crates/server/Cargo.toml`, `crates/server/src/lib.rs`, `Cargo.toml` (workspace dep) +- Create: `crates/server/src/web_assets.rs` +- Test: `crates/server/tests/embed.rs` + +- [ ] **Step 1: Add `memory-serve` as an optional workspace dependency** + +In root `Cargo.toml` `[workspace.dependencies]` add: +```toml +memory-serve = "1" +``` +(Use `gateway_invoke(server="cargo-mcp", ...)`/`cratesio` to confirm the current major version and the exact `MemoryServe`/`load_assets!` API before pinning; adapt the code below to the resolved version.) + +In `crates/server/Cargo.toml`: +```toml +[dependencies] +memory-serve = { workspace = true, optional = true } + +[features] +embed-web = ["dep:memory-serve"] +``` + +- [ ] **Step 2: Create the assets module** — `crates/server/src/web_assets.rs` + +```rust +//! Serves the embedded SPA (built `web/dist`) at `/` with a client-side-routing +//! fallback. Compiled only with the `embed-web` feature; in dev the SPA is served by +//! Vite (which proxies `/api` to this server), so this module is absent. + +use axum::Router; +use memory_serve::{load_assets, MemoryServe}; + +/// A router that serves the embedded `web/dist` assets, falling back to `index.html` +/// for unknown paths so the SPA can own client-side routes. +pub fn routes() -> Router { + MemoryServe::new(load_assets!("../../web/dist")) + .index_file(Some("/index.html")) + .fallback(Some("/index.html")) + .into_router() +} +``` +(`load_assets!` resolves its path relative to `CARGO_MANIFEST_DIR` = `crates/server`; `../../web/dist` points at the repo-root build output. Confirm the builder method names against the resolved `memory-serve` version and adjust.) + +- [ ] **Step 3: Merge the assets router when the feature is on** — in `crates/server/src/lib.rs` + +Add near the top: +```rust +#[cfg(feature = "embed-web")] +mod web_assets; +``` +In `serve`, after `let app = build_app(state);`, merge the SPA routes (feature-gated) so API routes take precedence and everything else falls through to the SPA: +```rust + let app = build_app(state); + + #[cfg(feature = "embed-web")] + let app = app.merge(web_assets::routes()); +``` +(Keep the rest of `serve` unchanged.) + +- [ ] **Step 4: Write the failing test** — `crates/server/tests/embed.rs` + +```rust +//! Only meaningful with `--features embed-web` and a built `web/dist`. Skips itself +//! (passes trivially) when the feature is off. +#![cfg(feature = "embed-web")] + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +#[tokio::test] +async fn serves_index_at_root_and_spa_fallback() { + let app = server::test_support::web_router(); + + let root = app + .clone() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(root.status(), StatusCode::OK); + let body = root.into_body().collect().await.unwrap().to_bytes(); + assert!(String::from_utf8_lossy(&body).contains("<div id=\"root\">")); + + // unknown client route falls back to index.html (not 404) + let deep = app + .oneshot(Request::builder().uri("/objects/123").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(deep.status(), StatusCode::OK); +} +``` +Expose a tiny test hook in `crates/server/src/lib.rs` (feature-gated): +```rust +#[cfg(feature = "embed-web")] +pub mod test_support { + /// The SPA-asset router, for tests. + pub fn web_router() -> axum::Router { + super::web_assets::routes() + } +} +``` + +- [ ] **Step 5: Build the SPA, then run the feature-gated test** + +```bash +cd web && pnpm install && pnpm build && cd .. +DATABASE_URL='postgres://postgres:postgres@localhost:5433/cms_dev' \ + cargo test -p server --features embed-web --test embed +``` +Expected: PASS — index served at `/`, `/objects/123` falls back to index. + +- [ ] **Step 6: Confirm the default (no-feature) build/test is unaffected** + +```bash +DATABASE_URL='postgres://postgres:postgres@localhost:5433/cms_dev' cargo test -p server +``` +Expected: PASS, and compiles without `web/dist` present (the module + test are `#[cfg(feature = "embed-web")]`). + +- [ ] **Step 7: fmt, clippy, commit** + +```bash +cargo +nightly fmt +DATABASE_URL='postgres://postgres:postgres@localhost:5433/cms_dev' \ + cargo clippy -p server --features embed-web --all-targets -- -D warnings +git add crates/server Cargo.toml Cargo.lock +git commit -m "feat(server): embed SPA via memory-serve behind embed-web feature" +``` + +--- + +## Task 12: Web CI job + bundle-size budget + +**Files:** +- Create: `web/scripts/check-bundle-size.mjs` +- Modify: `.gitea/workflows/<existing CI workflow>.yaml` (add a `web` job; inspect the existing workflow filename first) + +- [ ] **Step 1: Bundle-size gate script** — `web/scripts/check-bundle-size.mjs` + +```js +// Fails if the largest built JS entry chunk exceeds the gzipped budget. +import { readdirSync, readFileSync } from "node:fs"; +import { gzipSync } from "node:zlib"; +import { join } from "node:path"; + +const BUDGET_KB = 150; +const dir = "dist/assets"; +const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js")); +let largest = 0; +let largestName = ""; +for (const file of jsFiles) { + const gz = gzipSync(readFileSync(join(dir, file))).length; + if (gz > largest) { largest = gz; largestName = file; } +} +const kb = (largest / 1024).toFixed(1); +console.log(`largest JS chunk: ${largestName} = ${kb} KB gz (budget ${BUDGET_KB} KB)`); +if (largest > BUDGET_KB * 1024) { + console.error(`bundle-size budget exceeded: ${kb} KB > ${BUDGET_KB} KB`); + process.exit(1); +} +``` +Add to `web/package.json` scripts: `"check:size": "node scripts/check-bundle-size.mjs"`. + +- [ ] **Step 2: Run it locally after a build** + +```bash +cd web && pnpm build && pnpm check:size +``` +Expected: prints the chunk size and exits 0 (under 150 KB). If over, code-split (lazy-load routes via `React.lazy`) until under budget, then re-run. + +- [ ] **Step 3: Add the CI job** — in the existing `.gitea/workflows` CI file, add: + +```yaml + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 9 } + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm, cache-dependency-path: web/pnpm-lock.yaml } + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm lint + - run: pnpm test + - run: pnpm build + - run: pnpm check:size +``` +(Match the existing workflow's runner/style; first read the current `.gitea/workflows/*.yaml` to follow its conventions. `gen:api` is NOT run in CI — the committed `schema.d.ts` is the source of truth there.) + +- [ ] **Step 4: Commit** + +```bash +cd .. +git add web .gitea +git commit -m "ci(web): typecheck/lint/test/build + bundle-size budget" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** +- Scaffold + build/dev/serve → Tasks 1, 2, 11. ✓ +- Typed client (openapi-typescript + openapi-fetch, 401 redirect) → Task 3. ✓ +- TanStack Query hooks (me/objects/object/field-defs/login/logout) → Task 6. ✓ +- App shell (icon/sidebar nav, disabled future items, sv/en switch, logout) → Task 8. ✓ +- Login + session guard → Tasks 6, 7. ✓ +- Two-pane Objects (paginated list + read-only detail + visibility badge) → Tasks 9, 10. ✓ +- i18n sv/en → Task 5. ✓ +- States/errors (loading/empty/error/404/invalid-creds) → Tasks 7, 9, 10. ✓ +- Testing Vitest+RTL+MSW → Tasks 4–10. ✓ +- memory-serve embed behind `embed-web`, backend tests independent of build → Task 11. ✓ +- CI + bundle budget (≤150 KB gz) → Task 12. ✓ + +**Placeholder scan:** none — every code step shows complete code; the two "confirm against resolved version" notes (memory-serve API, generated schema names) are verification instructions, not deferred work, with concrete adapt-to-reality guidance. + +**Type consistency:** `api` client + `paths`/`components` from `schema.d.ts` used uniformly; hooks `useMe`/`useObjectsPage`/`useObject`/`useFieldDefinitions`/`useLogin`/`useLogout` defined in Task 6 and consumed in 7–10; `VisibilityBadge`, `renderApp`, `server` (MSW) consistent across tasks; route shape `/objects` + `/objects/:id` consistent in Tasks 9, 10. + +## Notes for follow-on milestones +- Object create/edit/delete + dynamic flexible-field forms (Milestone 2) — reuse the typed client, query hooks, and shadcn form primitives. +- Publishing workflow surfacing the required-field 422 (Milestone 3). +- Playwright e2e smoke once a `--seed` dataset exists (relates to issue #14). +- Detail currently renders flexible object/array values as JSON; typed rendering (term/authority label resolution) lands with the authoring/forms milestone.