mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
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:
parent
cf8bc755a7
commit
6fe46522d3
3 changed files with 228 additions and 32 deletions
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue