feat(web): app shell with sidebar nav, language switch, sign out
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { expect, test, beforeEach, afterEach } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { AppShell } from "./app-shell";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="/objects" element={<div>objects outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" element={<div>login page</div>} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shows active and disabled nav and renders the outlet", async () => {
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
|
||||||
|
// later milestones are present but disabled
|
||||||
|
expect(screen.getByRole("button", { name: /search/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("language switch toggles to Swedish", async () => {
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "SV" }));
|
||||||
|
await waitFor(() => expect(screen.getByText("Föremål")).toBeInTheDocument());
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useLogout } from "../api/queries";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LangSwitch } from "./lang-switch";
|
||||||
|
|
||||||
|
const FUTURE = ["vocabularies", "authorities", "fields", "search"] as const;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<aside className="w-44 shrink-0 border-r bg-neutral-50 p-3">
|
||||||
|
<div className="mb-4 font-semibold">{t("app.name")}</div>
|
||||||
|
<nav className="space-y-1 text-sm">
|
||||||
|
<NavLink
|
||||||
|
to="/objects"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.objects")}
|
||||||
|
</NavLink>
|
||||||
|
{FUTURE.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
disabled
|
||||||
|
title={t("nav.soon")}
|
||||||
|
className="block w-full cursor-not-allowed rounded px-2 py-1 text-left text-neutral-400"
|
||||||
|
>
|
||||||
|
{t(`nav.${key}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<LangSwitch />
|
||||||
|
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
||||||
|
{t("auth.signOut")}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useLocale } from "../i18n/use-locale";
|
||||||
|
|
||||||
|
export function LangSwitch() {
|
||||||
|
const { locale, setLocale } = useLocale();
|
||||||
|
const base = locale.startsWith("sv") ? "sv" : "en";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 text-xs">
|
||||||
|
{(["sv", "en"] as const).map((lng) => (
|
||||||
|
<button
|
||||||
|
key={lng}
|
||||||
|
onClick={() => setLocale(lng)}
|
||||||
|
aria-pressed={base === lng}
|
||||||
|
className={base === lng ? "font-bold" : "text-neutral-400"}
|
||||||
|
>
|
||||||
|
{lng.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user