fix(ui): restore dashboard modal backdrops (#45866)

* fix(ui): restore dashboard modal backdrops

* test(ui): verify modal backdrops in the real dashboard shell
This commit is contained in:
yuneng-jiang 2026-10-10 15:20:53 -07:00 • committed by GitHub
parent cf8bc755a7
commit 6fe46522d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 228 additions and 32 deletions

View file

@ -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(
<AuthProvider>
<Layout>
<h1>Gateway page</h1>
</Layout>
</AuthProvider>,
);
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: (
<Dialog>
<DialogTrigger>Open page overlay</DialogTrigger>
<DialogContent showCloseButton={false}>
<DialogTitle>Page overlay</DialogTitle>
<DialogClose>Close page overlay</DialogClose>
</DialogContent>
</Dialog>
),
},
{
name: "alert dialog",
role: "alertdialog",
slot: "alert-dialog-overlay",
page: (
<AlertDialog>
<AlertDialogTrigger>Open page overlay</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Page overlay</AlertDialogTitle>
<AlertDialogCancel>Close page overlay</AlertDialogCancel>
</AlertDialogContent>
</AlertDialog>
),
},
{
name: "sheet",
role: "dialog",
slot: "sheet-overlay",
page: (
<Sheet>
<SheetTrigger>Open page overlay</SheetTrigger>
<SheetContent showCloseButton={false}>
<SheetTitle>Page overlay</SheetTitle>
<SheetClose>Close page overlay</SheetClose>
</SheetContent>
</Sheet>
),
},
])("should display the page $name backdrop while navigation is closed", async ({ role, slot, page }) => {
const user = userEvent.setup();
renderWithProviders(
<AuthProvider>
<Layout>{page}</Layout>
</AuthProvider>,
);
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(
<AuthProvider>
<Layout>
<h1>Gateway page</h1>
</Layout>
</AuthProvider>,
);
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());
});
});

View file

@ -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";

View file

@ -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<string | null>(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 (
<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>
<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>
<Sheet
handle={mobileNavigationHandle}
open={mobileNavigationOpen}
onOpenChange={(open) => setMobileNavigationKey(open ? navigationKey : null)}
>
<SheetContent
side="left"
showCloseButton={false}
@ -168,32 +174,33 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
</SheetContent>
<LiteAdminFrame>
<CommandPaletteProvider>
<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>
</CommandPaletteProvider>
</LiteAdminFrame>
</div>
</Sheet>
</Sheet>
<LiteAdminFrame>
<CommandPaletteProvider>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader
navigationTrigger={
<SheetTrigger
handle={mobileNavigationHandle}
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>
</CommandPaletteProvider>
</LiteAdminFrame>
</div>
);
}