diff --git a/ui/litellm-dashboard/src/components/ui/ResizableSheetContent.test.tsx b/ui/litellm-dashboard/src/components/ui/ResizableSheetContent.test.tsx new file mode 100644 index 00000000000..a428f209d0f --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/ResizableSheetContent.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Sheet, SheetTitle } from "@/components/ui/sheet"; +import { ResizableSheetContent } from "./ResizableSheetContent"; + +function sheetContent(): HTMLElement { + return screen.getByRole("dialog"); +} + +describe("ResizableSheetContent", () => { + beforeEach(() => { + localStorage.clear(); + Object.defineProperty(window, "innerWidth", { value: 2000, configurable: true, writable: true }); + }); + + it("resizes by dragging the handle, clamps to the minimum, and persists on release", () => { + render( + + + t + + , + ); + + fireEvent.pointerDown(screen.getByRole("separator")); + fireEvent.pointerMove(window, { clientX: window.innerWidth * 0.5 }); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("50%"); + + fireEvent.pointerMove(window, { clientX: window.innerWidth * 0.9 }); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("40%"); + + fireEvent.pointerUp(window); + expect(localStorage.getItem("k")).toBe("40"); + }); + + it("resizes from the keyboard and exposes the width via aria-valuenow", () => { + render( + + + t + + , + ); + + const sep = screen.getByRole("separator"); + sep.focus(); + + fireEvent.keyDown(sep, { key: "ArrowLeft" }); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("80%"); + expect(localStorage.getItem("k")).toBe("80"); + + fireEvent.keyDown(sep, { key: "ArrowRight" }); + fireEvent.keyDown(sep, { key: "ArrowRight" }); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("70%"); + + fireEvent.keyDown(sep, { key: "End" }); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("40%"); + expect(sep).toHaveAttribute("aria-valuenow", "40"); + }); + + it("clamps the End key to the 720px floor when it exceeds the percentage minimum", () => { + Object.defineProperty(window, "innerWidth", { value: 1000, configurable: true, writable: true }); + render( + + + t + + , + ); + + const sep = screen.getByRole("separator"); + fireEvent.keyDown(sep, { key: "End" }); + + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("72%"); + expect(sep).toHaveAttribute("aria-valuenow", "72"); + }); + + it("announces the rendered width when the 720px floor overrides the stored percentage", () => { + localStorage.setItem("k", "40"); + Object.defineProperty(window, "innerWidth", { value: 1000, configurable: true, writable: true }); + render( + + + t + + , + ); + + const sep = screen.getByRole("separator"); + expect(sep).toHaveAttribute("aria-valuenow", "72"); + expect(sep).toHaveAttribute("aria-valuemin", "72"); + + Object.defineProperty(window, "innerWidth", { value: 2000, configurable: true, writable: true }); + fireEvent(window, new Event("resize")); + expect(sep).toHaveAttribute("aria-valuenow", "40"); + expect(sep).toHaveAttribute("aria-valuemin", "40"); + + Object.defineProperty(window, "innerWidth", { value: 800, configurable: true, writable: true }); + fireEvent(window, new Event("resize")); + expect(sep).toHaveAttribute("aria-valuenow", "90"); + expect(localStorage.getItem("k")).toBe("40"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/ResizableSheetContent.tsx b/ui/litellm-dashboard/src/components/ui/ResizableSheetContent.tsx new file mode 100644 index 00000000000..b21a5b2c56e --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/ResizableSheetContent.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import { Maximize2Icon, Minimize2Icon } from "lucide-react"; + +import { cn } from "@/lib/cva.config"; +import { Button } from "@/components/ui/button"; +import { SheetContent } from "@/components/ui/sheet"; +import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils"; + +type ResizableSheetContentProps = Omit, "side" | "style"> & { + storageKey: string; + defaultWidthPercent?: number; + minWidthPercent?: number; +}; + +const MIN_WIDTH_PX = 720; + +function clampWidth(value: number, min: number) { + return Math.min(100, Math.max(min, value)); +} + +function effectiveMinPercent(minWidthPercent: number, viewportWidth: number) { + if (!viewportWidth) return minWidthPercent; + return Math.min(100, Math.max(minWidthPercent, (MIN_WIDTH_PX / viewportWidth) * 100)); +} + +function readViewportWidth() { + return typeof window === "undefined" ? 0 : window.innerWidth; +} + +function readStoredWidth(storageKey: string, defaultWidthPercent: number, minWidthPercent: number) { + const stored = Number(getLocalStorageItem(storageKey)); + if (!Number.isFinite(stored)) return defaultWidthPercent; + if (stored < minWidthPercent || stored > 100) return defaultWidthPercent; + return stored; +} + +function ResizableSheetContent({ + className, + children, + storageKey, + defaultWidthPercent = 75, + minWidthPercent = 40, + ...props +}: ResizableSheetContentProps) { + const [width, setWidth] = React.useState(() => readStoredWidth(storageKey, defaultWidthPercent, minWidthPercent)); + const widthRef = React.useRef(width); + const lastNonFullWidthRef = React.useRef(defaultWidthPercent); + const cleanupRef = React.useRef<(() => void) | null>(null); + const [viewportWidth, setViewportWidth] = React.useState(readViewportWidth); + const isFull = width >= 100; + const minPercent = effectiveMinPercent(minWidthPercent, viewportWidth); + const renderedWidth = clampWidth(width, minPercent); + + React.useEffect(() => () => cleanupRef.current?.(), []); + + React.useEffect(() => { + const onResize = () => setViewportWidth(window.innerWidth); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + const applyWidth = (next: number, persist: boolean) => { + const clamped = clampWidth(next, effectiveMinPercent(minWidthPercent, window.innerWidth)); + widthRef.current = clamped; + setWidth(clamped); + if (persist) setLocalStorageItem(storageKey, String(clamped)); + }; + + const onPointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + cleanupRef.current?.(); + const onMove = (e: PointerEvent) => { + applyWidth(((window.innerWidth - e.clientX) / window.innerWidth) * 100, false); + }; + const onUp = () => { + cleanupRef.current?.(); + cleanupRef.current = null; + if (widthRef.current < 100) setLocalStorageItem(storageKey, String(widthRef.current)); + }; + cleanupRef.current = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }; + + const toggle = () => { + if (isFull) { + applyWidth(lastNonFullWidthRef.current, true); + return; + } + lastNonFullWidthRef.current = width; + applyWidth(100, false); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + applyWidth(widthRef.current + 5, true); + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + applyWidth(widthRef.current - 5, true); + return; + } + if (event.key === "Home") { + event.preventDefault(); + applyWidth(100, false); + return; + } + if (event.key === "End") { + event.preventDefault(); + applyWidth(effectiveMinPercent(minWidthPercent, window.innerWidth), true); + } + }; + + return ( + +
{children}
+
+ + + ); +} + +export { ResizableSheetContent }; diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx index 8d9a292c13c..b0672cee296 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx @@ -131,4 +131,64 @@ describe("AuditLogDrawer", () => { await waitFor(() => expect(writeText).toHaveBeenCalledWith(JSON.stringify({ max_budget: 10 }, null, 2))); }); + + describe("width", () => { + beforeEach(() => { + localStorage.clear(); + Object.defineProperty(window, "innerWidth", { value: 2000, configurable: true, writable: true }); + }); + + function sheetContent(): HTMLElement { + return screen.getByRole("dialog"); + } + + it("defaults to 75% width", () => { + render(); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%"); + }); + + it("expands to full width and collapses back to the previous width", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /expand drawer/i })); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("100%"); + + await user.click(screen.getByRole("button", { name: /collapse drawer/i })); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%"); + expect(screen.getByRole("button", { name: /expand drawer/i })).toBeInTheDocument(); + }); + + it("restores a stored width", () => { + localStorage.setItem("litellm:auditLogDrawerWidth", "58"); + render(); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("58%"); + }); + + it("keeps the saved width while expanded and restores it on collapse", async () => { + const user = userEvent.setup(); + localStorage.setItem("litellm:auditLogDrawerWidth", "58"); + render(); + + await user.click(screen.getByRole("button", { name: /expand drawer/i })); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("100%"); + expect(localStorage.getItem("litellm:auditLogDrawerWidth")).toBe("58"); + + await user.click(screen.getByRole("button", { name: /collapse drawer/i })); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("58%"); + expect(localStorage.getItem("litellm:auditLogDrawerWidth")).toBe("58"); + }); + + it("falls back to the default width for a non-numeric stored value", () => { + localStorage.setItem("litellm:auditLogDrawerWidth", "abc"); + render(); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%"); + }); + + it("falls back to the default width for an out-of-range stored value", () => { + localStorage.setItem("litellm:auditLogDrawerWidth", "10"); + render(); + expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx index 74364b53856..bb50d162181 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -6,7 +6,8 @@ import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag"; import CopyButton from "@/components/shared/CopyButton"; import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge"; import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { Sheet, SheetTitle } from "@/components/ui/sheet"; +import { ResizableSheetContent } from "@/components/ui/ResizableSheetContent"; interface AuditLogDrawerProps { open: boolean; @@ -176,7 +177,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { return ( !nextOpen && onClose()}> - + Audit log details
@@ -217,7 +218,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
-
+
); }