From 212421207ecc59d35da919166f0bf372b90d49c2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:25:05 -0700 Subject: [PATCH 1/2] test(ui): characterise budgets, skills and ui-theme panels before migration Adds a role/text-based characterisation test for UIThemeSettings, which had none, and extends the skills panel test to cover the delete confirmation. Both are green against the current antd/Tremor components so they can prove the shadcn migration keeps behaviour identical without being edited. --- .../ClaudeCodePluginsPanel.test.tsx | 82 ++++++++++- .../ui-theme/UIThemeSettings.test.tsx | 128 ++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx index 52f3dc21b7a..67bab398bd2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -1,7 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getClaudeCodePluginsList } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import type { Plugin } from "@/components/claude_code_plugins/types"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; @@ -12,8 +14,27 @@ vi.mock("@/components/networking", () => ({ vi.mock("./PluginTable", () => ({ __esModule: true, - default: ({ isLoading }: { isLoading: boolean }) => ( -
{isLoading ? "table-loading" : "table-loaded"}
+ default: ({ + isLoading, + pluginsList, + onDeleteClick, + }: { + isLoading: boolean; + pluginsList: Plugin[]; + onDeleteClick: (pluginName: string, displayName: string) => void; + }) => ( +
+ {isLoading ? "table-loading" : "table-loaded"} + {pluginsList.map((plugin) => ( + + ))} +
), })); @@ -21,6 +42,14 @@ vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null })); vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null })); const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); +const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin); + +const skill: Plugin = { + id: "plugin-1", + name: "my-skill", + source: { source: "github", repo: "acme/my-skill" }, + enabled: true, +}; describe("ClaudeCodePluginsPanel loading state", () => { beforeEach(() => { @@ -48,3 +77,48 @@ describe("ClaudeCodePluginsPanel loading state", () => { expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false); }); }); + +describe("ClaudeCodePluginsPanel delete confirmation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill], count: 1 }); + }); + + it("should ask for confirmation before deleting and name the skill", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + + expect(await screen.findByText(/are you sure you want to delete skill/i)).toBeInTheDocument(); + expect(screen.getByText("my-skill")).toBeInTheDocument(); + expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument(); + expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + }); + + it("should delete the skill and refresh the list once confirmed", async () => { + const user = userEvent.setup(); + mockDeleteClaudeCodePlugin.mockResolvedValue({}); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + await screen.findByText(/are you sure you want to delete skill/i); + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(mockDeleteClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "my-skill")); + await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); + }); + + it("should not delete the skill when the confirmation is cancelled", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + await screen.findByText(/are you sure you want to delete skill/i); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); + expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx new file mode 100644 index 00000000000..20f8960ae3a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx @@ -0,0 +1,128 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import NotificationsManager from "@/components/molecules/notifications_manager"; + +import UIThemeSettings from "./UIThemeSettings"; + +const setLogoUrl = vi.fn(); +const setFaviconUrl = vi.fn(); + +vi.mock("@/contexts/ThemeContext", () => ({ + useTheme: () => ({ logoUrl: null, setLogoUrl, faviconUrl: null, setFaviconUrl }), +})); + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "", + getGlobalLitellmHeaderName: () => "Authorization", +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + __esModule: true, + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +const LOGO_PLACEHOLDER = "https://example.com/logo.png"; +const FAVICON_PLACEHOLDER = "https://example.com/favicon.ico"; + +const okResponse = (values: Record = {}) => + Promise.resolve({ ok: true, json: () => Promise.resolve({ values }) } as Response); + +const fetchMock = vi.fn(); + +const patchCalls = () => fetchMock.mock.calls.filter(([, init]) => init?.method === "PATCH"); + +const bodyOf = (call: Parameters) => JSON.parse(String(call[1]?.body)); + +describe("UIThemeSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchMock.mockImplementation(() => okResponse()); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should render nothing without an access token", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should load the saved logo and favicon urls into the inputs", async () => { + fetchMock.mockImplementation(() => + okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg"); + }); + expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("https://cdn.example.com/fav.ico"); + expect(setLogoUrl).toHaveBeenCalledWith("https://cdn.example.com/logo.svg"); + expect(setFaviconUrl).toHaveBeenCalledWith("https://cdn.example.com/fav.ico"); + }); + + it("should save the entered urls and report success", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + await user.type(screen.getByPlaceholderText(LOGO_PLACEHOLDER), "https://a.test/logo.png"); + await user.type(screen.getByPlaceholderText(FAVICON_PLACEHOLDER), "https://a.test/fav.ico"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => expect(patchCalls()).toHaveLength(1)); + expect(bodyOf(patchCalls()[0])).toEqual({ + logo_url: "https://a.test/logo.png", + favicon_url: "https://a.test/fav.ico", + }); + await waitFor(() => + expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings updated successfully!"), + ); + }); + + it("should surface a backend failure when saving fails", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + fetchMock.mockImplementation(() => Promise.resolve({ ok: false } as Response)); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => + expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update theme settings"), + ); + expect(NotificationsManager.success).not.toHaveBeenCalled(); + }); + + it("should clear both inputs and persist nulls when resetting to default", async () => { + const user = userEvent.setup(); + fetchMock.mockImplementation(() => + okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg"); + }); + + await user.click(screen.getByRole("button", { name: "Reset to Default" })); + + await waitFor(() => expect(patchCalls()).toHaveLength(1)); + expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, favicon_url: null }); + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue(""); + expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue(""); + expect(setLogoUrl).toHaveBeenLastCalledWith(null); + expect(setFaviconUrl).toHaveBeenLastCalledWith(null); + await waitFor(() => expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings reset to default!")); + }); +}); From 39f0b56502e5ae572e08971c58723e2ff17455d6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:40:25 -0700 Subject: [PATCH 2/2] refactor(ui): migrate budgets, skills and ui-theme to shadcn Replaces antd and Tremor with the installed shadcn primitives on the three route-exclusive panels: Tremor tabs, buttons and text on budgets; the antd delete Modal and Tremor button on skills; the Tremor card, inputs and buttons on ui-theme. Markup only, no behaviour change. The characterisation tests added in the previous commit are untouched and stay green, and the ui-theme inputs now carry real label associations. Shared components stay on antd; they are reached by other routes and are migrated separately. The form-bearing files on these routes are left alone. --- ui/litellm-dashboard/eslint-suppressions.json | 9 -- .../budgets/_components/budget_panel.tsx | 141 +++++++++--------- .../_components/ClaudeCodePluginsPanel.tsx | 46 ++++-- .../(dashboard)/ui-theme/UIThemeSettings.tsx | 60 +++++--- 4 files changed, 141 insertions(+), 115 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05ba..c09b832a69b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -144,9 +144,6 @@ "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": { @@ -1840,9 +1837,6 @@ } }, "src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1888,9 +1882,6 @@ } }, "src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": { - "no-restricted-imports": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index 6d5c0c7be08..2cf2a4c06ec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -3,9 +3,10 @@ * */ -import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react"; import React, { useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; @@ -73,76 +74,82 @@ const BudgetPanel: React.FC = ({ accessToken }) => { return (
{canModify && ( - )} - - - Budgets - Examples - - - -
- - {selectedBudget && ( - - )} - Create a budget to assign to customers. - + + + Budgets + + + Examples + + + +
+ + {selectedBudget && ( + - -
- - -
- How to use budget id - - - Assign Budget to Customer - Test it (Curl) - Test it (OpenAI SDK) - - - - {CREATE_END_USER_CURL_COMMAND} - - - {CHAT_COMPLETIONS_CURL_COMMAND} - - - {OPENAI_SDK_PYTHON_CODE} - - - -
-
- - + )} +

Create a budget to assign to customers.

+ + +
+ + +
+

How to use budget id

+ + + + Assign Budget to Customer + + + Test it (Curl) + + + Test it (OpenAI SDK) + + + + {CREATE_END_USER_CURL_COMMAND} + + + {CHAT_COMPLETIONS_CURL_COMMAND} + + + {OPENAI_SDK_PYTHON_CODE} + + +
+
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index 5a638f9ae79..47fc8f41307 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,6 +1,14 @@ import React, { useState, useEffect } from "react"; -import { Button } from "@tremor/react"; -import { Modal } from "antd"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; import PluginTable from "./PluginTable"; @@ -115,20 +123,28 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT /> {pluginToDelete && ( - setPluginToDelete(null)} - confirmLoading={isDeleting} - okText="Delete" - okButtonProps={{ danger: true }} + { + if (!open) setPluginToDelete(null); + }} > -

- Are you sure you want to delete skill: {pluginToDelete.displayName}? -

-

This action cannot be undone.

-
+ + + Delete Skill + + Are you sure you want to delete skill: {pluginToDelete.displayName}? + +

This action cannot be undone.

+
+ + Cancel + + +
+ )} ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx index 15a3de0f78b..035d87c4e9b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx @@ -1,5 +1,9 @@ import React, { useState, useEffect } from "react"; -import { Card, Title, Text, TextInput, Button } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useTheme } from "@/contexts/ThemeContext"; import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -113,50 +117,58 @@ const UIThemeSettings: React.FC = ({ userID, userRole, acc return (
- UI Theme Customization - Customize your LiteLLM admin dashboard with a custom logo and favicon. +

UI Theme Customization

+

+ Customize your LiteLLM admin dashboard with a custom logo and favicon. +

- -
+ +
- Custom Logo URL - + Custom Logo URL + + { - setLogoUrlInput(v); - setLogoUrl(v || null); + onChange={(event) => { + setLogoUrlInput(event.target.value); + setLogoUrl(event.target.value || null); }} - className="w-full" /> - +

Enter a URL for your custom logo or leave empty for default - +

- Custom Favicon URL - + Custom Favicon URL + + { - setFaviconUrlInput(v); - setFaviconUrl(v || null); + onChange={(event) => { + setFaviconUrlInput(event.target.value); + setFaviconUrl(event.target.value || null); }} - className="w-full" /> - +

Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default - +

- -
-
+
);