mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(ui): make mobile sidebar and top bar responsive (#44603)
* fix(ui): improve gateway layouts on mobile * fix(ui): limit mobile cleanup to navigation and header * fix(ui): share cached settings with mobile navigation
This commit is contained in:
parent
8364f88cbb
commit
3b2ed83152
8 changed files with 328 additions and 84 deletions
|
|
@ -0,0 +1,45 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import Sidebar from "@/components/leftnav";
|
||||
import SidebarProvider from "./SidebarProvider";
|
||||
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: true } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({ getUiSettings: getSettings, getUISettings: getSettings }));
|
||||
vi.mock("@/components/leftnav", () => ({
|
||||
default: ({ enableProjectsUI }: ComponentProps<typeof Sidebar>) => (
|
||||
<nav>{enableProjectsUI && <a href="/projects">Projects</a>}</nav>
|
||||
),
|
||||
}));
|
||||
|
||||
it("reuses loaded navigation settings immediately when the mobile sidebar mounts again", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const shell = (mobileOpen: boolean) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<section aria-label="Desktop navigation">
|
||||
<SidebarProvider sidebarCollapsed={false} />
|
||||
</section>
|
||||
{mobileOpen && (
|
||||
<section aria-label="Mobile navigation">
|
||||
<SidebarProvider sidebarCollapsed={false} />
|
||||
</section>
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(shell(false));
|
||||
expect(await screen.findByRole("link", { name: "Projects" })).toBeVisible();
|
||||
|
||||
for (const open of [true, false, true]) {
|
||||
rerender(shell(open));
|
||||
if (open) {
|
||||
expect(
|
||||
within(screen.getByRole("region", { name: "Mobile navigation" })).getByRole("link", { name: "Projects" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
expect(getSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import Sidebar from "@/components/leftnav";
|
||||
import { getUISettings } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
|
||||
interface SidebarProviderProps {
|
||||
sidebarCollapsed: boolean;
|
||||
|
|
@ -11,66 +9,19 @@ interface SidebarProviderProps {
|
|||
}
|
||||
|
||||
const SidebarProvider = ({ sidebarCollapsed, onToggleCollapsed }: SidebarProviderProps) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState<string[] | null>(null);
|
||||
const [enableProjectsUI, setEnableProjectsUI] = useState<boolean>(false);
|
||||
const [disableAgentsForInternalUsers, setDisableAgentsForInternalUsers] = useState<boolean>(false);
|
||||
const [allowAgentsForTeamAdmins, setAllowAgentsForTeamAdmins] = useState<boolean>(false);
|
||||
const [disableVectorStoresForInternalUsers, setDisableVectorStoresForInternalUsers] = useState<boolean>(false);
|
||||
const [allowVectorStoresForTeamAdmins, setAllowVectorStoresForTeamAdmins] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUISettings = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getUISettings(accessToken);
|
||||
|
||||
// API returns 'values' not 'settings'
|
||||
if (settings?.values?.enabled_ui_pages_internal_users !== undefined) {
|
||||
setEnabledPagesInternalUsers(settings.values.enabled_ui_pages_internal_users);
|
||||
} else {
|
||||
}
|
||||
|
||||
if (settings?.values?.enable_projects_ui !== undefined) {
|
||||
setEnableProjectsUI(Boolean(settings.values.enable_projects_ui));
|
||||
}
|
||||
|
||||
if (settings?.values?.disable_agents_for_internal_users !== undefined) {
|
||||
setDisableAgentsForInternalUsers(Boolean(settings.values.disable_agents_for_internal_users));
|
||||
}
|
||||
|
||||
if (settings?.values?.allow_agents_for_team_admins !== undefined) {
|
||||
setAllowAgentsForTeamAdmins(Boolean(settings.values.allow_agents_for_team_admins));
|
||||
}
|
||||
|
||||
if (settings?.values?.disable_vector_stores_for_internal_users !== undefined) {
|
||||
setDisableVectorStoresForInternalUsers(Boolean(settings.values.disable_vector_stores_for_internal_users));
|
||||
}
|
||||
|
||||
if (settings?.values?.allow_vector_stores_for_team_admins !== undefined) {
|
||||
setAllowVectorStoresForTeamAdmins(Boolean(settings.values.allow_vector_stores_for_team_admins));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SidebarProvider] Failed to fetch UI settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUISettings();
|
||||
}, [accessToken]);
|
||||
const { data: settings } = useUISettings();
|
||||
const values = settings?.values;
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
enabledPagesInternalUsers={enabledPagesInternalUsers}
|
||||
enableProjectsUI={enableProjectsUI}
|
||||
disableAgentsForInternalUsers={disableAgentsForInternalUsers}
|
||||
allowAgentsForTeamAdmins={allowAgentsForTeamAdmins}
|
||||
disableVectorStoresForInternalUsers={disableVectorStoresForInternalUsers}
|
||||
allowVectorStoresForTeamAdmins={allowVectorStoresForTeamAdmins}
|
||||
enabledPagesInternalUsers={values?.enabled_ui_pages_internal_users ?? null}
|
||||
enableProjectsUI={Boolean(values?.enable_projects_ui)}
|
||||
disableAgentsForInternalUsers={Boolean(values?.disable_agents_for_internal_users)}
|
||||
allowAgentsForTeamAdmins={Boolean(values?.allow_agents_for_team_admins)}
|
||||
disableVectorStoresForInternalUsers={Boolean(values?.disable_vector_stores_for_internal_users)}
|
||||
allowVectorStoresForTeamAdmins={Boolean(values?.allow_vector_stores_for_team_admins)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import Layout from "./layout";
|
||||
|
|
@ -19,12 +19,17 @@ vi.mock("@/components/liteadmin/LiteAdmin", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/DashboardHeader", () => ({
|
||||
DashboardHeader: () => <div data-testid="dashboard-header" />,
|
||||
DashboardHeader: ({ navigationTrigger }: { navigationTrigger?: React.ReactNode }) => (
|
||||
<div data-testid="dashboard-header">{navigationTrigger}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/components/SidebarProvider", () => ({
|
||||
default: ({ sidebarCollapsed }: { sidebarCollapsed: boolean }) => (
|
||||
<div data-testid="sidebar" data-collapsed={String(sidebarCollapsed)} />
|
||||
default: ({ sidebarCollapsed, onToggleCollapsed }: { sidebarCollapsed: boolean; onToggleCollapsed: () => void }) => (
|
||||
<div data-testid="sidebar" data-collapsed={String(sidebarCollapsed)}>
|
||||
<button onClick={onToggleCollapsed}>Close navigation</button>
|
||||
<a href="#settings">Settings</a>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -89,6 +94,47 @@ describe("(dashboard) Layout", () => {
|
|||
vi.mocked(usePathname).mockReturnValue("/ui/guardrails");
|
||||
});
|
||||
|
||||
it("starts mobile navigation closed, opens a modal drawer and closes it after choosing a page", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<p>Gateway content</p>
|
||||
</Layout>
|
||||
</AuthProvider>,
|
||||
);
|
||||
pendingUiConfig.resolve();
|
||||
|
||||
const trigger = await screen.findByRole("button", { name: "Open navigation" });
|
||||
expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const navigation = await screen.findByRole("dialog", { name: "Navigation" });
|
||||
expect(within(navigation).getByRole("button", { name: "Close navigation" })).toBeInTheDocument();
|
||||
fireEvent.click(within(navigation).getByRole("link", { name: "Settings" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("closes the mobile drawer when navigation changes outside the drawer", async () => {
|
||||
const dashboard = () => (
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<p>Gateway content</p>
|
||||
</Layout>
|
||||
</AuthProvider>
|
||||
);
|
||||
const { rerender } = render(dashboard());
|
||||
pendingUiConfig.resolve();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open navigation" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Navigation" })).toBeInTheDocument();
|
||||
|
||||
vi.mocked(usePathname).mockReturnValue("/ui/api-keys");
|
||||
rerender(dashboard());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument());
|
||||
vi.mocked(usePathname).mockReturnValue("/ui/guardrails");
|
||||
rerender(dashboard());
|
||||
expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses the sidebar on Logs for a full-screen view and expands it again after leaving", async () => {
|
||||
const dashboard = () => (
|
||||
<AuthProvider>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
|
|||
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
|
||||
import { createApiClient } from "@/lib/http/client";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
|
||||
const pluginApiClient = createApiClient({ getBaseUrl: () => getProxyBaseUrl() ?? "" });
|
||||
|
||||
|
|
@ -104,7 +108,16 @@ const FULL_BLEED_SEGMENTS = new Set(["logs"]);
|
|||
function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const { accessToken } = useAuth();
|
||||
const { mode } = usePluginMode();
|
||||
const routeSegment = routeSegmentForPathname(usePathname());
|
||||
const pathname = usePathname();
|
||||
const routeSegment = routeSegmentForPathname(pathname);
|
||||
const searchParams = useSearchParams();
|
||||
const navigationKey = `${pathname}?${searchParams.toString()}`;
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
|
||||
const [mobileNavigationKey, setMobileNavigationKey] = useState<string | null>(null);
|
||||
if (mobileNavigationKey !== null && (isDesktop || mobileNavigationKey !== navigationKey)) {
|
||||
setMobileNavigationKey(null);
|
||||
}
|
||||
const mobileNavigationOpen = !isDesktop && mobileNavigationKey === navigationKey;
|
||||
const isFullBleed = FULL_BLEED_SEGMENTS.has(routeSegment);
|
||||
// A manual toggle holds only for the route it was made on; full-bleed routes default to collapsed.
|
||||
const [sidebarOverride, setSidebarOverride] = useState<{ segment: string; collapsed: boolean } | null>(null);
|
||||
|
|
@ -138,21 +151,46 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
// sidebar owns its own scroll and the content column scrolls independently,
|
||||
// so the page can't be dragged past the end of the nav.
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
|
||||
<LiteAdminFrame>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<DashboardHeader />
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<UpgradeBanner accessToken={accessToken} />
|
||||
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
<Sheet open={mobileNavigationOpen} onOpenChange={(open) => setMobileNavigationKey(open ? navigationKey : null)}>
|
||||
<div className="flex h-screen overflow-hidden bg-background max-md:h-dvh">
|
||||
<div className="hidden h-full md:flex">
|
||||
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
|
||||
</div>
|
||||
</LiteAdminFrame>
|
||||
</div>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="gap-0 p-0 data-[side=left]:w-[min(280px,calc(100vw-3rem))] [&_[data-slot=sidebar]]:w-full"
|
||||
onClickCapture={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null);
|
||||
}}
|
||||
>
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
|
||||
</SheetContent>
|
||||
<LiteAdminFrame>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<DashboardHeader
|
||||
navigationTrigger={
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" className="size-11 md:hidden" aria-label="Open navigation" />
|
||||
}
|
||||
>
|
||||
<Menu />
|
||||
</SheetTrigger>
|
||||
}
|
||||
/>
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<UpgradeBanner accessToken={accessToken} />
|
||||
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</LiteAdminFrame>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginModeProvider } from "@/contexts/PluginModeContext";
|
||||
import { DashboardHeader } from "./DashboardHeader";
|
||||
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" }));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/get/ui_settings")) return Response.json({ values: {} });
|
||||
if (url.endsWith("/litellm/.well-known/litellm-ui-config")) {
|
||||
const config = {
|
||||
server_root_path: "",
|
||||
proxy_base_url: "",
|
||||
is_control_plane: true,
|
||||
workers: [
|
||||
{ worker_id: "primary", name: "Primary worker", url: "http://primary.test" },
|
||||
{ worker_id: "backup", name: "Backup worker", url: "http://backup.test" },
|
||||
],
|
||||
};
|
||||
return Response.json(config);
|
||||
}
|
||||
if (url.endsWith("/api/plugins")) {
|
||||
return Response.json([{ name: "metrics", display_name: "Metrics", url: "/metrics" }]);
|
||||
}
|
||||
if (url.endsWith("/public/litellm_blog_posts")) {
|
||||
return Response.json({
|
||||
posts: [
|
||||
{
|
||||
title: "Gateway update",
|
||||
description: "Release notes",
|
||||
date: "2026-01-01",
|
||||
url: "https://example.com/update",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Response.json({}, { status: 404 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
async function openTools() {
|
||||
const user = userEvent.setup();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PluginModeProvider accessToken="test-session">
|
||||
<DashboardHeader />
|
||||
</PluginModeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "More options" }));
|
||||
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
|
||||
return { user, tools };
|
||||
}
|
||||
|
||||
describe("mobile header nested controls", () => {
|
||||
it.each(["mouse", "touch"] as const)(
|
||||
"selects a portalled gateway item with %s without dismissing More",
|
||||
async (pointer) => {
|
||||
const { user, tools } = await openTools();
|
||||
await user.click(within(tools).getByRole("button", { name: "AI Gateway" }));
|
||||
const item = await screen.findByRole("menuitem", { name: "Metrics" });
|
||||
if (pointer === "touch") {
|
||||
await user.pointer([
|
||||
{ keys: "[TouchA>]", target: item },
|
||||
{ keys: "[/TouchA]", target: item },
|
||||
]);
|
||||
} else {
|
||||
await user.click(item);
|
||||
}
|
||||
expect(within(tools).getByRole("button", { name: "Metrics" })).toBeVisible();
|
||||
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a nested blog link receive the click before closing its menu", async () => {
|
||||
const { user, tools } = await openTools();
|
||||
await user.click(within(tools).getByRole("button", { name: "Blog" }));
|
||||
const link = await screen.findByRole("link", { name: /Gateway update/ });
|
||||
expect(link).toHaveAttribute("href", "https://example.com/update");
|
||||
await user.click(link);
|
||||
expect(screen.queryByRole("menu", { name: "Blog" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("allows filtering and selecting a worker from the nested combobox", async () => {
|
||||
localStorage.setItem("litellm_selected_worker_id", "primary");
|
||||
const { user, tools } = await openTools();
|
||||
const worker = await within(tools).findByRole("combobox", { name: "Worker" });
|
||||
await user.clear(worker);
|
||||
await user.type(worker, "Backup");
|
||||
await user.click(await screen.findByRole("option", { name: "Backup worker" }));
|
||||
expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull();
|
||||
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
|
|||
plugins: [] as { name: string; display_name: string; url: string }[],
|
||||
enableChatUI: false,
|
||||
pathname: "/ui/logs",
|
||||
isDesktop: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
|
|
@ -19,6 +20,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
|
|||
vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => state.pathname }));
|
||||
vi.mock("usehooks-ts", () => ({ useMediaQuery: () => state.isDesktop }));
|
||||
vi.mock("@/hooks/useWorker", () => ({ useWorker: () => ({ isControlPlane: false, selectedWorker: null }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ useDisableShowPrompts: () => false }));
|
||||
vi.mock("@/components/Navbar/BlogDropdown/BlogDropdown", () => ({ BlogDropdown: () => null }));
|
||||
|
|
@ -34,6 +36,7 @@ describe("DashboardHeader breadcrumb", () => {
|
|||
state.plugins = [];
|
||||
state.enableChatUI = false;
|
||||
state.pathname = "/ui/logs";
|
||||
state.isDesktop = false;
|
||||
});
|
||||
|
||||
it("titles the breadcrumb from the current route, not from a sidebar page id", () => {
|
||||
|
|
@ -99,4 +102,27 @@ describe("DashboardHeader breadcrumb", () => {
|
|||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the gateway selector and tools available from the compact header menu", async () => {
|
||||
render(<DashboardHeader />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "More options" }));
|
||||
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
|
||||
expect(within(tools).getByRole("button", { name: "AI Gateway" })).toBeInTheDocument();
|
||||
expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument();
|
||||
expect(within(tools).getByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => {
|
||||
const { rerender } = render(<DashboardHeader />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "More options" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument();
|
||||
|
||||
state.isDesktop = true;
|
||||
rerender(<DashboardHeader />);
|
||||
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
|
||||
|
||||
state.isDesktop = false;
|
||||
rerender(<DashboardHeader />);
|
||||
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,11 +22,19 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPro
|
|||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
// Top bar for the dashboard shell. Sits only over the content column (the brand
|
||||
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
|
||||
export function DashboardHeader() {
|
||||
export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: ReactNode }) {
|
||||
const { title } = getBreadcrumb(usePathname());
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
|
||||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||
if (isDesktop && mobileToolsOpen) setMobileToolsOpen(false);
|
||||
const { isControlPlane, selectedWorker } = useWorker();
|
||||
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
|
||||
const hideCommunityLinks = useDisableShowPrompts();
|
||||
|
|
@ -40,20 +48,21 @@ export function DashboardHeader() {
|
|||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4">
|
||||
<Breadcrumb className="min-w-0">
|
||||
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4 max-md:gap-2 max-md:px-2">
|
||||
{navigationTrigger}
|
||||
<Breadcrumb className="min-w-0 max-md:flex-1">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="flex-none">
|
||||
<BreadcrumbItem className="flex-none max-md:hidden">
|
||||
<ViewSwitcher />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbSeparator className="max-md:hidden" />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="flex flex-none items-center gap-1">
|
||||
<div className="flex flex-none items-center gap-1 max-md:hidden">
|
||||
{showWorkerSwitch && (
|
||||
<>
|
||||
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
|
||||
|
|
@ -68,6 +77,26 @@ export function DashboardHeader() {
|
|||
<ThemeToggle />
|
||||
<NotificationsBell />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 md:hidden">
|
||||
<NotificationsBell />
|
||||
<Popover open={!isDesktop && mobileToolsOpen} onOpenChange={setMobileToolsOpen}>
|
||||
<PopoverTrigger render={<Button variant="ghost" size="icon" className="size-11" aria-label="More options" />}>
|
||||
<Ellipsis />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="max-w-[calc(100vw-2rem)]">
|
||||
<PopoverTitle>Gateway tools</PopoverTitle>
|
||||
<ViewSwitcher />
|
||||
{showWorkerSwitch && <WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LiteAdmin />
|
||||
<ThemeToggle />
|
||||
<DocsLink />
|
||||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
|||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
[
|
||||
"group/menu-btn relative flex w-full items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-left text-[13px] font-medium no-underline",
|
||||
"group/menu-btn relative flex w-full items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-left text-[13px] font-medium no-underline max-md:min-h-11",
|
||||
"text-sidebar-foreground/70 outline-none transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue