feat(web): UserMenu (email/role + sign out) + HeaderSearch components (#54)

This commit is contained in:
2026-06-07 19:23:43 +02:00
parent 4b55218c69
commit 5c8fe3cd81
6 changed files with 163 additions and 0 deletions
+1
View File
@@ -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",
+1
View File
@@ -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",
+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>
);
}
+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>
);
}