Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b3a587eab | |||
| 8511aebb53 | |||
| 6e1f5ea50f | |||
| 70025e1e71 | |||
| 40384d91dd | |||
| d3e88be70f | |||
| 03f6e1d7ed | |||
| aab1bb37dc |
@@ -0,0 +1,374 @@
|
|||||||
|
# Typography Hierarchy + Page `<h1>` + Per-Route `document.title` 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:** Give every AppShell route a consistent semantic page `<h1>` and a distinct browser-tab title (`"{Page} | {AppName}"`), via a small `PageTitle` component and a `useDocumentTitle` hook, and fix the one misused `<h3>` caption.
|
||||||
|
|
||||||
|
**Architecture:** A presentational `PageTitle` (`ui/page-title.tsx`) renders the styled `<h1>`. A `useDocumentTitle(page)` hook (`lib/`) composes `"{page} | {app_name}"` (app_name from `useConfig`), sets `document.title`, and restores the prior title on unmount — which lets a master-detail detail pane override the tab to the object's `object_number` and revert on close. Pages reuse existing i18n keys; no new strings, no new dependency.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-i18next, react-router 7, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; reuse existing i18n keys (en/sv already in parity); source double-quote/semicolon, stories single-quote/no-semicolon; token classes only; **do not restructure layout/columns — only add the heading element + title hook**; guard `document` for jsdom.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-07-typography-page-titles-design.md`
|
||||||
|
|
||||||
|
**File structure:**
|
||||||
|
- `web/src/components/ui/page-title.tsx` (new) — `<PageTitle>` h1.
|
||||||
|
- `web/src/components/ui/page-title.stories.tsx` (new) — story.
|
||||||
|
- `web/src/lib/use-document-title.ts` (new) — the hook.
|
||||||
|
- `web/src/lib/use-document-title.test.tsx` (new) — hook test.
|
||||||
|
- Modify pages: `web/src/objects/objects-page.tsx`, `web/src/objects/object-new-page.tsx`,
|
||||||
|
`web/src/vocab/vocabularies-page.tsx`, `web/src/authorities/authorities-page.tsx`,
|
||||||
|
`web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`.
|
||||||
|
- Modify `web/src/objects/object-detail.tsx` (detail title override).
|
||||||
|
- Modify `web/src/vocab/vocabulary-terms.tsx` (h3→div caption fix).
|
||||||
|
- Modify `web/src/auth/login-page.tsx` (document.title = app.name).
|
||||||
|
|
||||||
|
> NOTE: exact page file paths — verify with `git ls-files web/src | grep -E 'objects-page|object-new|vocabularies-page|authorities-page|fields-page|search-page|object-detail|vocabulary-terms|login-page'` before editing; the directory names above are from exploration but confirm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: `PageTitle` component + story
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/components/ui/page-title.tsx`
|
||||||
|
- Create: `web/src/components/ui/page-title.stories.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement** `web/src/components/ui/page-title.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function PageTitle({ className, ...props }: ComponentProps<"h1">) {
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
data-slot="page-title"
|
||||||
|
className={cn("text-2xl font-semibold tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the `cn` import path matches the other `ui/*` files (open `web/src/components/ui/button.tsx`
|
||||||
|
and copy its exact `cn` import — expected `@/lib/utils`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the story** `web/src/components/ui/page-title.stories.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { expect } from 'storybook/test'
|
||||||
|
|
||||||
|
import { PageTitle } from './page-title'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: PageTitle,
|
||||||
|
args: { children: 'Objects' },
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
} satisfies Meta<typeof PageTitle>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvas }) => {
|
||||||
|
await expect(canvas.getByRole('heading', { level: 1, name: 'Objects' })).toBeInTheDocument()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the house story style — single quotes, no semicolons — as in `web/src/components/label-editor.stories.tsx`. Adjust the `Meta` import if that file imports it differently.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the story-as-test + typecheck + lint**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/components/ui/page-title.stories.tsx && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS, clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/components/ui/page-title.tsx web/src/components/ui/page-title.stories.tsx
|
||||||
|
git commit -m "feat(web): PageTitle h1 component + story (#57)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: `useDocumentTitle` hook + test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/lib/use-document-title.ts`
|
||||||
|
- Create: `web/src/lib/use-document-title.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm the config hook.** Open `web/src/config/config-context.ts` and confirm the
|
||||||
|
exported hook name and that it returns `app_name` (expected `useConfig()` → `{ app_name, ... }`).
|
||||||
|
Use the exact import path/name in the hook below.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** `web/src/lib/use-document-title.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import "../i18n";
|
||||||
|
import { ConfigProvider } from "../config/config-provider";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useDocumentTitle } from "./use-document-title";
|
||||||
|
|
||||||
|
function Titled({ page }: { page: string }) {
|
||||||
|
useDocumentTitle(page);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(ui: React.ReactElement) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ConfigProvider>{ui}</ConfigProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.title = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets document.title to '{page} | {app_name}'", () => {
|
||||||
|
wrap(<Titled page="Objects" />);
|
||||||
|
expect(document.title).toMatch(/^Objects \| .+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores the previous title on unmount", () => {
|
||||||
|
document.title = "Prev";
|
||||||
|
const { unmount } = wrap(<Titled page="Objects" />);
|
||||||
|
expect(document.title).toMatch(/^Objects \| /);
|
||||||
|
unmount();
|
||||||
|
expect(document.title).toBe("Prev");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: this assumes `ConfigProvider` supplies a default `app_name` synchronously (the spec says
|
||||||
|
`useConfig` defaults to `"Collection Management System"` before `/api/config` resolves). If
|
||||||
|
`ConfigProvider` needs MSW for `/api/config`, instead import `handlers` from `../test/handlers` and
|
||||||
|
set up an MSW server in the test (mirror an existing test that uses ConfigProvider). If a simpler
|
||||||
|
existing wrapper exists (check `web/src/test/render.tsx` — does `renderApp` include ConfigProvider?),
|
||||||
|
prefer that. Do NOT weaken the assertions; adapt the wrapper to provide config.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/lib/use-document-title.test.tsx`
|
||||||
|
Expected: FAIL — cannot import `useDocumentTitle`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement** `web/src/lib/use-document-title.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
|
|
||||||
|
export function useDocumentTitle(page: string): void {
|
||||||
|
const { app_name } = useConfig();
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const previous = document.title;
|
||||||
|
document.title = `${page} | ${app_name}`;
|
||||||
|
return () => {
|
||||||
|
document.title = previous;
|
||||||
|
};
|
||||||
|
}, [page, app_name]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adjust the `useConfig` import path/name to match what Step 1 found.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/lib/use-document-title.test.tsx`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/use-document-title.ts web/src/lib/use-document-title.test.tsx
|
||||||
|
git commit -m "feat(web): useDocumentTitle hook (restores prior title on unmount) (#57)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: Wire `<PageTitle>` + `useDocumentTitle` into the list/form pages
|
||||||
|
|
||||||
|
**Files (modify):** `web/src/objects/objects-page.tsx`, `web/src/objects/object-new-page.tsx`,
|
||||||
|
`web/src/vocab/vocabularies-page.tsx`, `web/src/authorities/authorities-page.tsx`,
|
||||||
|
`web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`.
|
||||||
|
|
||||||
|
For EACH page: read it first, add the imports
|
||||||
|
`import { PageTitle } from "@/components/ui/page-title";` (match the file's import style) and
|
||||||
|
`import { useDocumentTitle } from "../lib/use-document-title";` (verify the relative depth), call the
|
||||||
|
hook near the top of the component, and render `<PageTitle>` at the top of the page's content. Use the
|
||||||
|
i18n key from the table. **Do not restructure existing layout/columns** — only add the heading
|
||||||
|
element (and, if the page already has a top action row, place `<PageTitle>` on its left).
|
||||||
|
|
||||||
|
| File | i18n key | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `objects-page.tsx` | `nav.objects` | Page already has a toolbar (filter, New button, pagination). Put `<PageTitle>` at the top-left of that toolbar row, or in a small header row above the table. |
|
||||||
|
| `object-new-page.tsx` | `objects.new` | Form page; `<PageTitle>` above the form. |
|
||||||
|
| `vocabularies-page.tsx` | `nav.vocabularies` | Two-column; `<PageTitle>` above the columns (full width). |
|
||||||
|
| `authorities-page.tsx` | `nav.authorities` | Tabbed; `<PageTitle>` above the tabs. |
|
||||||
|
| `fields-page.tsx` | `fields.title` | Two-column; `<PageTitle>` above the columns. |
|
||||||
|
| `search-page.tsx` | `nav.search` | Two-column; `<PageTitle>` above the columns. |
|
||||||
|
|
||||||
|
- [ ] **Step 1: objects-page.tsx** — add imports, `const { t } = useTranslation()` (it likely already
|
||||||
|
has `t`), `useDocumentTitle(t("nav.objects"))`, and render `<PageTitle>{t("nav.objects")}</PageTitle>`
|
||||||
|
at the top of the content. Keep all existing markup.
|
||||||
|
|
||||||
|
- [ ] **Step 2: object-new-page.tsx** — same pattern with `objects.new`.
|
||||||
|
- [ ] **Step 3: vocabularies-page.tsx** — same with `nav.vocabularies`.
|
||||||
|
- [ ] **Step 4: authorities-page.tsx** — same with `nav.authorities`.
|
||||||
|
- [ ] **Step 5: fields-page.tsx** — same with `fields.title`.
|
||||||
|
- [ ] **Step 6: search-page.tsx** — same with `nav.search`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Add/extend a page test.** In the existing test for objects (or create
|
||||||
|
`web/src/objects/objects-page.test.tsx` if none — check first), assert the `<h1>` and title:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("renders the page heading and sets the document title", async () => {
|
||||||
|
renderApp(/* the objects page route, mirroring the existing objects test setup */);
|
||||||
|
expect(await screen.findByRole("heading", { level: 1, name: /objects/i })).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(document.title).toMatch(/objects \| /i));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If an objects page/integration test already exists, ADD this assertion there using the same render
|
||||||
|
setup rather than duplicating the harness. Do not weaken existing assertions.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the affected tests + typecheck + lint**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/objects src/vocab src/authorities src/fields src/search && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS (existing tests unaffected by the added heading; the new assertion passes). If any
|
||||||
|
existing test breaks because a heading query is now ambiguous, investigate — the page `<h1>` text
|
||||||
|
should be distinct from row/cell content; do NOT weaken the test.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/objects/objects-page.tsx web/src/objects/object-new-page.tsx web/src/vocab/vocabularies-page.tsx web/src/authorities/authorities-page.tsx web/src/fields/fields-page.tsx web/src/search/search-page.tsx web/src/objects/*.test.tsx
|
||||||
|
git commit -m "feat(web): page <h1> + document.title on list/form routes (#57)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 4: Detail-pane title override, caption fix, login title + final gate
|
||||||
|
|
||||||
|
**Files (modify):** `web/src/objects/object-detail.tsx`, `web/src/vocab/vocabulary-terms.tsx`,
|
||||||
|
`web/src/auth/login-page.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Object detail title override.** In `web/src/objects/object-detail.tsx`, after the
|
||||||
|
object has loaded, call `useDocumentTitle(object.object_number)`. The hook must receive a real value
|
||||||
|
— only call it once `object` is loaded. Because hooks can't be conditional, call it unconditionally
|
||||||
|
with a guarded value, e.g.:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
// ... inside the component, AFTER object data is available:
|
||||||
|
useDocumentTitle(object?.object_number ?? "");
|
||||||
|
```
|
||||||
|
|
||||||
|
But setting `" | App"` (empty page) while loading is undesirable. Prefer: keep the hook call
|
||||||
|
unconditional but pass the object_number only when loaded, and make the component not render/return
|
||||||
|
early before the data is present (check the existing structure — `object-detail.tsx` likely already
|
||||||
|
early-returns a loading/skeleton state). If it early-returns BEFORE the hook, that violates rules-of-
|
||||||
|
hooks. Resolve by either: (a) calling `useDocumentTitle(object_number)` only in the loaded branch by
|
||||||
|
splitting the component into an outer (fetch + loading) and an inner `ObjectDetailLoaded({ object })`
|
||||||
|
that calls the hook — RECOMMENDED; or (b) guarding inside the hook usage so loading sets nothing.
|
||||||
|
Choose (a): create an inner component that receives the loaded `object` and calls
|
||||||
|
`useDocumentTitle(object.object_number)`; the outer handles loading. Keep `object_name` as the
|
||||||
|
existing `<h2>`.
|
||||||
|
|
||||||
|
Verify `object_number` is the right field (read the component / the `AdminObjectView`/object type).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Caption fix.** In `web/src/vocab/vocabulary-terms.tsx` around line 52, change the
|
||||||
|
`<h3 className="mb-2 label-caption">…</h3>` to `<div className="mb-2 label-caption">…</div>` (same
|
||||||
|
className/content; only the element changes). Confirm there is no CSS/test depending on the `h3`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Login title.** In `web/src/auth/login-page.tsx`, add a small effect:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
|
// ... inside the component (t from useTranslation already present):
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("app.name");
|
||||||
|
}, [t]);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Do not use `useDocumentTitle` here — login is pre-auth/standalone.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Detail-override test.** Add (or extend an existing object-detail test) asserting the
|
||||||
|
document title reflects the object on the detail route and reverts on unmount. Mirror the existing
|
||||||
|
object-detail test's render/MSW setup (reuse `web/src/test/handlers.ts` + fixtures):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("object detail sets the tab title to the object number and reverts", async () => {
|
||||||
|
document.title = "Base";
|
||||||
|
const { unmount } = renderApp(/* object detail route, per the existing detail test */);
|
||||||
|
await waitFor(() => expect(document.title).toMatch(/<expected object_number from fixture> \| /));
|
||||||
|
unmount();
|
||||||
|
expect(document.title).toBe("Base");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the actual `object_number` from the existing object fixture (read `web/src/test/fixtures.ts` —
|
||||||
|
the `amphora` fixture). If reverting-on-unmount is awkward to assert through the full route, assert
|
||||||
|
the override (title contains the object_number) at minimum; do not weaken below that.
|
||||||
|
|
||||||
|
- [ ] **Step 5: FULL GATE (single test pass — run tests exactly ONCE):**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||||
|
```
|
||||||
|
Expected: all green. Report test totals, largest chunk, check:colors line.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Codename + status:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
Expected: no codename matches.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual smoke (recommended).** `pnpm dev`: each route shows a page `<h1>`; the tab title
|
||||||
|
reads "{Page} | {AppName}"; opening an object changes the tab to the object number and closing it
|
||||||
|
reverts; exactly one `<h1>` per page.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/objects/object-detail.tsx web/src/vocab/vocabulary-terms.tsx web/src/auth/login-page.tsx web/src/objects/*.test.tsx
|
||||||
|
git commit -m "feat(web): object-detail tab title, caption element fix, login title (#57)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:** PageTitle h1 component (T1); useDocumentTitle hook with restore-on-unmount (T2);
|
||||||
|
per-route h1 + title on objects/object-new/vocabularies/authorities/fields/search reusing existing
|
||||||
|
keys (T3); detail-pane `object_number` override + revert (T4 S1); h3→div caption fix (T4 S2); login
|
||||||
|
title (T4 S3); one h1 per page preserved (list owns h1, detail keeps h2 — T3/T4); tests for component,
|
||||||
|
hook, page, and override (T1/T2/T3/T4); gate + no codename + no new dep + no new strings (T4). All
|
||||||
|
acceptance criteria 1–6 mapped. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** the only non-literal spots are render-harness reuse ("mirror the existing test
|
||||||
|
setup") and the object fixture's `object_number` ("read fixtures.ts") — these are deliberate "match
|
||||||
|
the existing pattern" instructions with concrete files named, not TODOs. The rules-of-hooks resolution
|
||||||
|
for object-detail (split into inner loaded component) is spelled out. ✓
|
||||||
|
|
||||||
|
**Type consistency:** `PageTitle` is `ComponentProps<"h1">`; `useDocumentTitle(page: string)` defined
|
||||||
|
in T2 and called with `t(key)` (string) in T3 and `object.object_number` (string) in T4;
|
||||||
|
`useConfig().app_name` is the title's app-name source throughout. ✓
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No new dependency, no new i18n strings (all keys exist in en/sv).
|
||||||
|
- The restore-on-unmount in `useDocumentTitle` is load-bearing for the master-detail override — keep it.
|
||||||
|
- Verify exact page file paths first (the NOTE under File structure); adjust import depths accordingly.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Typography Hierarchy + Page `<h1>` + Per-Route `document.title` — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-07
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
**Issue:** #57.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The app has a flat type scale (almost everything `text-sm`/`text-xs`) and only 3 ad-hoc headings:
|
||||||
|
`login-page.tsx` `<h1 text-2xl>`, `object-detail.tsx` `<h2 text-xl>` (object name), and
|
||||||
|
`vocabulary-terms.tsx:52` an `<h3 class="label-caption">` **misused as a caption**. The list routes
|
||||||
|
(objects, vocabularies, authorities, fields, search) have **no page `<h1>`** and no visible title.
|
||||||
|
Separately, `web/index.html` hardcodes `<title>Collection</title>` and nothing ever sets
|
||||||
|
`document.title` — every tab/bookmark reads "Collection", so a curator with many object tabs can't
|
||||||
|
tell them apart.
|
||||||
|
|
||||||
|
All authenticated routes render inside `AppShell` (login is standalone). The header has no
|
||||||
|
title/breadcrumb slot (that's #54 — out of scope here). `.label-caption` exists (from #49).
|
||||||
|
`app_name` comes from `useConfig()` (`ConfigView.app_name`). i18n already has `nav.{objects,
|
||||||
|
vocabularies,authorities,fields,search}` plus `objects.title`/`fields.title`/`objects.new`.
|
||||||
|
|
||||||
|
### Decisions (from brainstorming)
|
||||||
|
1. **`PageTitle` component** (`ui/page-title.tsx`) rendering a semantic `<h1>` with consistent
|
||||||
|
classes — over utility classes or inline copy-paste. No `SectionTitle` (YAGNI; one h2 exists).
|
||||||
|
2. **Tab title format `"{Page} | {AppName}"`** — page-first so truncated tabs stay distinguishable.
|
||||||
|
3. Reuse existing i18n keys for page titles — no new strings.
|
||||||
|
4. Master-detail: the list page owns the single `<h1>`; the detail pane overrides `document.title`
|
||||||
|
to the object identifier while mounted.
|
||||||
|
|
||||||
|
## Type scale
|
||||||
|
|
||||||
|
| Level | Element | Classes |
|
||||||
|
|---|---|---|
|
||||||
|
| Page title | `<h1>` | `text-2xl font-semibold tracking-tight` |
|
||||||
|
| Section title | `<h2>` | `text-xl font-semibold` (object-detail `object_name`, already this) |
|
||||||
|
| Body | — | `text-sm` (unchanged) |
|
||||||
|
| Caption | — | `.label-caption` (exists) |
|
||||||
|
|
||||||
|
Only the page-title level is new; the rest already exist in the codebase. No global CSS scale change
|
||||||
|
beyond the `PageTitle` component.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `web/src/components/ui/page-title.tsx` (new)
|
||||||
|
Mirrors the `ui/*` style (`data-slot`, `cn()`):
|
||||||
|
```tsx
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function PageTitle({ className, ...props }: ComponentProps<"h1">) {
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
data-slot="page-title"
|
||||||
|
className={cn("text-2xl font-semibold tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
A Storybook story renders it with sample text and asserts the `<h1>` role/level.
|
||||||
|
|
||||||
|
### `web/src/lib/use-document-title.ts` (new)
|
||||||
|
```ts
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
|
|
||||||
|
export function useDocumentTitle(page: string): void {
|
||||||
|
const { app_name } = useConfig();
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const previous = document.title;
|
||||||
|
document.title = `${page} | ${app_name}`;
|
||||||
|
return () => {
|
||||||
|
document.title = previous;
|
||||||
|
};
|
||||||
|
}, [page, app_name]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Sets `"{page} | {app_name}"`; re-runs when `page` or `app_name` changes (so it updates when the
|
||||||
|
async config resolves, or when a detail pane swaps the `page` value).
|
||||||
|
- **Restores the prior title on cleanup.** This is what makes the master-detail override revert: the
|
||||||
|
detail pane captures the list page's title (`"Objects | …"`), sets the object title, and restores
|
||||||
|
`"Objects | …"` when it unmounts.
|
||||||
|
- `useConfig()` exposes `app_name` (defaults to `"Collection Management System"` until `/api/config`
|
||||||
|
resolves); the dep on `app_name` means the title corrects itself once config loads.
|
||||||
|
|
||||||
|
## Per-route wiring
|
||||||
|
|
||||||
|
Each `AppShell` page renders one `<PageTitle>` and calls `useDocumentTitle` (reusing existing keys):
|
||||||
|
|
||||||
|
| Route | Component | `<h1>` / title key |
|
||||||
|
|---|---|---|
|
||||||
|
| `/objects` | `ObjectsPage` | `nav.objects` |
|
||||||
|
| `/objects/new` | `ObjectNewPage` | `objects.new` |
|
||||||
|
| `/vocabularies` | `VocabulariesPage` | `nav.vocabularies` |
|
||||||
|
| `/authorities/:kind` | `AuthoritiesPage` | `nav.authorities` |
|
||||||
|
| `/fields` | `FieldsPage` | `fields.title` |
|
||||||
|
| `/search` | `SearchPage` | `nav.search` |
|
||||||
|
|
||||||
|
Placement: the `<PageTitle>` goes at the top of each page's content region (above the table/columns),
|
||||||
|
in a consistent wrapper (e.g. a small header row with existing actions). **Only color/typography +
|
||||||
|
the added heading element — do not restructure the existing layout/columns.** Where a page already
|
||||||
|
has a top action row (e.g. "New object" button), put the `<PageTitle>` on the left of that row.
|
||||||
|
|
||||||
|
### Master-detail `document.title` override
|
||||||
|
- `/objects/:id`: `ObjectsPage` still renders the visible `<h1>` ("Objects") and sets the base title.
|
||||||
|
`ObjectDetail` (right pane) keeps `object_name` as its `<h2>` and calls
|
||||||
|
`useDocumentTitle(object.object_number)` once the object is loaded — overriding the tab to e.g.
|
||||||
|
`"1024.1 | Collection"`, reverting to `"Objects | Collection"` on unmount (hook restore).
|
||||||
|
- `/search/:id`: `ObjectDetail` is reused in `SearchPage`'s pane — same override over the "Search"
|
||||||
|
base, automatically.
|
||||||
|
- Result: exactly **one `<h1>` per page** (a11y), while each open object tab is distinguishable.
|
||||||
|
- Edge: `ObjectDetail` must only call the hook with a real value once loaded (guard the loading
|
||||||
|
state — don't set `"undefined | …"`). While loading, leave the base title.
|
||||||
|
|
||||||
|
### Semantic caption fix
|
||||||
|
`web/src/vocabulary/vocabulary-terms.tsx:52`: `<h3 className="mb-2 label-caption">` →
|
||||||
|
`<div className="mb-2 label-caption">` (it's a caption, not a section heading). No style change
|
||||||
|
(class identical), just the element.
|
||||||
|
|
||||||
|
### Login (standalone)
|
||||||
|
`login-page.tsx` keeps its `<h1>` (app name) and sets `document.title = t("app.name")` via a small
|
||||||
|
inline `useEffect` (deps `[t]`) — **not** `useDocumentTitle`, since `/api/config` may be
|
||||||
|
unauthenticated pre-login and `useConfig`/`ConfigProvider` may not apply there. This is deterministic
|
||||||
|
(no `"{page} | {app}"` composition on the login screen), keeping login self-contained.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
Route mounts → page calls `useDocumentTitle(t(key))` → effect sets `"{page} | {app_name}"`. Config
|
||||||
|
resolves → `app_name` dep changes → title corrected. Detail pane mounts → overrides with
|
||||||
|
`object_number` → unmount restores. `<PageTitle>` is purely presentational.
|
||||||
|
|
||||||
|
## Error handling / edges
|
||||||
|
- `document` guarded (test/SSR safety).
|
||||||
|
- Detail pane must not set a title until the object is loaded (no `"undefined | …"`).
|
||||||
|
- Two `useDocumentTitle` instances active at once (list + detail) on a detail route: the detail's
|
||||||
|
effect runs after the list's (child mounts after parent), so the object title wins; restore on the
|
||||||
|
detail's unmount returns to the list title. The list's effect does not re-fire on detail
|
||||||
|
open/close (its `page`/`app_name` deps are unchanged), so it won't clobber the override.
|
||||||
|
- One `<h1>` per page is preserved on master-detail routes (list owns the h1; detail uses h2).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- **`page-title` story/test:** renders `<PageTitle>Objects</PageTitle>`, assert
|
||||||
|
`getByRole("heading", { level: 1 })` has the text.
|
||||||
|
- **`use-document-title.test.tsx`:** render a component using the hook inside `renderApp` (which
|
||||||
|
provides config) or a small ConfigProvider wrapper; assert `document.title === "X | <app_name>"`;
|
||||||
|
unmount → assert it reverts to the previous value. (Mock/confirm the config `app_name` used.)
|
||||||
|
- **Page-level:** assert `ObjectsPage` renders an `<h1>` with the localized "Objects" and sets
|
||||||
|
`document.title`. A detail test: rendering the object route sets `document.title` to the
|
||||||
|
`object_number` and reverts when navigating away (reuse existing object MSW handlers/fixtures).
|
||||||
|
- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`; en/sv parity
|
||||||
|
(reused keys); no codename; `check:size` within 250 KB gz.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. A `PageTitle` (`<h1>`) component exists and is rendered once per `AppShell` route (objects,
|
||||||
|
object-new, vocabularies, authorities, fields, search) using existing i18n keys.
|
||||||
|
2. `document.title` is set per route as `"{Page} | {AppName}"`; object detail routes
|
||||||
|
(`/objects/:id`, `/search/:id`) show the object's `object_number` in the tab and revert on close.
|
||||||
|
3. The misused `<h3>` caption in `vocabulary-terms` becomes a non-heading element (`.label-caption`).
|
||||||
|
4. Exactly one `<h1>` per page (master-detail: list owns the `<h1>`, detail keeps `<h2>`).
|
||||||
|
5. No layout/spacing restructure beyond adding the heading element; no new i18n strings; no new dep.
|
||||||
|
6. `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no codename.
|
||||||
|
|
||||||
|
## Out of scope → follow-ups
|
||||||
|
- Header breadcrumb / wayfinding / global search entry (#54).
|
||||||
|
- Broader density/spacing/typography redesign per screen (the type scale here is intentionally
|
||||||
|
minimal: page title + the existing section/body/caption levels).
|
||||||
|
- A `<title>` template via a router data API or a `<DocumentTitle>` provider (the hook is sufficient).
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@ export function LoginPage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("app.name");
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent) => {
|
const onSubmit = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
login.mutate(
|
login.mutate(
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { components } from "../api/schema";
|
|||||||
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||||
import { LabelEditor } from "../components/label-editor";
|
import { LabelEditor } from "../components/label-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
import { AuthorityRow } from "./authority-row";
|
import { AuthorityRow } from "./authority-row";
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
|
||||||
type LabelInput = components["schemas"]["LabelInput"];
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ export function AuthoritiesPage() {
|
|||||||
const [labels, setLabels] = useState<LabelInput[]>([]);
|
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useDocumentTitle(t("nav.authorities"));
|
||||||
|
|
||||||
if (!isValidKind) return <Navigate to="/authorities/person" replace />;
|
if (!isValidKind) return <Navigate to="/authorities/person" replace />;
|
||||||
|
|
||||||
const onCreate = (event: FormEvent) => {
|
const onCreate = (event: FormEvent) => {
|
||||||
@@ -45,6 +49,7 @@ export function AuthoritiesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto p-4">
|
<div className="overflow-auto p-4">
|
||||||
|
<PageTitle className="mb-3">{t("nav.authorities")}</PageTitle>
|
||||||
<div role="tablist" className="mb-3 flex gap-2">
|
<div role="tablist" className="mb-3 flex gap-2">
|
||||||
{KINDS.map((k) => (
|
{KINDS.map((k) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { expect } from 'storybook/test'
|
||||||
|
|
||||||
|
import { PageTitle } from './page-title'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: PageTitle,
|
||||||
|
args: { children: 'Objects' },
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
} satisfies Meta<typeof PageTitle>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvas }) => {
|
||||||
|
await expect(canvas.getByRole('heading', { level: 1, name: 'Objects' })).toBeInTheDocument()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ComponentProps } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function PageTitle({ className, ...props }: ComponentProps<"h1">) {
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
data-slot="page-title"
|
||||||
|
className={cn("text-2xl font-semibold tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { FieldList } from "./field-list";
|
import { FieldList } from "./field-list";
|
||||||
import { FieldForm } from "./field-form";
|
import { FieldForm } from "./field-form";
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
|
|
||||||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
|
||||||
export function FieldsPage() {
|
export function FieldsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
|
const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
|
||||||
|
|
||||||
|
useDocumentTitle(t("fields.title"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
<div className="flex h-full flex-col">
|
||||||
<div className="overflow-hidden border-r">
|
<PageTitle className="px-4 pt-4 pb-2">{t("fields.title")}</PageTitle>
|
||||||
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
|
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
|
||||||
</div>
|
<div className="overflow-hidden border-r">
|
||||||
<div className="overflow-hidden">
|
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
|
||||||
<FieldForm
|
</div>
|
||||||
key={selected?.key ?? "create"}
|
<div className="overflow-hidden">
|
||||||
editing={selected}
|
<FieldForm
|
||||||
onDone={() => setSelected(null)}
|
key={selected?.key ?? "create"}
|
||||||
/>
|
editing={selected}
|
||||||
|
onDone={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import "../i18n";
|
||||||
|
import { ConfigProvider } from "../config/config-provider";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useDocumentTitle } from "./use-document-title";
|
||||||
|
|
||||||
|
function Titled({ page }: { page: string }) {
|
||||||
|
useDocumentTitle(page);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(ui: React.ReactElement) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ConfigProvider>{ui}</ConfigProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.title = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets document.title to '{page} | {app_name}'", () => {
|
||||||
|
wrap(<Titled page="Objects" />);
|
||||||
|
expect(document.title).toMatch(/^Objects \| .+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores the previous title on unmount", () => {
|
||||||
|
document.title = "Prev";
|
||||||
|
const { unmount } = wrap(<Titled page="Objects" />);
|
||||||
|
expect(document.title).toMatch(/^Objects \| /);
|
||||||
|
unmount();
|
||||||
|
expect(document.title).toBe("Prev");
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
|
|
||||||
|
export function useDocumentTitle(page: string): void {
|
||||||
|
const { app_name } = useConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const previous = document.title;
|
||||||
|
|
||||||
|
document.title = `${page} | ${app_name}`;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.title = previous;
|
||||||
|
};
|
||||||
|
}, [page, app_name]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { screen, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { server } from "../test/server";
|
import { server } from "../test/server";
|
||||||
@@ -96,6 +96,14 @@ test("shows a not-found state for a missing object", async () => {
|
|||||||
expect(await screen.findByText(/object not found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/object not found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sets the tab title to the object number and reverts on unmount", async () => {
|
||||||
|
document.title = "Base";
|
||||||
|
const { unmount } = renderDetail();
|
||||||
|
await waitFor(() => expect(document.title).toMatch(/^LM-0042 \| /));
|
||||||
|
unmount();
|
||||||
|
expect(document.title).toBe("Base");
|
||||||
|
});
|
||||||
|
|
||||||
test("detail shows the publish control with the current visibility stepper", async () => {
|
test("detail shows the publish control with the current visibility stepper", async () => {
|
||||||
// default GET /api/admin/objects/:id handler returns amphora (visibility "public")
|
// default GET /api/admin/objects/:id handler returns amphora (visibility "public")
|
||||||
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
|
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { useObject, useFieldDefinitions } from "../api/queries";
|
import { useObject, useFieldDefinitions } from "../api/queries";
|
||||||
import { formatDate } from "../lib/format-date";
|
import { formatDate } from "../lib/format-date";
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
import { DeleteObjectDialog } from "./delete-object-dialog";
|
import { DeleteObjectDialog } from "./delete-object-dialog";
|
||||||
import { FlexibleFieldValue } from "./flexible-field-value";
|
import { FlexibleFieldValue } from "./flexible-field-value";
|
||||||
import { PublishControl } from "./publish-control";
|
import { PublishControl } from "./publish-control";
|
||||||
@@ -13,6 +14,7 @@ import { buttonVariants } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: ReactNode }) {
|
function Field({ label, value }: { label: string; value: ReactNode }) {
|
||||||
const empty = value === null || value === undefined || value === "";
|
const empty = value === null || value === undefined || value === "";
|
||||||
@@ -26,10 +28,9 @@ function Field({ label, value }: { label: string; value: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ObjectDetail() {
|
export function ObjectDetail() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { data: object, isLoading, isError } = useObject(id!);
|
const { data: object, isLoading, isError } = useObject(id!);
|
||||||
const { data: definitions } = useFieldDefinitions();
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +44,15 @@ export function ObjectDetail() {
|
|||||||
|
|
||||||
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
|
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
|
||||||
|
|
||||||
|
return <ObjectDetailLoaded object={object} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectDetailLoaded({ object }: { object: AdminObjectView }) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { data: definitions } = useFieldDefinitions();
|
||||||
|
|
||||||
|
useDocumentTitle(object.object_number);
|
||||||
|
|
||||||
// Prefer the active locale's label, then English, then the raw key.
|
// Prefer the active locale's label, then English, then the raw key.
|
||||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
const labelFor = (key: string) => {
|
const labelFor = (key: string) => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ObjectForm, type ObjectFormValues } from "./object-form";
|
import { ObjectForm, type ObjectFormValues } from "./object-form";
|
||||||
import { useCreateObject, useSetFields, FieldRejection } from "../api/queries";
|
import { useCreateObject, useSetFields, FieldRejection } from "../api/queries";
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
|
|
||||||
export function ObjectNewPage() {
|
export function ObjectNewPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -12,6 +14,8 @@ export function ObjectNewPage() {
|
|||||||
const setFields = useSetFields();
|
const setFields = useSetFields();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useDocumentTitle(t("objects.new"));
|
||||||
|
|
||||||
const onSubmit = async (values: ObjectFormValues) => {
|
const onSubmit = async (values: ObjectFormValues) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@ export function ObjectNewPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<PageTitle className="mb-4">{t("objects.new")}</PageTitle>
|
||||||
<ObjectForm
|
<ObjectForm
|
||||||
mode="create"
|
mode="create"
|
||||||
formError={error}
|
formError={error}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { screen, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
@@ -39,6 +39,16 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders the page heading and sets the document title", async () => {
|
||||||
|
setViewport(true);
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { level: 1, name: /objects/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(document.title).toMatch(/objects \| /i));
|
||||||
|
});
|
||||||
|
|
||||||
test("the table is the landing view; no detail panel until a row is opened", async () => {
|
test("the table is the landing view; no detail panel until a row is opened", async () => {
|
||||||
setViewport(true);
|
setViewport(true);
|
||||||
renderApp(tree(), { route: "/objects" });
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { X } from "lucide-react";
|
|||||||
|
|
||||||
import { ObjectsTable } from "./objects-table";
|
import { ObjectsTable } from "./objects-table";
|
||||||
import { useMediaQuery } from "../lib/use-media-query";
|
import { useMediaQuery } from "../lib/use-media-query";
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
|
|
||||||
const ObjectDetailDrawer = lazy(() =>
|
const ObjectDetailDrawer = lazy(() =>
|
||||||
import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })),
|
import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })),
|
||||||
@@ -23,11 +25,16 @@ export function ObjectsPage() {
|
|||||||
const open = Boolean(detailMatch ?? editMatch);
|
const open = Boolean(detailMatch ?? editMatch);
|
||||||
const isWide = useMediaQuery("(min-width: 1024px)");
|
const isWide = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
|
useDocumentTitle(t("nav.objects"));
|
||||||
|
|
||||||
const closeDetail = () => navigate(`/objects?${searchParams}`);
|
const closeDetail = () => navigate(`/objects?${searchParams}`);
|
||||||
|
|
||||||
const table = (
|
const table = (
|
||||||
<div className="overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<ObjectsTable />
|
<PageTitle className="px-4 pt-4 pb-2">{t("nav.objects")}</PageTitle>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ObjectsTable />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { SearchPanel } from "./search-panel";
|
import { SearchPanel } from "./search-panel";
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
|
|
||||||
export function SearchPage() {
|
export function SearchPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useDocumentTitle(t("nav.search"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-[24rem_1fr]">
|
<div className="flex h-full flex-col">
|
||||||
<div className="overflow-hidden border-r">
|
<PageTitle className="px-4 pt-4 pb-2">{t("nav.search")}</PageTitle>
|
||||||
<SearchPanel />
|
<div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden">
|
||||||
</div>
|
<div className="overflow-hidden border-r">
|
||||||
<div className="overflow-hidden">
|
<SearchPanel />
|
||||||
<Outlet />
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { VocabularyList } from "./vocabulary-list";
|
import { VocabularyList } from "./vocabulary-list";
|
||||||
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
|
|
||||||
export function VocabulariesPage() {
|
export function VocabulariesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useDocumentTitle(t("nav.vocabularies"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
<div className="flex h-full flex-col">
|
||||||
<div className="overflow-hidden border-r">
|
<PageTitle className="px-4 pt-4 pb-2">{t("nav.vocabularies")}</PageTitle>
|
||||||
<VocabularyList />
|
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
|
||||||
</div>
|
<div className="overflow-hidden border-r">
|
||||||
<div className="overflow-hidden">
|
<VocabularyList />
|
||||||
<Outlet />
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export function VocabularyTerms() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto p-4">
|
<div className="overflow-auto p-4">
|
||||||
<h3 className="mb-2 label-caption">
|
<div className="mb-2 label-caption">
|
||||||
{t("vocab.terms")}
|
{t("vocab.terms")}
|
||||||
</h3>
|
</div>
|
||||||
<ul className="mb-4">
|
<ul className="mb-4">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<li className="text-sm text-muted-foreground">…</li>
|
<li className="text-sm text-muted-foreground">…</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user