mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(ui): keep the account menu in agent-plane mode and update e2e for the new sidebar
The redesigned sidebar + header shell is now scoped to the ai-gateway dashboard. Agent-control-plane (plugin) mode falls back to the original full-width Navbar, which carries the account menu, so preferences and sign-out stay reachable there; Chat and the public model hub already use that Navbar Update the e2e specs the antd Menu -> shadcn nav swap broke. Sidebar items are now links/buttons rather than antd menuitems, and the top bar renders a breadcrumb whose current-page item is also a "Virtual Keys" link, so the migration and navigation smoke checks now scope their sidebar lookups to the complementary landmark and expand the new collapsible groups by data-slot. Also harden the collapsed-rail unit test to assert the icon and link rather than the CSS-hidden label
This commit is contained in:
parent
7d63e452f1
commit
87d979d399
7 changed files with 59 additions and 29 deletions
|
|
@ -6,7 +6,8 @@ test.describe("Logout", () => {
|
|||
|
||||
test("Clicking Logout clears the session and forces re-login on a protected page", async ({ page }) => {
|
||||
await page.goto("/ui");
|
||||
await expect(page.getByText("Virtual Keys")).toBeVisible({ timeout: 10_000 });
|
||||
// Scope to the sidebar; the top-bar breadcrumb also shows "Virtual Keys".
|
||||
await expect(page.getByRole("complementary").getByText("Virtual Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Open the navbar User dropdown. The trigger button exposes an aria-label
|
||||
// of "Account menu — <role> — signed in as <email>", and the antd Dropdown
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ test.describe("PROXY_LOGOUT_URL redirect", () => {
|
|||
timeout: 30_000,
|
||||
});
|
||||
await page.goto("/ui");
|
||||
await expect(page.getByText("Virtual Keys")).toBeVisible({ timeout: 15_000 });
|
||||
// Scope to the sidebar; the top-bar breadcrumb also shows "Virtual Keys".
|
||||
await expect(page.getByRole("complementary").getByText("Virtual Keys")).toBeVisible({ timeout: 15_000 });
|
||||
await settingsLoaded;
|
||||
|
||||
// Pre-condition: we start authenticated. The admin storage state carries a
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ test.describe("Internal User with no team memberships", () => {
|
|||
await page.getByPlaceholder("Enter your username").fill("noteam@test.local");
|
||||
await page.getByPlaceholder("Enter your password").fill("test");
|
||||
await page.getByRole("button", { name: "Login", exact: true }).click();
|
||||
await expect(page.getByText("Virtual Keys")).toBeVisible({ timeout: 15_000 });
|
||||
// Scope to the sidebar; the top-bar breadcrumb also shows "Virtual Keys".
|
||||
await expect(page.getByRole("complementary").getByText("Virtual Keys")).toBeVisible({ timeout: 15_000 });
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Open the Create Key modal.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ const ROOT = process.env.SERVER_ROOT_PATH ?? "";
|
|||
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pathRe = (segment: string) => new RegExp(`${esc(ROOT)}/ui/${esc(segment)}/?($|\\?)`);
|
||||
const virtualKeysLink = (page: Page) => page.getByRole("link", { name: "Virtual Keys", exact: true });
|
||||
// Scope nav lookups to the sidebar (a `complementary` landmark). The top bar
|
||||
// now renders a breadcrumb whose current-page item is also a "Virtual Keys"
|
||||
// link, so an unscoped locator would match two elements.
|
||||
const sidebar = (page: Page) => page.getByRole("complementary");
|
||||
const virtualKeysLink = (page: Page) => sidebar(page).getByRole("link", { name: "Virtual Keys", exact: true });
|
||||
|
||||
/** The dashboard shell is present (sidebar rendered); page didn't 404 / crash. */
|
||||
async function expectRendered(page: Page) {
|
||||
|
|
@ -26,16 +30,21 @@ async function expectRendered(page: Page) {
|
|||
|
||||
/**
|
||||
* Click a migrated page's sidebar link. Migrated items render as <a href=".../ui/<segment>">;
|
||||
* nested ones live under collapsible submenus, so expand submenus until the link is clickable.
|
||||
* nested ones live under collapsible groups whose children only render while the
|
||||
* group is open, so expand collapsed groups until the link is clickable.
|
||||
*/
|
||||
async function clickSidebar(page: Page, segment: string) {
|
||||
const link = page.locator(`a[href$="/ui/${segment}"]`).first();
|
||||
const link = sidebar(page).locator(`a[href$="/ui/${segment}"]`).first();
|
||||
for (let i = 0; i < 8 && !(await link.isVisible().catch(() => false)); i++) {
|
||||
const collapsedSubmenu = page
|
||||
.locator(".ant-menu-submenu:not(.ant-menu-submenu-open) > .ant-menu-submenu-title")
|
||||
// A collapsed group is a menu item with a group-toggle button but no
|
||||
// rendered submenu yet; clicking the toggle expands it.
|
||||
const collapsedGroup = sidebar(page)
|
||||
.locator(
|
||||
'[data-slot="sidebar-menu-item"]:has(> [data-slot="sidebar-menu-button"]):not(:has(> [data-slot="sidebar-menu-sub"])) > [data-slot="sidebar-menu-button"]',
|
||||
)
|
||||
.first();
|
||||
if (!(await collapsedSubmenu.isVisible().catch(() => false))) break;
|
||||
await collapsedSubmenu.click();
|
||||
if (!(await collapsedGroup.isVisible().catch(() => false))) break;
|
||||
await collapsedGroup.click();
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
await link.click();
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ for (const { role, storage } of roles) {
|
|||
throw new Error(`No page mapping found for menu label: ${buttonLabel}`);
|
||||
}
|
||||
|
||||
const tab = page.getByRole("menuitem", { name: buttonLabel });
|
||||
// Sidebar items are links inside the `complementary` landmark; scoping
|
||||
// there avoids the top-bar breadcrumb, which also links the page name.
|
||||
const tab = page.getByRole("complementary").getByRole("link", { name: buttonLabel });
|
||||
await expect(tab).toBeVisible();
|
||||
|
||||
await tab.click();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { Suspense, useState, useRef, useEffect } from "react";
|
||||
import { DashboardHeader } from "@/components/DashboardHeader";
|
||||
import Navbar from "@/components/navbar";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
|
@ -109,30 +110,39 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage));
|
||||
};
|
||||
|
||||
// Non-gateway (agent control plane) mode keeps the original full-width Navbar,
|
||||
// which carries the account menu; the redesigned sidebar + header shell is
|
||||
// scoped to the ai-gateway dashboard. Chat and the public model hub are
|
||||
// separate routes that likewise keep the old Navbar.
|
||||
if (!isGateway) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background">
|
||||
<Navbar accessToken={accessToken} isPublicPage={false} />
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<main className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<AgentControlPlaneView />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard app shell: the viewport is fixed height and never scrolls. The
|
||||
// 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">
|
||||
{isGateway && (
|
||||
<SidebarProvider
|
||||
setPage={navigateToPage}
|
||||
defaultSelectedKey={page}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
<SidebarProvider
|
||||
setPage={navigateToPage}
|
||||
defaultSelectedKey={page}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<DashboardHeader page={page} />
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
{isGateway ? (
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
) : (
|
||||
<main className="flex min-w-0 flex-1 overflow-hidden">
|
||||
<AgentControlPlaneView />
|
||||
</main>
|
||||
)}
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -235,11 +235,17 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(screen.getByText("Virtual Keys").closest("a")).not.toHaveAttribute("data-active");
|
||||
});
|
||||
|
||||
it("hides labels but keeps items when collapsed to the rail", () => {
|
||||
it("hides labels but keeps items reachable (icon + link) when collapsed to the rail", () => {
|
||||
const { container } = renderWithProviders(<Sidebar {...defaultProps} collapsed />);
|
||||
expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute("data-collapsed", "true");
|
||||
// Items still render (icons), so navigation is reachable in rail mode.
|
||||
expect(screen.getByText("Virtual Keys")).toBeInTheDocument();
|
||||
// The item stays navigable in the icon-only rail: its link still renders with
|
||||
// an icon (asserting the <a> + svg, not the text, so a removed icon would
|
||||
// fail here), while the label is present but CSS-hidden.
|
||||
const label = screen.getByText("Virtual Keys");
|
||||
const link = label.closest("a");
|
||||
expect(link).not.toBeNull();
|
||||
expect(link!.querySelector("svg")).not.toBeNull();
|
||||
expect(label).toHaveClass("group-data-[collapsed=true]/sidebar:hidden");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue