From 5c8fe3cd810b7c5f5fc894c65877df6c18692afb Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 19:23:43 +0200 Subject: [PATCH] feat(web): UserMenu (email/role + sign out) + HeaderSearch components (#54) --- web/src/i18n/en.json | 1 + web/src/i18n/sv.json | 1 + web/src/shell/header-search.test.tsx | 47 ++++++++++++++++++++++++++++ web/src/shell/header-search.tsx | 37 ++++++++++++++++++++++ web/src/shell/user-menu.test.tsx | 35 +++++++++++++++++++++ web/src/shell/user-menu.tsx | 42 +++++++++++++++++++++++++ 6 files changed, 163 insertions(+) create mode 100644 web/src/shell/header-search.test.tsx create mode 100644 web/src/shell/header-search.tsx create mode 100644 web/src/shell/user-menu.test.tsx create mode 100644 web/src/shell/user-menu.tsx diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 00705ee..6dd2891 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -20,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 8157ddf..247f64d 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -20,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/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/user-menu.test.tsx b/web/src/shell/user-menu.test.tsx new file mode 100644 index 0000000..70085b9 --- /dev/null +++ b/web/src/shell/user-menu.test.tsx @@ -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(); + 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(); + + 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)); +}); diff --git a/web/src/shell/user-menu.tsx b/web/src/shell/user-menu.tsx new file mode 100644 index 0000000..a8779be --- /dev/null +++ b/web/src/shell/user-menu.tsx @@ -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 ( + + + + {me.email} + + } + /> + +
+
{me.email}
+
{me.role}
+
+ + {t("auth.signOut")} +
+
+ ); +}