Compare commits

..

10 Commits

Author SHA1 Message Date
logaritmisk b7242caf51 merge: app header wayfinding — breadcrumb, user menu, search, app_name brand (#54)
CI / web (push) Has been cancelled
2026-06-07 19:45:23 +02:00
logaritmisk 6efe09d40c feat(web): assemble header — breadcrumb, search, user menu; remove standalone sign out (#54)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:27:43 +02:00
logaritmisk 5c8fe3cd81 feat(web): UserMenu (email/role + sign out) + HeaderSearch components (#54) 2026-06-07 19:23:43 +02:00
logaritmisk 4b55218c69 feat(web): set breadcrumb trails on all AppShell routes (#54) 2026-06-07 19:18:43 +02:00
logaritmisk af6004f731 refactor(web): remove eslint-disable from useBreadcrumb via ref (#54) 2026-06-07 19:15:03 +02:00
logaritmisk 18cb35beff feat(web): page-driven breadcrumb context + header render + objects wiring (#54)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:11:31 +02:00
logaritmisk dbaf22500e feat(web): ui/menu Base UI dropdown wrapper + story (#54) 2026-06-07 19:05:25 +02:00
logaritmisk 4fad3c43f0 feat(web): render configured app_name for brand + login; drop hardcoded app.name (#54)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:01:15 +02:00
logaritmisk e4badbdefc docs(plans): app header wayfinding — 6-task plan (#54) 2026-06-07 18:58:04 +02:00
logaritmisk 285d35601b docs(specs): app header wayfinding — breadcrumb, user menu, search, app_name (#54) 2026-06-07 18:18:03 +02:00
28 changed files with 1176 additions and 40 deletions
@@ -0,0 +1,521 @@
# App Header Wayfinding 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:** Fill the empty app header with wayfinding — a route-driven breadcrumb (left), a signed-in user menu + compact global search (right) — and render the configured `app_name` for the brand + login.
**Architecture:** A page-driven breadcrumb (a `BreadcrumbProvider` context + `useBreadcrumb(trail)` hook, parallel to #57's `useDocumentTitle`) that each route sets and the header renders. A reusable `ui/menu.tsx` Base UI Menu wrapper powers a `UserMenu` (email/role + Sign out). A `HeaderSearch` input navigates to `/search?q=`. Brand + login read `useConfig().app_name`. No new dependency.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-router 7, react-i18next, Base UI (`@base-ui/react/menu` — namespace `Menu`), lucide-react, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; **ui/ files = no-semicolon base-nova style** (match `alert-dialog.tsx`); **app source (shell/, lib/, pages) = double-quote + semicolon**; stories = single-quote + no-semicolon; token classes only (`check:colors`); guard DOM globals.
**Spec:** `docs/superpowers/specs/2026-06-07-header-wayfinding-design.md`
**Key facts (verified):** `useMe()` (`api/queries.ts:30`) → `UserView | null` = `{ email, id, role }`. `useLogout()` (`queries.ts:129`). `useVocabularies()` (`queries.ts:258`) → `VocabularyView[]` with `.key` (the display name). Current logout flow in `app-shell.tsx`: `logout.mutate(undefined, { onSuccess: () => navigate("/login", { replace: true }) })`. Base UI render-prop pattern: see `ui/alert-dialog.tsx` (namespace import, `data-slot`, `cn()`).
**File structure:**
- `web/src/components/ui/menu.tsx` (new) + `menu.stories.tsx` (new)
- `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new render component)
- `web/src/shell/user-menu.tsx` (new), `header-search.tsx` (new)
- Modify: `web/src/shell/app-shell.tsx`, `sidebar.tsx`, `auth/login-page.tsx`, the 9 page/detail components, `i18n/en.json`, `i18n/sv.json`, `shell/app-shell.test.tsx`, `auth/login-page.test.tsx`.
---
# Task 1: Render `app_name` for brand + login; remove dead `app.name` key
**Files:** `web/src/shell/sidebar.tsx`, `web/src/auth/login-page.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/auth/login-page.test.tsx`.
- [ ] **Step 1: Sidebar brand.** In `web/src/shell/sidebar.tsx` add `import { useConfig } from "../config/config-context";`, get `const { app_name } = useConfig();` in the component, and change line ~76:
`{!collapsed && <span className="font-semibold">{t("app.name")}</span>}`
`{!collapsed && <span className="font-semibold">{app_name}</span>}`.
- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `<h1>` (line ~38) to `{app_name}` and the title effect (line ~18) to `document.title = app_name;` with deps `[app_name]`. Remove the now-unused `t` for that purpose only if `t` is otherwise unused (check — login uses `t` for field labels/errors, so keep the `useTranslation` import).
- [ ] **Step 3: Remove the dead i18n key.** Delete the `"app": { "name": "..." }` entry from BOTH `web/src/i18n/en.json` and `web/src/i18n/sv.json` (grep first: `grep -rn 'app\.name\|"app"' web/src` — confirm no remaining `t("app.name")` after Steps 12). en/sv must stay in parity (remove from both).
- [ ] **Step 4: Update login test if needed.** Read `web/src/auth/login-page.test.tsx`. If it asserts the heading text via `t("app.name")` / "Collection", update it to the config default `"Collection Management System"` (the value `useConfig` returns in tests via `DEFAULTS`). Do NOT weaken; just match the new source.
- [ ] **Step 5: Verify (run vitest once for these files).**
`cd web && pnpm vitest run src/auth src/shell/app-shell.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS. The sidebar brand + login now show "Collection Management System" (config default) in tests.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/sidebar.tsx web/src/auth/login-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/auth/login-page.test.tsx
git commit -m "feat(web): render configured app_name for brand + login; drop hardcoded app.name (#54)"
```
---
# Task 2: `ui/menu.tsx` Base UI Menu wrapper + story (validate by running)
**Files:** `web/src/components/ui/menu.tsx` (new), `web/src/components/ui/menu.stories.tsx` (new).
- [ ] **Step 1: Read the reference** `web/src/components/ui/alert-dialog.tsx` for the exact house pattern (namespace import, `data-slot`, `cn()`, no semicolons, token classes). The Base UI Menu API is `import { Menu } from "@base-ui/react/menu"` then `Menu.Root`, `Menu.Trigger`, `Menu.Portal`, `Menu.Positioner`, `Menu.Popup`, `Menu.Item`, `Menu.Separator`. **This is novel — you MUST validate the exact part tree by running the story (Step 3).**
- [ ] **Step 2: Implement** `web/src/components/ui/menu.tsx` (no-semicolon style). Export: `Menu` (Root re-export with data-slot), `MenuTrigger`, `MenuContent` (composes Portal + Positioner + Popup), `MenuItem`, `MenuSeparator`. Skeleton (adapt class/props to what runs):
```tsx
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
function Menu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="menu" {...props} />
}
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
}
function MenuContent({
className,
sideOffset = 6,
align = "end",
...props
}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner sideOffset={sideOffset} align={align} className="z-50">
<MenuPrimitive.Popup
data-slot="menu-content"
className={cn(
"min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
return (
<MenuPrimitive.Item
data-slot="menu-item"
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
export { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator }
```
IMPORTANT: the exact prop names (`sideOffset`, `align`, `Popup` vs `Popup`+`Positioner` arrangement) MUST be confirmed against the installed `@base-ui/react` types — open `web/node_modules/@base-ui/react/menu/` or check via the editor/types and adjust. Do not guess; if a prop/part errors at typecheck or runtime, fix it to match the real API. No `data-[highlighted]` raw colors — `bg-accent`/`text-accent-foreground` are tokens (OK).
- [ ] **Step 3: Story** `web/src/components/ui/menu.stories.tsx` (single-quote, no-semicolon). Render a `Menu` with a `MenuTrigger` (a Button via `render` or as child) + `MenuContent` with two `MenuItem`s; a `play` test that opens the menu (click the trigger) and asserts an item is visible:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'
import { Button } from './button'
const meta = {
component: Menu,
tags: ['ai-generated'],
} satisfies Meta<typeof Menu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Menu>
<MenuTrigger render={<Button variant="ghost">Open</Button>} />
<MenuContent>
<MenuItem>First</MenuItem>
<MenuSeparator />
<MenuItem>Second</MenuItem>
</MenuContent>
</Menu>
),
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Open' }))
await expect(await canvas.findByText('First')).toBeInTheDocument()
},
}
```
If `MenuTrigger render={<Button/>}` isn't the right composition for Base UI Menu, use the pattern that works (e.g. `<MenuTrigger><Button/></MenuTrigger>` or `render` per the alert-dialog usage). The story passing IS the validation.
- [ ] **Step 4: Run the story-as-test + typecheck + lint.**
`cd web && pnpm vitest run src/components/ui/menu.stories.tsx && pnpm typecheck && pnpm lint`
Expected: PASS. If the menu doesn't open / portal isn't found, fix the part tree until the play test passes (this is the validate-by-running step). The portal renders to document.body — `findByText` on the canvas/body should find it; if the addon's `canvas` is scoped, query `within(document.body)` or use the screen — match how other portal-using stories (drawer/combobox/toast) assert.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/ui/menu.tsx web/src/components/ui/menu.stories.tsx
git commit -m "feat(web): ui/menu Base UI dropdown wrapper + story (#54)"
```
---
# Task 3: Breadcrumb infrastructure + mount in header + wire objects-page
**Files:** `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new), `web/src/shell/app-shell.tsx` (modify), `web/src/objects/objects-page.tsx` (modify), `web/src/shell/breadcrumb.test.tsx` (new).
- [ ] **Step 1: Context** `web/src/shell/breadcrumb-context.ts`:
```ts
import { createContext, useContext } from "react";
export type BreadcrumbItem = { label: string; to?: string };
type BreadcrumbContextValue = {
trail: BreadcrumbItem[];
setTrail: (trail: BreadcrumbItem[]) => void;
};
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
trail: [],
setTrail: () => {},
});
export function useBreadcrumbTrail(): BreadcrumbItem[] {
return useContext(BreadcrumbContext).trail;
}
```
- [ ] **Step 2: Provider** `web/src/shell/breadcrumb-provider.tsx`:
```tsx
import { useState, type ReactNode } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
return (
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
{children}
</BreadcrumbContext.Provider>
);
}
```
- [ ] **Step 3: Hook** `web/src/shell/use-breadcrumb.ts`:
```ts
import { useContext, useEffect } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
const { setTrail } = useContext(BreadcrumbContext);
const key = trail.map((i) => `${i.label}${i.to ?? ""}`).join("");
useEffect(() => {
setTrail(trail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, setTrail]);
}
```
NOTE on the disable: the effect intentionally depends on the serialized `key` (stable) instead of the `trail` array identity. **Do NOT add `eslint-disable` if the linter doesn't require it** — first try `[key, setTrail]` with no comment; only if `react-hooks/exhaustive-deps` errors on the missing `trail` dep, prefer refactoring (e.g. build the trail inside the effect from primitive args) over disabling. If a clean form isn't possible, a single scoped disable on that line is acceptable here (the serialization is the correct dep). Use judgment; document the choice in the commit.
- [ ] **Step 4: Render component** `web/src/shell/breadcrumb.tsx`:
```tsx
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { useBreadcrumbTrail } from "./breadcrumb-context";
export function Breadcrumb() {
const trail = useBreadcrumbTrail();
if (trail.length === 0) return <div className="min-w-0 flex-1" />;
return (
<nav aria-label="Breadcrumb" className="flex min-w-0 flex-1 items-center gap-1 text-sm">
{trail.map((item, i) => {
const last = i === trail.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
{i > 0 && <span className="text-muted-foreground">/</span>}
{item.to && !last ? (
<Link to={item.to} className="truncate text-muted-foreground hover:text-foreground">
{item.label}
</Link>
) : (
<span className={last ? "truncate text-foreground" : "truncate text-muted-foreground"}>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}
```
(The empty-trail branch renders the `flex-1` spacer so the right-side controls stay right-aligned.)
- [ ] **Step 5: Mount in app-shell.** In `web/src/shell/app-shell.tsx`: wrap the inner `<div className="flex flex-1 flex-col">…</div>` (header+main) — actually wrap the whole returned tree's header+main region — in `<BreadcrumbProvider>`. Simplest: wrap the `return (<div className="flex min-h-screen">…)` content's right column. Concretely, import `BreadcrumbProvider` and `Breadcrumb`, and render `<BreadcrumbProvider>` around the `<div className="flex flex-1 flex-col">` (so both header and `<Outlet/>` are inside it). Replace the header's leading `<div className="flex-1" />` with `<Breadcrumb />` (which itself provides the flex-1). Leave ThemeSwitch/LangSwitch/Sign out as-is for now (Task 5/6 handle the right side).
- [ ] **Step 6: Wire objects-page** (proof of the pipe). In `web/src/objects/objects-page.tsx` add `import { useBreadcrumb } from "../shell/use-breadcrumb";` and call `useBreadcrumb([{ label: t("nav.objects") }]);` near the top (alongside the existing `useDocumentTitle`).
- [ ] **Step 7: Test** `web/src/shell/breadcrumb.test.tsx` — render the provider + a setter component + the Breadcrumb, assert it renders the crumbs and a non-leaf links:
```tsx
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { renderApp } from "../test/render";
import { BreadcrumbProvider } from "./breadcrumb-provider";
import { Breadcrumb } from "./breadcrumb";
import { useBreadcrumb } from "./use-breadcrumb";
function Setter() {
useBreadcrumb([
{ label: "Objects", to: "/objects" },
{ label: "LM-0042" },
]);
return null;
}
test("renders the trail with a link on non-leaf crumbs", async () => {
renderApp(
<BreadcrumbProvider>
<Breadcrumb />
<Setter />
</BreadcrumbProvider>,
);
const link = await screen.findByRole("link", { name: "Objects" });
expect(link).toHaveAttribute("href", "/objects");
expect(screen.getByText("LM-0042")).toBeInTheDocument();
});
```
(`renderApp` provides the Router so `<Link>` works.)
- [ ] **Step 8: Verify (vitest once).**
`cd web && pnpm vitest run src/shell src/objects/objects-page.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS (breadcrumb test + existing shell/objects tests). The objects-page test from #57 still passes; optionally assert the header crumb there too.
- [ ] **Step 9: Commit**
```bash
git add web/src/shell/breadcrumb-context.ts web/src/shell/breadcrumb-provider.tsx web/src/shell/use-breadcrumb.ts web/src/shell/breadcrumb.tsx web/src/shell/app-shell.tsx web/src/objects/objects-page.tsx web/src/shell/breadcrumb.test.tsx
git commit -m "feat(web): page-driven breadcrumb context + header render + objects wiring (#54)"
```
---
# Task 4: Wire `useBreadcrumb` into the remaining routes
**Files (modify):** `web/src/objects/object-new-page.tsx`, `web/src/objects/object-detail.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/vocab/vocabularies-page.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`.
For each: add `import { useBreadcrumb } from "../shell/use-breadcrumb";` (verify depth: all these dirs are one level under `src/`, so `../shell/...` is correct) and call it near the top (after `useTranslation`). Reuse existing i18n keys.
- [ ] **Step 1: object-new-page**`useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]);`
- [ ] **Step 2: object-detail** — in the inner `ObjectDetailLoaded({ object })` component (added in #57), add `useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number }]);` (it has `t` via `useTranslation` — add if missing). This covers `/objects/:id` AND `/search/:id` (reused).
- [ ] **Step 3: object-edit-form** — read the file; if it loads the object (has `object_number` + the `:id`), add `useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number, to: \`/objects/${id}\` }, { label: t("actions.edit") }]);` in the loaded branch (split like ObjectDetail if it early-returns before data — same rules-of-hooks care). If it does NOT have the object loaded (only the form), use `[{ label: t("nav.objects"), to: "/objects" }, { label: t("actions.edit") }]`. Choose based on what the component actually has; don't add a fetch just for this.
- [ ] **Step 4: vocabularies-page**`useBreadcrumb([{ label: t("nav.vocabularies") }]);`
- [ ] **Step 5: vocabulary-terms** — it has only `id` (UUID). Add the vocab name via the cached list:
```tsx
import { useVocabularies } from "../api/queries";
// inside, after const { id } = useParams():
const { data: vocabularies } = useVocabularies();
const vocabKey = vocabularies?.find((v) => v.id === id)?.key;
useBreadcrumb(
vocabKey
? [{ label: t("nav.vocabularies"), to: "/vocabularies" }, { label: vocabKey }]
: [{ label: t("nav.vocabularies"), to: "/vocabularies" }],
);
```
(`useVocabularies()` is cache-shared with the vocabularies list — no extra request. `.key` is the display name, per `vocabulary-list.tsx`.) Place the hook BEFORE the existing `if (!id) return null;` early return.
- [ ] **Step 6: authorities-page**`useBreadcrumb([{ label: t("nav.authorities") }]);` (place before the `isValidKind` early return, like `useDocumentTitle`).
- [ ] **Step 7: fields-page**`useBreadcrumb([{ label: t("nav.fields") }]);`
- [ ] **Step 8: search-page**`useBreadcrumb([{ label: t("nav.search") }]);`
- [ ] **Step 9: Integration test.** Add a test (in `breadcrumb.test.tsx` or `objects-page.test.tsx`) that rendering a nested route shows the breadcrumb in the header. Easiest reliable one: at `/objects/new`, the header shows "Objects" (link → /objects) and "New". Use the existing app-shell/objects render setup (the route must render inside `AppShell` so the header + provider are present). If wiring a full-route render is heavy, assert via the objects route that the header `<nav aria-label="Breadcrumb">` contains the section label. Do not weaken; pick a route that reliably mounts AppShell.
- [ ] **Step 10: Verify (vitest once).**
`cd web && pnpm vitest run src/objects src/vocab src/authorities src/fields src/search src/shell && pnpm typecheck && pnpm lint`
Expected: PASS. Existing tests unaffected (breadcrumb context default is a no-op when no provider; inside AppShell the provider is present).
- [ ] **Step 11: Commit**
```bash
git add web/src/objects/object-new-page.tsx web/src/objects/object-detail.tsx web/src/objects/object-edit-form.tsx web/src/vocab/vocabularies-page.tsx web/src/vocab/vocabulary-terms.tsx web/src/authorities/authorities-page.tsx web/src/fields/fields-page.tsx web/src/search/search-page.tsx web/src/shell/breadcrumb.test.tsx
git commit -m "feat(web): set breadcrumb trails on all AppShell routes (#54)"
```
---
# Task 5: `UserMenu` + `HeaderSearch` components
**Files:** `web/src/shell/user-menu.tsx` (new), `web/src/shell/header-search.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`, plus tests `web/src/shell/user-menu.test.tsx` (new), `web/src/shell/header-search.test.tsx` (new).
- [ ] **Step 1: i18n** — add `"headerPlaceholder": "Search…"` to the `search` namespace in `en.json` and `"headerPlaceholder": "Sök…"` in `sv.json` (parity). (Confirm a `search` namespace exists; if not, add it in both.)
- [ ] **Step 2: UserMenu** `web/src/shell/user-menu.tsx`:
```tsx
import { CircleUser } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLogout, useMe } from "../api/queries";
import { Button } from "@/components/ui/button";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/ui/menu";
export function UserMenu() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: me } = useMe();
const logout = useLogout();
const onSignOut = () =>
logout.mutate(undefined, {
onSuccess: () => navigate("/login", { replace: true }),
});
if (!me) return null;
return (
<Menu>
<MenuTrigger
render={
<Button variant="ghost" size="sm" className="max-w-44">
<CircleUser className="h-4 w-4" aria-hidden />
<span className="truncate">{me.email}</span>
</Button>
}
/>
<MenuContent>
<div className="px-2 py-1.5">
<div className="truncate text-sm font-medium">{me.email}</div>
<div className="text-xs text-muted-foreground">{me.role}</div>
</div>
<MenuSeparator />
<MenuItem onClick={onSignOut}>{t("auth.signOut")}</MenuItem>
</MenuContent>
</Menu>
);
}
```
Adjust `MenuTrigger`/`render` to the form Task 2 validated. The `MenuItem` action prop may be `onClick` or Base UI's `onClick`/`render` — match the wrapper. Ensure clicking it triggers `onSignOut`.
- [ ] **Step 3: HeaderSearch** `web/src/shell/header-search.tsx`:
```tsx
import { Search } from "lucide-react";
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Input } from "@/components/ui/input";
export function HeaderSearch() {
const { t } = useTranslation();
const navigate = useNavigate();
const [q, setQ] = useState("");
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const query = q.trim();
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={onSubmit} className="hidden sm:block">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("search.headerPlaceholder")}
aria-label={t("nav.search")}
className="w-48 pl-8 lg:w-64"
/>
</div>
</form>
);
}
```
- [ ] **Step 4: Tests.**
- `web/src/shell/user-menu.test.tsx`: render `<UserMenu/>` via `renderApp` with MSW returning a `me` user (reuse `web/src/test/handlers.ts`; if `/api/admin/me` isn't in handlers, add a handler or override per-test). Assert the email shows; open the menu; click Sign out → assert the logout POST fired (MSW) / navigation. Mirror how the existing `app-shell.test.tsx` tested sign-out. If asserting navigation is awkward, assert the logout request was made.
- `web/src/shell/header-search.test.tsx`: render `<HeaderSearch/>` via `renderApp`; type "amphora" + submit (Enter); assert navigation to `/search?q=amphora` (use a `MemoryRouter` location probe or render a small route tree that shows the location — mirror existing navigation tests; if none, render with a `*` route echoing `useLocation().search`).
- [ ] **Step 5: Verify (vitest once).**
`cd web && pnpm vitest run src/shell/user-menu.test.tsx src/shell/header-search.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/user-menu.tsx web/src/shell/header-search.tsx web/src/shell/user-menu.test.tsx web/src/shell/header-search.test.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): UserMenu (email/role + sign out) + HeaderSearch components (#54)"
```
---
# Task 6: Header assembly + app-shell test + final gate
**Files:** `web/src/shell/app-shell.tsx`, `web/src/shell/app-shell.test.tsx`.
- [ ] **Step 1: Assemble the header.** In `web/src/shell/app-shell.tsx`:
- Import `HeaderSearch` and `UserMenu`.
- Remove the standalone Sign out `<Button>` and the now-unused `onSignOut`/`useLogout`/`navigate`/`t` (the logout flow now lives in `UserMenu`). Keep imports only if still used.
- Header becomes:
```tsx
<header className="flex items-center gap-4 border-b px-4 py-2">
<Breadcrumb />
<HeaderSearch />
<ThemeSwitch />
<LangSwitch />
<UserMenu />
</header>
```
(`<Breadcrumb />` provides the `flex-1`; if both Breadcrumb's flex-1 and a spacer fight, ensure exactly one flex-1 between left and right — Breadcrumb already has `flex-1`, so no extra spacer.) Keep `BreadcrumbProvider` wrapping header+main (from Task 3).
- [ ] **Step 2: Update `app-shell.test.tsx`.** The Sign out button moved into `UserMenu` (a menu). Update the existing sign-out test: it must now open the user menu first, then click Sign out. Ensure `useMe` resolves a user in the test (MSW handler for `/api/admin/me`). If the test renders `AppShell` directly, the header now needs `me` + breadcrumb provider (provider is inside AppShell, fine). Don't weaken; adapt to the menu interaction. Keep the language-switch + nav-links tests.
- [ ] **Step 3: FULL GATE (single test pass — run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
Expected: all green. **Report the `check:size` value** — adding Base UI Menu to the always-loaded shell may increase the largest chunk. If it EXCEEDS 250 KB gz, STOP and report to the controller (do not raise the budget yourself). If under, report the number.
- [ ] **Step 4: 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 5: Manual smoke (recommended).** `pnpm dev`: header shows breadcrumb (left) that updates per route (Objects / New, Objects / {number}, Vocabularies / {key}); the user menu shows email/role + Sign out works; the search box navigates to /search?q=; brand + login show the configured app name; search hidden below sm.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/app-shell.tsx web/src/shell/app-shell.test.tsx
git commit -m "feat(web): assemble header — breadcrumb, search, user menu; remove standalone sign out (#54)"
```
---
## Self-Review (completed)
**Spec coverage:** app_name brand+login + dead-key removal (T1); ui/menu Base UI wrapper + validate-by-running (T2); breadcrumb context/provider/hook/render + header mount (T3) + all routes wired incl. object_number & vocab .key (T3/T4); UserMenu email/role/sign-out (T5) + HeaderSearch → /search?q= (T5); header assembly removing the standalone Sign out (T6); check:size reported/flagged (T6); tests for breadcrumb, menu story, user-menu, header-search, app-shell update; en/sv parity (one new key, one removed); no new dep. Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the Base UI Menu part tree/props are "confirm against installed types + validate by running" — a deliberate validation step (the primitive is novel), not a TODO; concrete skeleton + reference file given. object-edit-form trail is conditional on what the component already has (explicit branch). No vague steps. ✓
**Type consistency:** `BreadcrumbItem = { label: string; to?: string }` defined in T3, used by the hook (T3), render (T3), and all page trails (T3/T4); `useBreadcrumb(trail)` signature consistent; `useMe()``{email, role}` used in UserMenu (T5); `useVocabularies().key` used in T4. Menu exports (`Menu/MenuTrigger/MenuContent/MenuItem/MenuSeparator`) defined in T2, consumed in T5. ✓
## Notes
- No new dependency (Base UI + lucide already present); one new i18n key (`search.headerPlaceholder`), one removed (`app.name`).
- The breadcrumb mirrors #57's page-driven title pattern — pages now call both `useDocumentTitle` and `useBreadcrumb`; a future consolidation into one `usePageMeta` is possible but out of scope.
- `check:size` is the one budget risk (Menu in the shell) — measured and flagged in T6, not silently bumped.
- Validate-by-running (T2/T4) is mandatory for the novel Base UI Menu, per the established repo pattern (combobox/drawer/tooltip/toast).
@@ -0,0 +1,169 @@
# App Header Wayfinding — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #54.
## Context
The `<header>` in `web/src/shell/app-shell.tsx` carries only a flex spacer + ThemeSwitch +
LangSwitch + a Sign out button — no "where am I", no user identity, no search entry. Three further
gaps: deep-linking to nested/create routes gives no header cue; the configured `app_name`
(`useConfig()`, default "Collection Management System") is never shown — the sidebar brand
(`sidebar.tsx:76`) and login heading (`login-page.tsx:38`) render the hardcoded `t("app.name")` =
"Collection"; and global search requires a full route change via the sidebar.
Facts established: routes are JSX `<Route>` elements (no `useMatches`/`handle`), so a breadcrumb can't
use react-router's match data — and the object routes carry a UUID `:id`, not the `object_number` the
breadcrumb wants. `useMe()` (`api/queries.ts:30`) returns `UserView | null` =
`{ email, id, role }`. `useLogout()` (`queries.ts:129`) POSTs `/api/admin/logout`. Base UI ships a
Menu primitive — `import { Menu } from "@base-ui/react/menu"` (namespace export) — no new dependency.
`#57` added `useDocumentTitle(page)` per page; this milestone adds a parallel page-driven breadcrumb.
The full ⌘K command palette / in-place search modal is **#33** (out of scope here).
### Decisions (from brainstorming)
1. **Page-driven breadcrumb context** (`useBreadcrumb(trail)`), parallel to `useDocumentTitle` — the
header stays dumb; detail pages supply exact dynamic crumbs (`object_number`, vocab name).
2. **User-menu dropdown** using a new `ui/menu.tsx` Base UI Menu wrapper; Sign out moves into it.
3. **Compact header search** that navigates to `/search?q=…` (light entry; the palette is #33).
4. **Brand + login use `useConfig().app_name`**; the hardcoded `app.name` i18n key is removed.
## Components
### 1. `app_name` everywhere (kill "Collection")
- `web/src/shell/sidebar.tsx:76`: `t("app.name")``useConfig().app_name` (still hidden when the
sidebar is collapsed).
- `web/src/auth/login-page.tsx`: the `<h1>` (line 38) and the `document.title` effect (line 18,
added in #57) → `useConfig().app_name`. Login is rendered inside `ConfigProvider` (mounted in
`main.tsx` around everything), so `useConfig()` works there; if `/api/config` is auth-gated
pre-login, it returns the default "Collection Management System" — still the correct product name,
and better than "Collection".
- Remove the now-unused `app.name` key from `en.json` and `sv.json` (grep confirms only the three
usages above; all are migrated). `useDocumentTitle` already reads `app_name` from `useConfig`, not
i18n, so nothing else depends on the key.
### 2. Breadcrumb (page-driven context)
- **`web/src/shell/breadcrumb-context.ts`** — `export type BreadcrumbItem = { label: string; to?: string }`
and a context `{ trail: BreadcrumbItem[]; setTrail: (t: BreadcrumbItem[]) => void }` (default
`{ trail: [], setTrail: () => {} }`).
- **`web/src/shell/breadcrumb-provider.tsx`** — holds `useState<BreadcrumbItem[]>([])`, provides
`{ trail, setTrail }`. Mounted in `AppShell` wrapping the header + main.
- **`web/src/shell/use-breadcrumb.ts`** — `useBreadcrumb(trail: BreadcrumbItem[])`: in a `useEffect`
calls `setTrail(trail)`, keyed on a **serialized** trail string
(`trail.map(i => \`${i.label}${i.to ?? ""}\`).join("")`) to avoid re-running on every
render from a fresh array literal. (No clear-on-unmount: every AppShell route sets its own trail,
so the next page overwrites — avoids an empty-then-refill flash.)
- **Header rendering:** a `Breadcrumb` element on the header's left renders the trail as
`crumb / crumb / crumb`; non-terminal crumbs with a `to` are `<Link>`s (`text-muted-foreground
hover:text-foreground`), the terminal crumb is `text-foreground`. Separator `/` (muted). Uses
`text-sm`. Truncates with `truncate`/`min-w-0` so a long `object_number` doesn't push the right side
off. (No `aria` nav landmark needed beyond a simple `<nav aria-label="Breadcrumb">`.)
**Per-page trails** (reusing existing i18n keys; dynamic labels from already-loaded data):
| Route / component | Trail |
|---|---|
| `objects-page` | `[{label: t("nav.objects")}]` |
| `object-new-page` | `[{label: t("nav.objects"), to: "/objects"}, {label: t("objects.new")}]` |
| `object-detail` (`ObjectDetailLoaded`) | `[{label: t("nav.objects"), to: "/objects"}, {label: object.object_number}]` |
| `object-edit-form` (loaded) | `[{nav.objects→/objects}, {object_number→/objects/:id}, {t("actions.edit")}]` |
| `vocabularies-page` | `[{label: t("nav.vocabularies")}]` |
| `vocabulary-terms` (loaded) | `[{nav.vocabularies→/vocabularies}, {label: <vocab name>}]` |
| `authorities-page` | `[{label: t("nav.authorities")}]` |
| `fields-page` | `[{label: t("nav.fields")}]` |
| `search-page` | `[{label: t("nav.search")}]` |
`/search/:id` reuses `ObjectDetail`, so it shows the object's canonical `Objects / {number}` trail
(acceptable — it identifies the record; refining to a search-relative trail is a later nicety).
### 3. `web/src/components/ui/menu.tsx` (new — Base UI Menu wrapper)
Wrap the Base UI Menu parts in the established `ui/*` style (`data-slot`, `cn()`, `render` prop where
a part should be a `Button`). Minimum surface needed: `Menu.Root` → `MenuTrigger`, and a
`MenuContent` composing `Menu.Portal` + `Menu.Positioner` + `Menu.Popup`, plus `MenuItem` and
`MenuSeparator`. Style the popup as a card (`bg-popover text-popover-foreground border rounded-md
shadow-md p-1`), items as `data-[highlighted]` rows (mirror the combobox/alert-dialog token classes).
**Base UI Menu is novel in this repo → the exact part tree + props (`render`, positioner side/align,
portal) must be validated by running** (a story), as combobox/drawer/tooltip/toast were. A
`menu.stories.tsx` renders a trigger + a few items and (play test) opens it and asserts an item.
### 4. `web/src/shell/user-menu.tsx`
- `const { data: me } = useMe();` and the `useLogout()` flow (moved out of the header bar).
- Trigger: a `Button variant="ghost" size="sm"` with a lucide `User`/`CircleUser` icon + the email
(truncated; icon-only below `sm` if needed). Opens `MenuContent`:
- email (a non-interactive header row, `label-caption` or muted),
- role (secondary muted text — raw `me.role`),
- `MenuSeparator`,
- `MenuItem` **Sign out** (`t("auth.signOut")`) → triggers logout + navigate to `/login` (the same
logic currently in `app-shell.tsx`).
- If `me` is null (shouldn't happen inside `AppShell`/RequireAuth), render nothing or just the trigger.
### 5. `web/src/shell/header-search.tsx`
- A small `<form>` with an `<Input>` (lucide `Search` icon, placeholder `t("search.headerPlaceholder")`).
- `onSubmit`: `navigate("/search?q=" + encodeURIComponent(query.trim()))` when non-empty; clears or
keeps the field (keep). The search page already reads `?q=` (`search-panel.tsx`), so it pre-fills +
executes. Width compact (`w-48 lg:w-64`); **hidden below `sm`** (`hidden sm:block`) to keep the
narrow header uncluttered (full responsive header = #58).
- New i18n key `search.headerPlaceholder` (en: "Search…", sv: "Sök…") in both locales (parity).
### 6. Header assembly (`app-shell.tsx`)
```tsx
<header className="flex items-center gap-4 border-b px-4 py-2">
<Breadcrumb /> {/* left, truncates */}
<div className="flex-1" />
<HeaderSearch />
<ThemeSwitch />
<LangSwitch />
<UserMenu />
</header>
```
The standalone Sign out `<Button>` is removed (now in `UserMenu`); `onSignOut`/logout logic moves to
`UserMenu`. Wrap header+main in `BreadcrumbProvider` so both the pages (setters) and the header
(reader) share it.
## Data flow
Route mounts → page calls `useBreadcrumb(trail)` → provider state updates → header `Breadcrumb`
renders it. `UserMenu` reads `useMe()` (cached `["me"]` query). `HeaderSearch` submit → router
navigate to `/search?q=`. Brand/login read `useConfig().app_name`.
## Error handling / edges
- `useBreadcrumb` effect keyed on serialized trail → no render loop, no stale array identity churn.
- A page that forgets to set a trail would show the previous page's crumbs; all AppShell routes set
one (acceptance check). Login is outside AppShell (no breadcrumb there).
- Long `object_number`/email truncate (`min-w-0 truncate`) so the header never overflows.
- Base UI Menu requires Portal + Positioner (like the other primitives) — validate by running.
- `me` null inside AppShell is not expected (RequireAuth guards), but `UserMenu` guards it.
## Testing
- **Breadcrumb:** a page sets a trail → the header renders the crumbs; a non-leaf crumb is a working
`<Link>` (click navigates). Test via `renderApp` at a nested route (e.g. `/objects/new` shows
"Objects / New" with "Objects" linking to `/objects`).
- **`ui/menu` story** (validate-by-running): open the menu, assert an item is visible.
- **UserMenu:** renders the email from a mocked `useMe`; opening it shows Sign out; clicking Sign out
triggers the logout request (MSW) and navigates to `/login`. (Mirror the existing app-shell signout
expectations — move them here.)
- **HeaderSearch:** typing a query + submit navigates to `/search?q=<encoded>` (assert the resulting
route/`?q=`).
- **app_name:** sidebar brand + login render `useConfig().app_name` (the default in tests).
- **app-shell.test.tsx:** update — the Sign out button moved into UserMenu; keep/upgrade the signout
assertion to go through the menu. Don't weaken.
- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`; en/sv parity (one new
key `search.headerPlaceholder`; `app.name` removed from both); no codename. **`check:size`:** Base UI
Menu is added to the always-loaded shell — it may nudge the largest chunk; report the value (budget
250 KB gz; raise only if it genuinely exceeds, and flag to the user rather than silently bumping).
## Acceptance criteria
1. The header shows a route-driven breadcrumb on the left for every AppShell route (section for list
pages; `Section / New|Edit` and `Section / {object_number|vocab name}` for nested), via a
page-driven `useBreadcrumb`/context; non-leaf crumbs link.
2. A user-menu dropdown on the right shows the signed-in email + role and a Sign out item (which logs
out); the standalone Sign out button is gone. Built on a reusable `ui/menu` Base UI wrapper.
3. A compact header search field navigates to `/search?q=…` (hidden below `sm`).
4. The sidebar brand and the login heading + tab title render `useConfig().app_name`; the hardcoded
`app.name` i18n key is removed.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported (within budget or
flagged); en/sv parity; no codename; no new npm dependency.
## Out of scope → follow-ups
- Full ⌘K command palette / in-place search-results modal (#33).
- Broader responsive header behavior (#58) — this only hides the search field below `sm`.
- User avatar images, a user/account settings page, role-name i18n mapping.
- Refining `/search/:id` to a search-relative breadcrumb (currently shows the canonical object trail).
+5 -3
View File
@@ -3,20 +3,22 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useLogin } from "../api/queries";
import { useConfig } from "../config/config-context";
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 { app_name } = useConfig();
const navigate = useNavigate();
const login = useLogin();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
document.title = t("app.name");
}, [t]);
document.title = app_name;
}, [app_name]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
@@ -35,7 +37,7 @@ export function LoginPage() {
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>
<h1 className="text-2xl font-semibold">{app_name}</h1>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
+2
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { PageTitle } from "@/components/ui/page-title";
import { AuthorityRow } from "./authority-row";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
type LabelInput = components["schemas"]["LabelInput"];
@@ -29,6 +30,7 @@ export function AuthoritiesPage() {
const [error, setError] = useState(false);
useDocumentTitle(t("nav.authorities"));
useBreadcrumb([{ label: t("nav.authorities") }]);
if (!isValidKind) return <Navigate to="/authorities/person" replace />;
+32
View File
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, within } from 'storybook/test'
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'
import { Button } from './button'
const meta = {
component: Menu,
tags: ['ai-generated'],
} satisfies Meta<typeof Menu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Menu>
<MenuTrigger render={<Button variant="ghost">Open</Button>} />
<MenuContent>
<MenuItem>First</MenuItem>
<MenuSeparator />
<MenuItem>Second</MenuItem>
</MenuContent>
</Menu>
),
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Open' }))
await expect(
await within(document.body).findByText('First'),
).toBeInTheDocument()
},
}
+67
View File
@@ -0,0 +1,67 @@
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
function Menu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="menu" {...props} />
}
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
}
function MenuContent({
className,
sideOffset = 6,
align = "end",
...props
}: MenuPrimitive.Popup.Props & {
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"]
align?: MenuPrimitive.Positioner.Props["align"]
}) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
sideOffset={sideOffset}
align={align}
className="z-50"
>
<MenuPrimitive.Popup
data-slot="menu-content"
className={cn(
"min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
return (
<MenuPrimitive.Item
data-slot="menu-item"
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
export { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator }
+2
View File
@@ -5,6 +5,7 @@ import type { components } from "../api/schema";
import { FieldList } from "./field-list";
import { FieldForm } from "./field-form";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
@@ -14,6 +15,7 @@ export function FieldsPage() {
const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
useDocumentTitle(t("fields.title"));
useBreadcrumb([{ label: t("nav.fields") }]);
return (
<div className="flex h-full flex-col">
+1 -1
View File
@@ -1,5 +1,4 @@
{
"app": { "name": "Collection" },
"common": { "yes": "Yes", "no": "No", "close": "Close" },
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" },
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
@@ -21,6 +20,7 @@
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
},
"search": {
"headerPlaceholder": "Search…",
"placeholder": "Search the collection…",
"all": "All",
"prompt": "Type to search",
+1 -1
View File
@@ -1,5 +1,4 @@
{
"app": { "name": "Samling" },
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng" },
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" },
"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" },
@@ -21,6 +20,7 @@
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
},
"search": {
"headerPlaceholder": "Sök…",
"placeholder": "Sök i samlingen…",
"all": "Alla",
"prompt": "Skriv för att söka",
+2
View File
@@ -6,6 +6,7 @@ import type { components } from "../api/schema";
import { useObject, useFieldDefinitions } from "../api/queries";
import { formatDate } from "../lib/format-date";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { DeleteObjectDialog } from "./delete-object-dialog";
import { FlexibleFieldValue } from "./flexible-field-value";
import { PublishControl } from "./publish-control";
@@ -52,6 +53,7 @@ function ObjectDetailLoaded({ object }: { object: AdminObjectView }) {
const { data: definitions } = useFieldDefinitions();
useDocumentTitle(object.object_number);
useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number }]);
// Prefer the active locale's label, then English, then the raw key.
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
+23 -6
View File
@@ -2,16 +2,31 @@ import { useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useObject, useUpdateObject, useSetFields, FieldRejection } from "../api/queries";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form";
type AdminObjectView = components["schemas"]["AdminObjectView"];
export function ObjectEditForm() {
const { t } = useTranslation();
const { id } = useParams();
const { data: object, isLoading } = useObject(id!);
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
return <ObjectEditFormLoaded object={object} id={id!} />;
}
function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { data: object, isLoading } = useObject(id!);
const update = useUpdateObject();
const setFields = useSetFields();
@@ -27,9 +42,11 @@ export function ObjectEditForm() {
locationState?.fieldErrorKey ?? null,
);
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
useBreadcrumb([
{ label: t("nav.objects"), to: "/objects" },
{ label: object.object_number, to: `/objects/${id}` },
{ label: t("actions.edit") },
]);
const core: ObjectCore = {
object_number: object.object_number,
@@ -49,8 +66,8 @@ export function ObjectEditForm() {
setFieldErrorKey(null);
try {
await update.mutateAsync({ id: id!, body: values.core });
await setFields.mutateAsync({ id: id!, fields: values.fields });
await update.mutateAsync({ id, body: values.core });
await setFields.mutateAsync({ id, fields: values.fields });
} catch (e) {
if (e instanceof FieldRejection) {
setFieldErrorKey(e.field);
+2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { ObjectForm, type ObjectFormValues } from "./object-form";
import { useCreateObject, useSetFields, FieldRejection } from "../api/queries";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function ObjectNewPage() {
@@ -15,6 +16,7 @@ export function ObjectNewPage() {
const [error, setError] = useState<string | null>(null);
useDocumentTitle(t("objects.new"));
useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]);
const onSubmit = async (values: ObjectFormValues) => {
setError(null);
+2
View File
@@ -6,6 +6,7 @@ import { X } from "lucide-react";
import { ObjectsTable } from "./objects-table";
import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
const ObjectDetailDrawer = lazy(() =>
@@ -26,6 +27,7 @@ export function ObjectsPage() {
const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.objects"));
useBreadcrumb([{ label: t("nav.objects") }]);
const closeDetail = () => navigate(`/objects?${searchParams}`);
+2
View File
@@ -3,12 +3,14 @@ import { useTranslation } from "react-i18next";
import { SearchPanel } from "./search-panel";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function SearchPage() {
const { t } = useTranslation();
useDocumentTitle(t("nav.search"));
useBreadcrumb([{ label: t("nav.search") }]);
return (
<div className="flex h-full flex-col">
+28 -1
View File
@@ -1,8 +1,10 @@
import { expect, test, beforeEach, afterEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Routes, Route } from "react-router-dom";
import { http, HttpResponse } from "msw";
import i18n from "../i18n";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { AppShell } from "./app-shell";
@@ -39,3 +41,28 @@ test("language switch toggles to Swedish", async () => {
await userEvent.click(await screen.findByRole("button", { name: "SV" }));
await waitFor(() => expect(screen.getByText("Föremål")).toBeInTheDocument());
});
test("signs out via the user menu and navigates to /login", async () => {
let loggedOut = false;
server.use(
http.post("/api/admin/logout", () => {
loggedOut = true;
return new HttpResponse(null, { status: 204 });
}),
);
renderApp(tree(), { route: "/objects" });
// The user menu trigger shows the signed-in email (from /api/admin/me).
const trigger = await screen.findByRole("button", { name: /editor@example.com/ });
await userEvent.click(trigger);
// Menu content renders in a portal on document.body.
const menu = within(document.body);
const signOut = await menu.findByText("Sign out");
await userEvent.click(signOut);
await waitFor(() => expect(loggedOut).toBe(true));
// Sign-out replaces the route with /login.
expect(await screen.findByText("login page")).toBeInTheDocument();
});
+19 -26
View File
@@ -1,38 +1,31 @@
import { Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Outlet } from "react-router-dom";
import { useLogout } from "../api/queries";
import { Button } from "@/components/ui/button";
import { LangSwitch } from "./lang-switch";
import { ThemeSwitch } from "./theme-switch";
import { Sidebar } from "./sidebar";
import { BreadcrumbProvider } from "./breadcrumb-provider";
import { Breadcrumb } from "./breadcrumb";
import { HeaderSearch } from "./header-search";
import { UserMenu } from "./user-menu";
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">
<Sidebar />
<div className="flex flex-1 flex-col">
<header className="flex items-center gap-4 border-b px-4 py-2">
<div className="flex-1" />
<ThemeSwitch />
<LangSwitch />
<Button variant="ghost" size="sm" onClick={onSignOut}>
{t("auth.signOut")}
</Button>
</header>
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
<BreadcrumbProvider>
<div className="flex flex-1 flex-col">
<header className="flex items-center gap-4 border-b px-4 py-2">
<Breadcrumb />
<HeaderSearch />
<ThemeSwitch />
<LangSwitch />
<UserMenu />
</header>
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
</BreadcrumbProvider>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
export type BreadcrumbItem = { label: string; to?: string };
type BreadcrumbContextValue = {
trail: BreadcrumbItem[];
setTrail: (trail: BreadcrumbItem[]) => void;
};
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
trail: [],
setTrail: () => {},
});
export function useBreadcrumbTrail(): BreadcrumbItem[] {
return useContext(BreadcrumbContext).trail;
}
+12
View File
@@ -0,0 +1,12 @@
import { useState, type ReactNode } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
return (
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
{children}
</BreadcrumbContext.Provider>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "vitest";
import { screen, within } from "@testing-library/react";
import { Routes, Route } from "react-router-dom";
import { renderApp } from "../test/render";
import { AppShell } from "./app-shell";
import { ObjectNewPage } from "../objects/object-new-page";
import { BreadcrumbProvider } from "./breadcrumb-provider";
import { Breadcrumb } from "./breadcrumb";
import { useBreadcrumb } from "./use-breadcrumb";
function Setter() {
useBreadcrumb([
{ label: "Objects", to: "/objects" },
{ label: "LM-0042" },
]);
return null;
}
test("renders the trail with a link on non-leaf crumbs", async () => {
renderApp(
<BreadcrumbProvider>
<Breadcrumb />
<Setter />
</BreadcrumbProvider>,
);
const link = await screen.findByRole("link", { name: "Objects" });
expect(link).toHaveAttribute("href", "/objects");
expect(screen.getByText("LM-0042")).toBeInTheDocument();
});
test("a nested route sets the header breadcrumb inside AppShell", async () => {
renderApp(
<Routes>
<Route element={<AppShell />}>
<Route path="/objects/new" element={<ObjectNewPage />} />
</Route>
</Routes>,
{ route: "/objects/new" },
);
const nav = await screen.findByRole("navigation", { name: "Breadcrumb" });
const within_nav = within(nav);
const objectsLink = within_nav.getByRole("link", { name: "Objects" });
expect(objectsLink).toHaveAttribute("href", "/objects");
expect(within_nav.getByText("New object")).toBeInTheDocument();
});
+30
View File
@@ -0,0 +1,30 @@
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { useBreadcrumbTrail } from "./breadcrumb-context";
export function Breadcrumb() {
const trail = useBreadcrumbTrail();
if (trail.length === 0) return <div className="min-w-0 flex-1" />;
return (
<nav aria-label="Breadcrumb" className="flex min-w-0 flex-1 items-center gap-1 text-sm">
{trail.map((item, i) => {
const last = i === trail.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
{i > 0 && <span className="text-muted-foreground">/</span>}
{item.to && !last ? (
<Link to={item.to} className="truncate text-muted-foreground hover:text-foreground">
{item.label}
</Link>
) : (
<span className={last ? "truncate text-foreground" : "truncate text-muted-foreground"}>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Routes, Route, useLocation } from "react-router-dom";
import { renderApp } from "../test/render";
import { HeaderSearch } from "./header-search";
function LocationProbe() {
const location = useLocation();
return <div data-testid="location">{location.pathname + location.search}</div>;
}
function tree() {
return (
<Routes>
<Route
path="/"
element={
<>
<HeaderSearch />
<LocationProbe />
</>
}
/>
<Route path="/search" element={<LocationProbe />} />
</Routes>
);
}
test("submitting a query navigates to /search?q=", async () => {
renderApp(tree());
const input = await screen.findByRole("searchbox", { name: "Search" });
await userEvent.type(input, "amphora{Enter}");
expect(await screen.findByTestId("location")).toHaveTextContent("/search?q=amphora");
});
test("submitting an empty query does not navigate", async () => {
renderApp(tree());
const input = await screen.findByRole("searchbox", { name: "Search" });
await userEvent.type(input, " {Enter}");
expect(screen.getByTestId("location")).toHaveTextContent("/");
expect(screen.getByTestId("location")).not.toHaveTextContent("/search");
});
+37
View File
@@ -0,0 +1,37 @@
import { Search } from "lucide-react";
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Input } from "@/components/ui/input";
export function HeaderSearch() {
const { t } = useTranslation();
const navigate = useNavigate();
const [q, setQ] = useState("");
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const query = q.trim();
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={onSubmit} className="hidden sm:block">
<div className="relative">
<Search
className="pointer-events-none absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("search.headerPlaceholder")}
aria-label={t("nav.search")}
className="w-48 pl-8 lg:w-64"
/>
</div>
</form>
);
}
+3 -1
View File
@@ -15,6 +15,7 @@ import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tooltip } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/lib/use-media-query";
import { useConfig } from "../config/config-context";
const STORAGE_KEY = "sidebar-collapsed";
@@ -50,6 +51,7 @@ function navLinkClass(collapsed: boolean) {
export function Sidebar() {
const { t } = useTranslation();
const { app_name } = useConfig();
const narrow = useMediaQuery("(max-width: 768px)");
const [stored, setStored] = useState(readStored);
@@ -73,7 +75,7 @@ export function Sidebar() {
)}
>
<div className="mb-4 flex items-center justify-between">
{!collapsed && <span className="font-semibold">{t("app.name")}</span>}
{!collapsed && <span className="font-semibold">{app_name}</span>}
<button
type="button"
onClick={toggle}
+15
View File
@@ -0,0 +1,15 @@
import { useContext, useEffect, useRef } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
const { setTrail } = useContext(BreadcrumbContext);
const trailRef = useRef(trail);
useEffect(() => {
trailRef.current = trail;
});
const key = JSON.stringify(trail.map((item) => [item.label, item.to ?? null]));
useEffect(() => {
setTrail(trailRef.current);
}, [key, setTrail]);
}
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { UserMenu } from "./user-menu";
test("shows the signed-in email on the trigger", async () => {
renderApp(<UserMenu />);
expect(await screen.findByText("editor@example.com")).toBeInTheDocument();
});
test("opens the menu showing email + role and signs out", async () => {
let loggedOut = false;
server.use(
http.post("/api/admin/logout", () => {
loggedOut = true;
return new HttpResponse(null, { status: 204 });
}),
);
renderApp(<UserMenu />);
const trigger = await screen.findByRole("button", { name: /editor@example.com/ });
await userEvent.click(trigger);
// Menu content renders in a portal on document.body.
const menu = within(document.body);
expect(await menu.findByText("editor")).toBeInTheDocument();
const signOut = await menu.findByText("Sign out");
await userEvent.click(signOut);
await waitFor(() => expect(loggedOut).toBe(true));
});
+42
View File
@@ -0,0 +1,42 @@
import { CircleUser } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLogout, useMe } from "../api/queries";
import { Button } from "@/components/ui/button";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/ui/menu";
export function UserMenu() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: me } = useMe();
const logout = useLogout();
const onSignOut = () =>
logout.mutate(undefined, {
onSuccess: () => navigate("/login", { replace: true }),
});
if (!me) return null;
return (
<Menu>
<MenuTrigger
render={
<Button variant="ghost" size="sm" className="max-w-44">
<CircleUser className="h-4 w-4" aria-hidden />
<span className="truncate">{me.email}</span>
</Button>
}
/>
<MenuContent>
<div className="px-2 py-1.5">
<div className="truncate text-sm font-medium">{me.email}</div>
<div className="text-xs text-muted-foreground">{me.role}</div>
</div>
<MenuSeparator />
<MenuItem onClick={onSignOut}>{t("auth.signOut")}</MenuItem>
</MenuContent>
</Menu>
);
}
+2
View File
@@ -3,12 +3,14 @@ import { useTranslation } from "react-i18next";
import { VocabularyList } from "./vocabulary-list";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function VocabulariesPage() {
const { t } = useTranslation();
useDocumentTitle(t("nav.vocabularies"));
useBreadcrumb([{ label: t("nav.vocabularies") }]);
return (
<div className="flex h-full flex-col">
+11 -1
View File
@@ -3,7 +3,8 @@ import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useTerms, useAddTerm } from "../api/queries";
import { useTerms, useAddTerm, useVocabularies } from "../api/queries";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { LabelEditor } from "../components/label-editor";
import { TermRow } from "./term-row";
import { Button } from "@/components/ui/button";
@@ -29,6 +30,15 @@ export function VocabularyTerms() {
const [error, setError] = useState(false);
const { data: vocabularies } = useVocabularies();
const vocabKey = vocabularies?.find((v) => v.id === id)?.key;
useBreadcrumb(
vocabKey
? [{ label: t("nav.vocabularies"), to: "/vocabularies" }, { label: vocabKey }]
: [{ label: t("nav.vocabularies"), to: "/vocabularies" }],
);
if (!id) return null;
const onAdd = (event: FormEvent) => {