diff --git a/docs/superpowers/plans/2026-06-07-header-wayfinding.md b/docs/superpowers/plans/2026-06-07-header-wayfinding.md new file mode 100644 index 0000000..ef94858 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-header-wayfinding.md @@ -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 && {t("app.name")}}` → + `{!collapsed && {app_name}}`. + +- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `

` (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 1–2). 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 +} + +function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function MenuContent({ + className, + sideOffset = 6, + align = "end", + ...props +}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) { + return ( + + + + + + ) +} + +function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) { + return ( + + ) +} + +function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + Open} /> + + First + + Second + + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Open' })) + await expect(await canvas.findByText('First')).toBeInTheDocument() + }, +} +``` +If `MenuTrigger render={ + } + /> + +
+
{me.email}
+
{me.role}
+
+ + {t("auth.signOut")} +
+ + ); +} +``` +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 ( +
+
+ + setQ(e.target.value)} + placeholder={t("search.headerPlaceholder")} + aria-label={t("nav.search")} + className="w-48 pl-8 lg:w-64" + /> +
+
+ ); +} +``` + +- [ ] **Step 4: Tests.** + - `web/src/shell/user-menu.test.tsx`: render `` 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 `` 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 `} /> + + First + + Second + + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Open' })) + await expect( + await within(document.body).findByText('First'), + ).toBeInTheDocument() + }, +} diff --git a/web/src/components/ui/menu.tsx b/web/src/components/ui/menu.tsx new file mode 100644 index 0000000..b0a4d3d --- /dev/null +++ b/web/src/components/ui/menu.tsx @@ -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 +} + +function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function MenuContent({ + className, + sideOffset = 6, + align = "end", + ...props +}: MenuPrimitive.Popup.Props & { + sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"] + align?: MenuPrimitive.Positioner.Props["align"] +}) { + return ( + + + + + + ) +} + +function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) { + return ( + + ) +} + +function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +export { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator } diff --git a/web/src/fields/fields-page.tsx b/web/src/fields/fields-page.tsx index 3027317..47adb86 100644 --- a/web/src/fields/fields-page.tsx +++ b/web/src/fields/fields-page.tsx @@ -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(null); useDocumentTitle(t("fields.title")); + useBreadcrumb([{ label: t("nav.fields") }]); return (
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index ae7c008..6dd2891 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index fdfac42..247f64d 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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", diff --git a/web/src/objects/object-detail.tsx b/web/src/objects/object-detail.tsx index fc6e2ac..326d48f 100644 --- a/web/src/objects/object-detail.tsx +++ b/web/src/objects/object-detail.tsx @@ -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"; diff --git a/web/src/objects/object-edit-form.tsx b/web/src/objects/object-edit-form.tsx index 3bb9188..39f1006 100644 --- a/web/src/objects/object-edit-form.tsx +++ b/web/src/objects/object-edit-form.tsx @@ -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
; + + if (!object) return

{t("objects.notFound")}

; + + return ; +} + +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
; - - if (!object) return

{t("objects.notFound")}

; + 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); diff --git a/web/src/objects/object-new-page.tsx b/web/src/objects/object-new-page.tsx index 4887877..d541b76 100644 --- a/web/src/objects/object-new-page.tsx +++ b/web/src/objects/object-new-page.tsx @@ -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(null); useDocumentTitle(t("objects.new")); + useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]); const onSubmit = async (values: ObjectFormValues) => { setError(null); diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx index d3f490c..5894ec6 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -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}`); diff --git a/web/src/search/search-page.tsx b/web/src/search/search-page.tsx index a14706a..4084b13 100644 --- a/web/src/search/search-page.tsx +++ b/web/src/search/search-page.tsx @@ -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 (
diff --git a/web/src/shell/app-shell.test.tsx b/web/src/shell/app-shell.test.tsx index 5e60cd2..728e4f3 100644 --- a/web/src/shell/app-shell.test.tsx +++ b/web/src/shell/app-shell.test.tsx @@ -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(); +}); diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index d7ff942..650429f 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -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 (
-
-
-
- - - -
-
- -
-
+ +
+
+ + + + + +
+
+ +
+
+
); } diff --git a/web/src/shell/breadcrumb-context.ts b/web/src/shell/breadcrumb-context.ts new file mode 100644 index 0000000..d1efe51 --- /dev/null +++ b/web/src/shell/breadcrumb-context.ts @@ -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({ + trail: [], + setTrail: () => {}, +}); + +export function useBreadcrumbTrail(): BreadcrumbItem[] { + return useContext(BreadcrumbContext).trail; +} diff --git a/web/src/shell/breadcrumb-provider.tsx b/web/src/shell/breadcrumb-provider.tsx new file mode 100644 index 0000000..9bbca29 --- /dev/null +++ b/web/src/shell/breadcrumb-provider.tsx @@ -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([]); + return ( + + {children} + + ); +} diff --git a/web/src/shell/breadcrumb.test.tsx b/web/src/shell/breadcrumb.test.tsx new file mode 100644 index 0000000..67d090d --- /dev/null +++ b/web/src/shell/breadcrumb.test.tsx @@ -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( + + + + , + ); + 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( + + }> + } /> + + , + { 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(); +}); diff --git a/web/src/shell/breadcrumb.tsx b/web/src/shell/breadcrumb.tsx new file mode 100644 index 0000000..68ed412 --- /dev/null +++ b/web/src/shell/breadcrumb.tsx @@ -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
; + return ( + + ); +} diff --git a/web/src/shell/header-search.test.tsx b/web/src/shell/header-search.test.tsx new file mode 100644 index 0000000..99b9c91 --- /dev/null +++ b/web/src/shell/header-search.test.tsx @@ -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
{location.pathname + location.search}
; +} + +function tree() { + return ( + + + + + + } + /> + } /> + + ); +} + +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"); +}); diff --git a/web/src/shell/header-search.tsx b/web/src/shell/header-search.tsx new file mode 100644 index 0000000..c0b50ca --- /dev/null +++ b/web/src/shell/header-search.tsx @@ -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 ( + +
+ + setQ(e.target.value)} + placeholder={t("search.headerPlaceholder")} + aria-label={t("nav.search")} + className="w-48 pl-8 lg:w-64" + /> +
+ + ); +} diff --git a/web/src/shell/sidebar.tsx b/web/src/shell/sidebar.tsx index 25b041b..e354241 100644 --- a/web/src/shell/sidebar.tsx +++ b/web/src/shell/sidebar.tsx @@ -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() { )} >
- {!collapsed && {t("app.name")}} + {!collapsed && {app_name}} + } + /> + +
+
{me.email}
+
{me.role}
+
+ + {t("auth.signOut")} +
+ + ); +} diff --git a/web/src/vocab/vocabularies-page.tsx b/web/src/vocab/vocabularies-page.tsx index 64e06d5..a82c8d1 100644 --- a/web/src/vocab/vocabularies-page.tsx +++ b/web/src/vocab/vocabularies-page.tsx @@ -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 (
diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index 4a786a2..dde518f 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -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) => {