From 6fe46522d37f70769cb401fd28804c8adce1c060 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 10 Oct 2026 15:20:53 -0700 Subject: [PATCH] fix(ui): restore dashboard modal backdrops (#45866) * fix(ui): restore dashboard modal backdrops * test(ui): verify modal backdrops in the real dashboard shell --- .../(dashboard)/layout.integration.test.tsx | 189 ++++++++++++++++++ .../src/app/(dashboard)/layout.test.tsx | 2 +- .../src/app/(dashboard)/layout.tsx | 69 ++++--- 3 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx new file mode 100644 index 00000000000..537f2115eda --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../tests/test-utils"; +import { AuthProvider } from "@/contexts/AuthContext"; +import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Sheet, SheetClose, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import Layout from "./layout"; + +vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); +vi.unmock("@/lib/toast"); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => "/ui/guardrails", +})); + +beforeEach(() => { + testQueryClient.clear(); + localStorage.clear(); + const claims = { + key: "sk-layout-test", + user_id: "layout-admin", + user_role: "proxy_admin", + login_method: "sso", + exp: Date.now() / 1000 + 3600, + }; + const token = `${btoa(JSON.stringify({ alg: "none" }))}.${btoa(JSON.stringify(claims))}.test`; + document.cookie = `token=${token}; Path=/`; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + const path = new URL(url, "http://localhost").pathname; + switch (path) { + case "/litellm/.well-known/litellm-ui-config": + return Response.json({ server_root_path: "", proxy_base_url: "", admin_ui_disabled: false }); + case "/get/ui_settings": + case "/get/ui_theme_settings": + return Response.json({ values: {} }); + case "/api/plugins": + case "/team/list": + case "/organization/list": + return Response.json([]); + case "/health/readiness/details": + return Response.json({ status: "healthy" }); + case "/health/license": { + const license = { + has_license: false, + license_type: null, + expiration_date: null, + allowed_features: [], + limits: { max_users: null, max_teams: null }, + }; + return Response.json(license); + } + case "/user/available_users": { + const usage = { + total_users: null, + total_users_used: 1, + total_users_remaining: null, + total_teams: null, + total_teams_used: 0, + total_teams_remaining: null, + }; + return Response.json(usage); + } + case "/sso/get/ui_settings": + return Response.json({}); + case "/get/user_banner": + return Response.json({ enabled: false, message: "", severity: "info" }); + case "/get/latest_release_info": { + const release = { version: "", release_url: "", bug_fixes: 0, new_features: 0, other_updates: 0 }; + return Response.json(release); + } + case "/public/litellm_blog_posts": + return Response.json({ posts: [] }); + default: + throw new Error(`Unexpected request: ${url}`); + } + }), + ); +}); + +afterEach(() => { + testQueryClient.clear(); + vi.unstubAllGlobals(); + document.cookie = "token=; Max-Age=0; Path=/"; + localStorage.clear(); +}); + +describe("dashboard overlay boundaries", () => { + it("should render the page with mobile navigation closed", async () => { + renderWithProviders( + + +

Gateway page

+
+
, + ); + + expect(await screen.findByRole("heading", { name: "Gateway page" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Open navigation" })).toBeVisible(); + expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument(); + }); + + it.each([ + { + name: "dialog", + role: "dialog", + slot: "dialog-overlay", + page: ( + + Open page overlay + + Page overlay + Close page overlay + + + ), + }, + { + name: "alert dialog", + role: "alertdialog", + slot: "alert-dialog-overlay", + page: ( + + Open page overlay + + Page overlay + Close page overlay + + + ), + }, + { + name: "sheet", + role: "dialog", + slot: "sheet-overlay", + page: ( + + Open page overlay + + Page overlay + Close page overlay + + + ), + }, + ])("should display the page $name backdrop while navigation is closed", async ({ role, slot, page }) => { + const user = userEvent.setup(); + renderWithProviders( + + {page} + , + ); + + await user.click(await screen.findByRole("button", { name: "Open page overlay" })); + await screen.findByRole(role, { name: "Page overlay" }); + expect( + screen.getAllByRole("presentation", { hidden: true }).find((element) => element.dataset.slot === slot), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Close page overlay" })); + }); + + it("should return focus to the navigation trigger after closing the drawer with Escape", async () => { + const user = userEvent.setup(); + renderWithProviders( + + +

Gateway page

+
+
, + ); + + const trigger = await screen.findByRole("button", { name: "Open navigation" }); + await user.click(trigger); + const navigation = await screen.findByRole("dialog", { name: "Navigation" }); + expect(within(navigation).getByRole("link", { name: "Virtual Keys" })).toBeVisible(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 876a4e24d84..552bffb6cd8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, renderWithProviders as render, screen, waitFor, within } from "../../../tests/test-utils"; import { usePathname } from "next/navigation"; import { AuthProvider } from "@/contexts/AuthContext"; import Layout from "./layout"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 81d290cdfe4..9233a56781e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -1,6 +1,7 @@ "use client"; import React, { Suspense, useState, useRef, useEffect } from "react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { DashboardHeader } from "@/components/DashboardHeader"; import Navbar from "@/components/navbar"; import LoadingScreen from "@/components/common_components/LoadingScreen"; @@ -115,6 +116,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { const navigationKey = `${pathname}?${searchParams.toString()}`; const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false }); const [mobileNavigationKey, setMobileNavigationKey] = useState(null); + const [mobileNavigationHandle] = useState(() => DialogPrimitive.createHandle()); if (mobileNavigationKey !== null && (isDesktop || mobileNavigationKey !== navigationKey)) { setMobileNavigationKey(null); } @@ -152,11 +154,15 @@ 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 ( - setMobileNavigationKey(open ? navigationKey : null)}> -
-
- -
+
+
+ +
+ setMobileNavigationKey(open ? navigationKey : null)} + > Navigation setMobileNavigationKey(null)} /> - - -
- - } - > - - - } - /> - - - - - - -
{children}
-
-
-
-
- + + + +
+ + } + > + + + } + /> + + + + + + +
{children}
+
+
+
+
); }