mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge pull request #34465 from BerriAI/litellm_/dazzling-gagarin-3d4c69
refactor(ui): migrate budgets, skills, ui-theme to shadcn
This commit is contained in:
commit
2bd7c86291
6 changed files with 347 additions and 119 deletions
|
|
@ -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": {
|
||||
|
|
@ -1830,9 +1827,6 @@
|
|||
}
|
||||
},
|
||||
"src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1878,9 +1872,6 @@
|
|||
}
|
||||
},
|
||||
"src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 3
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
{canModify && (
|
||||
<Button size="sm" variant="primary" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
|
||||
<Button size="sm" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
|
||||
+ Create Budget
|
||||
</Button>
|
||||
)}
|
||||
<TabGroup>
|
||||
<TabList>
|
||||
<Tab>Budgets</Tab>
|
||||
<Tab>Examples</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<div className="mt-6">
|
||||
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
|
||||
{selectedBudget && (
|
||||
<EditBudgetModal
|
||||
isModalVisible={isEditModalVisible}
|
||||
setIsModalVisible={setIsEditModalVisible}
|
||||
existingBudget={selectedBudget}
|
||||
/>
|
||||
)}
|
||||
<Text className="mb-4">Create a budget to assign to customers.</Text>
|
||||
<BudgetTable
|
||||
budgets={budgetList}
|
||||
isLoading={isLoading}
|
||||
canModify={canModify}
|
||||
onEditClick={handleEditCall}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
<Tabs defaultValue="budgets">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="budgets" className="flex-none rounded-none px-4 py-2">
|
||||
Budgets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="examples" className="flex-none rounded-none px-4 py-2">
|
||||
Examples
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="budgets">
|
||||
<div className="mt-6">
|
||||
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
|
||||
{selectedBudget && (
|
||||
<EditBudgetModal
|
||||
isModalVisible={isEditModalVisible}
|
||||
setIsModalVisible={setIsEditModalVisible}
|
||||
existingBudget={selectedBudget}
|
||||
/>
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalVisible}
|
||||
title="Delete Budget?"
|
||||
message="Are you sure you want to delete this budget? This action cannot be undone."
|
||||
resourceInformationTitle="Budget Information"
|
||||
resourceInformation={[
|
||||
{ label: "Budget ID", value: selectedBudget?.budget_id, code: true },
|
||||
{ label: "Max Budget", value: selectedBudget?.max_budget },
|
||||
{ label: "TPM", value: selectedBudget?.tpm_limit },
|
||||
{ label: "RPM", value: selectedBudget?.rpm_limit },
|
||||
]}
|
||||
onCancel={handleDeleteCancel}
|
||||
onOk={handleDeleteConfirm}
|
||||
confirmLoading={deleteBudget.isPending}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="mt-6">
|
||||
<Text className="text-base">How to use budget id</Text>
|
||||
<TabGroup>
|
||||
<TabList>
|
||||
<Tab>Assign Budget to Customer</Tab>
|
||||
<Tab>Test it (Curl)</Tab>
|
||||
<Tab>Test it (OpenAI SDK)</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<SyntaxHighlighter language="bash">{CREATE_END_USER_CURL_COMMAND}</SyntaxHighlighter>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<SyntaxHighlighter language="bash">{CHAT_COMPLETIONS_CURL_COMMAND}</SyntaxHighlighter>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<SyntaxHighlighter language="python">{OPENAI_SDK_PYTHON_CODE}</SyntaxHighlighter>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
)}
|
||||
<p className="mb-4 text-sm text-muted-foreground">Create a budget to assign to customers.</p>
|
||||
<BudgetTable
|
||||
budgets={budgetList}
|
||||
isLoading={isLoading}
|
||||
canModify={canModify}
|
||||
onEditClick={handleEditCall}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
/>
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalVisible}
|
||||
title="Delete Budget?"
|
||||
message="Are you sure you want to delete this budget? This action cannot be undone."
|
||||
resourceInformationTitle="Budget Information"
|
||||
resourceInformation={[
|
||||
{ label: "Budget ID", value: selectedBudget?.budget_id, code: true },
|
||||
{ label: "Max Budget", value: selectedBudget?.max_budget },
|
||||
{ label: "TPM", value: selectedBudget?.tpm_limit },
|
||||
{ label: "RPM", value: selectedBudget?.rpm_limit },
|
||||
]}
|
||||
onCancel={handleDeleteCancel}
|
||||
onOk={handleDeleteConfirm}
|
||||
confirmLoading={deleteBudget.isPending}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="examples">
|
||||
<div className="mt-6">
|
||||
<p className="text-base text-muted-foreground">How to use budget id</p>
|
||||
<Tabs defaultValue="assign-budget">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="assign-budget" className="flex-none rounded-none px-4 py-2">
|
||||
Assign Budget to Customer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="curl" className="flex-none rounded-none px-4 py-2">
|
||||
Test it (Curl)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="openai-sdk" className="flex-none rounded-none px-4 py-2">
|
||||
Test it (OpenAI SDK)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="assign-budget">
|
||||
<SyntaxHighlighter language="bash">{CREATE_END_USER_CURL_COMMAND}</SyntaxHighlighter>
|
||||
</TabsContent>
|
||||
<TabsContent value="curl">
|
||||
<SyntaxHighlighter language="bash">{CHAT_COMPLETIONS_CURL_COMMAND}</SyntaxHighlighter>
|
||||
</TabsContent>
|
||||
<TabsContent value="openai-sdk">
|
||||
<SyntaxHighlighter language="python">{OPENAI_SDK_PYTHON_CODE}</SyntaxHighlighter>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div data-testid="plugin-table">{isLoading ? "table-loading" : "table-loaded"}</div>
|
||||
default: ({
|
||||
isLoading,
|
||||
pluginsList,
|
||||
onDeleteClick,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
pluginsList: Plugin[];
|
||||
onDeleteClick: (pluginName: string, displayName: string) => void;
|
||||
}) => (
|
||||
<div data-testid="plugin-table">
|
||||
{isLoading ? "table-loading" : "table-loaded"}
|
||||
{pluginsList.map((plugin) => (
|
||||
<button
|
||||
key={plugin.id}
|
||||
data-testid={`row-delete-${plugin.id}`}
|
||||
onClick={() => onDeleteClick(plugin.name, plugin.name)}
|
||||
>
|
||||
row delete
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -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(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
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(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
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(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
/>
|
||||
|
||||
{pluginToDelete && (
|
||||
<Modal
|
||||
title="Delete Skill"
|
||||
open={pluginToDelete !== null}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={() => setPluginToDelete(null)}
|
||||
confirmLoading={isDeleting}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPluginToDelete(null);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete skill: <strong>{pluginToDelete.displayName}</strong>?
|
||||
</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
</Modal>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Skill</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete skill: <strong>{pluginToDelete.displayName}</strong>?
|
||||
</AlertDialogDescription>
|
||||
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, string | null> = {}) =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ values }) } as Response);
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
const patchCalls = () => fetchMock.mock.calls.filter(([, init]) => init?.method === "PATCH");
|
||||
|
||||
const bodyOf = (call: Parameters<typeof fetch>) => 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(<UIThemeSettings userID="user-1" userRole="Admin" accessToken={null} />);
|
||||
|
||||
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(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
||||
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(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
||||
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(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
||||
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(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
||||
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!"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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<UIThemeSettingsProps> = ({ userID, userRole, acc
|
|||
return (
|
||||
<div className="w-full mx-auto max-w-4xl px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<Title className="text-2xl font-bold mb-2">UI Theme Customization</Title>
|
||||
<Text className="text-gray-600">Customize your LiteLLM admin dashboard with a custom logo and favicon.</Text>
|
||||
<h1 className="mb-2 text-2xl font-bold">UI Theme Customization</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize your LiteLLM admin dashboard with a custom logo and favicon.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="shadow-xs p-6">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Logo URL</Text>
|
||||
<TextInput
|
||||
<Label htmlFor="ui-theme-logo-url" className="mb-2">
|
||||
Custom Logo URL
|
||||
</Label>
|
||||
<Input
|
||||
id="ui-theme-logo-url"
|
||||
placeholder="https://example.com/logo.png"
|
||||
value={logoUrlInput}
|
||||
onValueChange={(v) => {
|
||||
setLogoUrlInput(v);
|
||||
setLogoUrl(v || null);
|
||||
onChange={(event) => {
|
||||
setLogoUrlInput(event.target.value);
|
||||
setLogoUrl(event.target.value || null);
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Text className="text-xs text-gray-500 mt-1">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Enter a URL for your custom logo or leave empty for default
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Favicon URL</Text>
|
||||
<TextInput
|
||||
<Label htmlFor="ui-theme-favicon-url" className="mb-2">
|
||||
Custom Favicon URL
|
||||
</Label>
|
||||
<Input
|
||||
id="ui-theme-favicon-url"
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
value={faviconUrlInput}
|
||||
onValueChange={(v) => {
|
||||
setFaviconUrlInput(v);
|
||||
setFaviconUrl(v || null);
|
||||
onChange={(event) => {
|
||||
setFaviconUrlInput(event.target.value);
|
||||
setFaviconUrl(event.target.value || null);
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Text className="text-xs text-gray-500 mt-1">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button onClick={handleSave} loading={loading} disabled={loading} color="indigo">
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button onClick={handleReset} loading={loading} disabled={loading} variant="secondary" color="gray">
|
||||
<Button variant="outline" onClick={handleReset} disabled={loading}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue