From 208cf5e86d8f86d0e2087fe21ac74d5051d5bf79 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Feb 2026 11:53:03 -0800 Subject: [PATCH 1/3] refactor - rename search tools files --- ui/litellm-dashboard/src/app/page.tsx | 2 +- .../CreateSearchTools.tsx} | 12 ++++++------ .../SearchConnectionTest.tsx} | 0 .../SearchToolColumn.tsx} | 0 .../SearchToolTester.tsx} | 0 .../SearchToolView.tsx} | 12 +++++------- .../search_tools.tsx => SearchTools/SearchTools.tsx} | 6 +++--- .../src/components/SearchTools/index.tsx | 6 ++++++ .../{search_tools => SearchTools}/types.tsx | 0 .../src/components/search_tools/index.tsx | 6 ------ 10 files changed, 21 insertions(+), 23 deletions(-) rename ui/litellm-dashboard/src/components/{search_tools/create_search_tool.tsx => SearchTools/CreateSearchTools.tsx} (98%) rename ui/litellm-dashboard/src/components/{search_tools/search_connection_test.tsx => SearchTools/SearchConnectionTest.tsx} (100%) rename ui/litellm-dashboard/src/components/{search_tools/search_tool_columns.tsx => SearchTools/SearchToolColumn.tsx} (100%) rename ui/litellm-dashboard/src/components/{search_tools/search_tool_tester.tsx => SearchTools/SearchToolTester.tsx} (100%) rename ui/litellm-dashboard/src/components/{search_tools/search_tool_view.tsx => SearchTools/SearchToolView.tsx} (95%) rename ui/litellm-dashboard/src/components/{search_tools/search_tools.tsx => SearchTools/SearchTools.tsx} (98%) create mode 100644 ui/litellm-dashboard/src/components/SearchTools/index.tsx rename ui/litellm-dashboard/src/components/{search_tools => SearchTools}/types.tsx (100%) delete mode 100644 ui/litellm-dashboard/src/components/search_tools/index.tsx diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 5f94db7e9f..8e887d80fb 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -27,7 +27,7 @@ import Organizations, { fetchOrganizations } from "@/components/organizations"; import PassThroughSettings from "@/components/pass_through_settings"; import PromptsPanel from "@/components/prompts"; import PublicModelHub from "@/components/public_model_hub"; -import { SearchTools } from "@/components/search_tools"; +import { SearchTools } from "@/components/SearchTools"; import Settings from "@/components/settings"; import { SurveyPrompt, SurveyModal, ClaudeCodePrompt, ClaudeCodeModal } from "@/components/survey"; import TagManagement from "@/components/tag_management"; diff --git a/ui/litellm-dashboard/src/components/search_tools/create_search_tool.tsx b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/search_tools/create_search_tool.tsx rename to ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx index 9dd14256ee..18eaf5e493 100644 --- a/ui/litellm-dashboard/src/components/search_tools/create_search_tool.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx @@ -7,7 +7,7 @@ import { SearchTool, AvailableSearchProvider } from "./types"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "../molecules/notifications_manager"; import { useQuery } from "@tanstack/react-query"; -import SearchConnectionTest from "./search_connection_test"; +import SearchConnectionTest from "./SearchConnectionTest"; import Image from "next/image"; const { TextArea } = Input; @@ -97,8 +97,8 @@ const CreateSearchTool: React.FC = ({ }, search_tool_info: formValues.description ? { - description: formValues.description, - } + description: formValues.description, + } : undefined, }; @@ -130,7 +130,7 @@ const CreateSearchTool: React.FC = ({ try { // Validate required fields for testing await form.validateFields(["search_provider", "api_key"]); - + setIsTestingConnection(true); // Generate a new test ID (using timestamp for uniqueness) setConnectionTestId(`test-${Date.now()}`); @@ -225,8 +225,8 @@ const CreateSearchTool: React.FC = ({ optionLabelProp="label" > {availableProviders.map((provider) => ( - = ({ size="small" icon={copiedStates["search-tool-name"] ? : } onClick={() => copyToClipboard(searchTool.search_tool_name, "search-tool-name")} - className={`left-2 z-10 transition-all duration-200 ${ - copiedStates["search-tool-name"] + className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-name"] ? "text-green-600 bg-green-50 border-green-200" : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" - }`} + }`} />
@@ -67,11 +66,10 @@ export const SearchToolView: React.FC = ({ size="small" icon={copiedStates["search-tool-id"] ? : } onClick={() => copyToClipboard(searchTool.search_tool_id, "search-tool-id")} - className={`left-2 z-10 transition-all duration-200 ${ - copiedStates["search-tool-id"] + className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-id"] ? "text-green-600 bg-green-50 border-green-200" : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" - }`} + }`} />
diff --git a/ui/litellm-dashboard/src/components/search_tools/search_tools.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/search_tools/search_tools.tsx rename to ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx index 2fbdd4d27c..be4c74099b 100644 --- a/ui/litellm-dashboard/src/components/search_tools/search_tools.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Modal, Form, Input, Select } from "antd"; import { Button, Title, Text, Grid, Col } from "@tremor/react"; import { DataTable } from "../view_logs/table"; -import { searchToolColumns } from "./search_tool_columns"; +import { searchToolColumns } from "./SearchToolColumn"; import { fetchSearchTools, updateSearchTool, @@ -13,8 +13,8 @@ import { import { SearchTool, AvailableSearchProvider } from "./types"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "../molecules/notifications_manager"; -import { SearchToolView } from "./search_tool_view"; -import CreateSearchTool from "./create_search_tool"; +import { SearchToolView } from "./SearchToolView"; +import CreateSearchTool from "./CreateSearchTools"; interface SearchToolsProps { accessToken: string | null; diff --git a/ui/litellm-dashboard/src/components/SearchTools/index.tsx b/ui/litellm-dashboard/src/components/SearchTools/index.tsx new file mode 100644 index 0000000000..54820d3849 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SearchTools/index.tsx @@ -0,0 +1,6 @@ +export { default as SearchTools } from './SearchTools'; +export { SearchToolView } from './SearchToolView'; +export { default as SearchConnectionTest } from './SearchConnectionTest'; +export { SearchToolTester } from './SearchToolTester'; +export * from './types'; + diff --git a/ui/litellm-dashboard/src/components/search_tools/types.tsx b/ui/litellm-dashboard/src/components/SearchTools/types.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/search_tools/types.tsx rename to ui/litellm-dashboard/src/components/SearchTools/types.tsx diff --git a/ui/litellm-dashboard/src/components/search_tools/index.tsx b/ui/litellm-dashboard/src/components/search_tools/index.tsx deleted file mode 100644 index eae43a1d39..0000000000 --- a/ui/litellm-dashboard/src/components/search_tools/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export { default as SearchTools } from './search_tools'; -export { SearchToolView } from './search_tool_view'; -export { default as SearchConnectionTest } from './search_connection_test'; -export { SearchToolTester } from './search_tool_tester'; -export * from './types'; - From 609c423314f290dd9c215ad5609ff21fd54ca79c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Feb 2026 12:22:57 -0800 Subject: [PATCH 2/3] migrate to antd table --- .../SearchTools/SearchToolColumn.tsx | 170 +++++++++++------- .../components/SearchTools/SearchTools.tsx | 82 +++++---- .../src/components/SearchTools/types.tsx | 1 + 3 files changed, 151 insertions(+), 102 deletions(-) diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx index 78aa109400..86b0bde4d0 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx @@ -1,78 +1,114 @@ -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnsType } from "antd/es/table"; import { SearchTool } from "./types"; -import { Icon } from "@tremor/react"; -import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; +import { Tag } from "antd"; +import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; export const searchToolColumns = ( onView: (searchToolId: string) => void, onEdit: (searchToolId: string) => void, onDelete: (searchToolId: string) => void, availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>, -): ColumnDef[] => [ - { - accessorKey: "search_tool_id", - header: "Search Tool ID", - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "search_tool_name", - header: "Name", - cell: ({ getValue }) => {getValue() as string}, - }, - { - id: "provider", - header: "Provider", - cell: ({ row }) => { - const provider = row.original.litellm_params.search_provider; - const providerInfo = availableProviders.find((p) => p.provider_name === provider); - const displayName = providerInfo?.ui_friendly_name || provider; +): ColumnsType => [ + { + title: "Search Tool ID", + dataIndex: "search_tool_id", + key: "search_tool_id", + render: (_, tool) => { + const isFromConfig = tool.is_from_config; - return {displayName}; + if (isFromConfig) { + return -; + } + + return ( + + ); + }, }, - }, - { - header: "Created At", - accessorKey: "created_at", - sortingFn: "datetime", - cell: ({ row }) => { - const tool = row.original; - return {tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}; + { + title: "Name", + dataIndex: "search_tool_name", + key: "search_tool_name", + render: (name: string) => {name}, }, - }, - { - header: "Updated At", - accessorKey: "updated_at", - sortingFn: "datetime", - cell: ({ row }) => { - const tool = row.original; - return {tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}; + { + title: "Provider", + key: "provider", + render: (_, tool) => { + const provider = tool.litellm_params.search_provider; + const providerInfo = availableProviders.find((p) => p.provider_name === provider); + const displayName = providerInfo?.ui_friendly_name || provider; + + return {displayName}; + }, }, - }, - { - id: "actions", - header: "Actions", - cell: ({ row }) => ( -
- onEdit(row.original.search_tool_id!)} - className="cursor-pointer" - /> - onDelete(row.original.search_tool_id!)} - className="cursor-pointer" - /> -
- ), - }, -]; + { + title: "Created At", + dataIndex: "created_at", + key: "created_at", + render: (_, tool) => { + return {tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}; + }, + }, + { + title: "Updated At", + dataIndex: "updated_at", + key: "updated_at", + render: (_, tool) => { + return {tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}; + }, + }, + { + title: "Source", + key: "source", + render: (_, tool) => { + const isFromConfig = tool.is_from_config ?? false; + + return ( + + {isFromConfig ? "Config" : "DB"} + + ); + }, + }, + { + title: "Actions", + key: "actions", + render: (_, tool) => { + const toolId = tool.search_tool_id; + const isFromConfig = tool.is_from_config ?? false; + + return ( +
+ { + if (toolId && !isFromConfig) { + onEdit(toolId); + } + }} + /> + { + if (toolId && !isFromConfig) { + onDelete(toolId); + } + }} + /> +
+ ); + }, + }, + ]; diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx index be4c74099b..ba42ea22cd 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx @@ -1,8 +1,8 @@ import React, { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Modal, Form, Input, Select } from "antd"; -import { Button, Title, Text, Grid, Col } from "@tremor/react"; -import { DataTable } from "../view_logs/table"; +import { Modal, Form, Input, Select, Table, Spin } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; +import { Button, Title, Text } from "@tremor/react"; import { searchToolColumns } from "./SearchToolColumn"; import { fetchSearchTools, @@ -15,6 +15,7 @@ import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "../molecules/notifications_manager"; import { SearchToolView } from "./SearchToolView"; import CreateSearchTool from "./CreateSearchTools"; +import DeleteResourceModal from "../common_components/DeleteResourceModal"; interface SearchToolsProps { accessToken: string | null; @@ -22,24 +23,6 @@ interface SearchToolsProps { userID: string | null; } -const DeleteModal: React.FC<{ - isModalOpen: boolean; - title: string; - confirmDelete: () => void; - cancelDelete: () => void; -}> = ({ isModalOpen, title, confirmDelete, cancelDelete }) => { - if (!isModalOpen) return null; - return ( - - - {title} - -

Are you sure you want to delete this search tool?

- -
-
- ); -}; const SearchTools: React.FC = ({ accessToken, userRole, userID }) => { const { @@ -72,6 +55,7 @@ const SearchTools: React.FC = ({ accessToken, userRole, userID // State const [toolIdToDelete, setToolToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const [selectedToolId, setSelectedToolId] = useState(null); const [editTool, setEditTool] = useState(false); const [isCreateModalVisible, setCreateModalVisible] = useState(false); @@ -116,16 +100,19 @@ const SearchTools: React.FC = ({ accessToken, userRole, userID if (toolIdToDelete == null || accessToken == null) { return; } + setIsDeleting(true); try { await deleteSearchTool(accessToken, toolIdToDelete); NotificationsManager.success("Deleted search tool successfully"); + setIsDeleteModalOpen(false); + setToolToDelete(null); refetch(); } catch (error) { console.error("Error deleting the search tool:", error); NotificationsManager.error("Failed to delete search tool"); + } finally { + setIsDeleting(false); } - setIsDeleteModalOpen(false); - setToolToDelete(null); }; const cancelDelete = () => { @@ -133,6 +120,11 @@ const SearchTools: React.FC = ({ accessToken, userRole, userID setToolToDelete(null); }; + const toolToDelete = searchTools?.find((t) => t.search_tool_id === toolIdToDelete); + const providerInfo = toolToDelete + ? availableProviders.find((p) => p.provider_name === toolToDelete.litellm_params.search_provider) + : null; + const handleCreateSuccess = (newSearchTool: SearchTool) => { setCreateModalVisible(false); refetch(); @@ -231,26 +223,46 @@ const SearchTools: React.FC = ({ accessToken, userRole, userID /> ) : (
-
- } size="large"> +
} - getRowCanExpand={() => false} - isLoading={isLoadingTools} - noDataMessage="No search tools configured" + rowKey={(record) => record.search_tool_id || record.search_tool_name} + pagination={false} + locale={{ + emptyText: "No search tools configured", + }} + size="small" /> - + + ); return (
- Date: Wed, 4 Feb 2026 12:35:58 -0800 Subject: [PATCH 3/3] Adding tests --- .../SearchTools/CreateSearchTools.tsx | 16 +- .../SearchTools/SearchConnectionTest.tsx | 6 +- .../SearchTools/SearchToolColumn.tsx | 4 +- .../SearchTools/SearchToolView.test.tsx | 278 ++++++++++++++++++ .../components/SearchTools/SearchToolView.tsx | 18 +- .../SearchTools/SearchTools.test.tsx | 234 +++++++++++++++ .../components/SearchTools/SearchTools.tsx | 24 +- 7 files changed, 546 insertions(+), 34 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx create mode 100644 ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx diff --git a/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx index 18eaf5e493..49b57c2a88 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx @@ -1,14 +1,14 @@ -import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input, Typography } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { Button, TextInput } from "@tremor/react"; -import { createSearchTool, fetchAvailableSearchProviders } from "../networking"; -import { SearchTool, AvailableSearchProvider } from "./types"; import { isAdminRole } from "@/utils/roles"; -import NotificationsManager from "../molecules/notifications_manager"; +import { InfoCircleOutlined } from "@ant-design/icons"; import { useQuery } from "@tanstack/react-query"; -import SearchConnectionTest from "./SearchConnectionTest"; +import { Button, TextInput } from "@tremor/react"; +import { Form, Input, Modal, Select, Tooltip, Typography } from "antd"; import Image from "next/image"; +import React, { useState } from "react"; +import NotificationsManager from "../molecules/notifications_manager"; +import { createSearchTool, fetchAvailableSearchProviders } from "../networking"; +import SearchConnectionTest from "./SearchConnectionTest"; +import { AvailableSearchProvider, SearchTool } from "./types"; const { TextArea } = Input; diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx index e18d314591..a8472cb1f8 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx @@ -1,8 +1,8 @@ +import { InfoCircleOutlined, WarningOutlined } from "@ant-design/icons"; +import { Button, Divider, Typography } from "antd"; import React, { useEffect, useState } from "react"; -import { testSearchToolConnection } from "../networking"; -import { Button, Typography, Divider } from "antd"; -import { WarningOutlined, InfoCircleOutlined } from "@ant-design/icons"; import NotificationsManager from "../molecules/notifications_manager"; +import { testSearchToolConnection } from "../networking"; const { Text } = Typography; diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx index 86b0bde4d0..e741bd06a9 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx @@ -1,7 +1,7 @@ -import { ColumnsType } from "antd/es/table"; -import { SearchTool } from "./types"; import { Tag } from "antd"; +import { ColumnsType } from "antd/es/table"; import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import { SearchTool } from "./types"; export const searchToolColumns = ( onView: (searchToolId: string) => void, diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx new file mode 100644 index 0000000000..bb04a04a99 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx @@ -0,0 +1,278 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchToolView } from "./SearchToolView"; +import { AvailableSearchProvider, SearchTool } from "./types"; + +vi.mock("@/utils/dataUtils", () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), +})); + +vi.mock("./SearchToolTester", () => ({ + SearchToolTester: ({ searchToolName, accessToken }: { searchToolName: string; accessToken: string }) => ( +
+ Search Tool Tester for {searchToolName} + Access Token: {accessToken} +
+ ), +})); + +describe("SearchToolView", () => { + const mockSearchTool: SearchTool = { + search_tool_id: "test-tool-id-123", + search_tool_name: "Test Search Tool", + litellm_params: { + search_provider: "perplexity", + api_key: "sk-test-key", + }, + search_tool_info: { + description: "Test description", + }, + created_at: "2024-01-15T10:30:00Z", + }; + + const mockAvailableProviders: AvailableSearchProvider[] = [ + { + provider_name: "perplexity", + ui_friendly_name: "Perplexity AI", + }, + { + provider_name: "tavily", + ui_friendly_name: "Tavily Search", + }, + ]; + + const defaultProps = { + searchTool: mockSearchTool, + onBack: vi.fn(), + isEditing: false, + accessToken: "test-token", + availableProviders: mockAvailableProviders, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { copyToClipboard } = await import("@/utils/dataUtils"); + vi.mocked(copyToClipboard).mockResolvedValue(true); + }); + + it("should render", () => { + render(); + expect(screen.getByText("Test Search Tool")).toBeInTheDocument(); + }); + + it("should display search tool name", () => { + render(); + expect(screen.getByText("Test Search Tool")).toBeInTheDocument(); + }); + + it("should display search tool ID", () => { + render(); + expect(screen.getByText("test-tool-id-123")).toBeInTheDocument(); + }); + + it("should display provider name using UI-friendly name when available", () => { + render(); + expect(screen.getByText("Perplexity AI")).toBeInTheDocument(); + }); + + it("should display provider name using provider_name when UI-friendly name is not available", () => { + const searchToolWithoutProvider: SearchTool = { + ...mockSearchTool, + litellm_params: { + search_provider: "unknown-provider", + }, + }; + + render( + , + ); + expect(screen.getByText("unknown-provider")).toBeInTheDocument(); + }); + + it("should display masked API key when API key is set", () => { + render(); + expect(screen.getByText("****")).toBeInTheDocument(); + }); + + it("should display 'Not set' when API key is not set", () => { + const searchToolWithoutApiKey: SearchTool = { + ...mockSearchTool, + litellm_params: { + search_provider: "perplexity", + }, + }; + + render( + , + ); + expect(screen.getByText("Not set")).toBeInTheDocument(); + }); + + it("should display formatted created_at date", () => { + render(); + const dateText = screen.getByText(/2024-01-15/); + expect(dateText).toBeInTheDocument(); + }); + + it("should display 'Unknown' when created_at is not set", () => { + const searchToolWithoutDate: SearchTool = { + ...mockSearchTool, + created_at: undefined, + }; + + render( + , + ); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); + + it("should display description when search_tool_info.description is provided", () => { + render(); + expect(screen.getByText("Test description")).toBeInTheDocument(); + }); + + it("should not display description card when search_tool_info.description is not provided", () => { + const searchToolWithoutDescription: SearchTool = { + ...mockSearchTool, + search_tool_info: {}, + }; + + render( + , + ); + expect(screen.queryByText("Description")).not.toBeInTheDocument(); + }); + + it("should call onBack when back button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + const onBack = vi.fn(); + render(); + + const backButton = screen.getByRole("button", { name: /back to all search tools/i }); + await user.click(backButton); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it("should copy search tool name to clipboard when copy button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + render(); + + const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); + expect(toolNameContainer).toBeInTheDocument(); + + const copyButtons = within(toolNameContainer!).getAllByRole("button"); + const nameCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(nameCopyButton).toBeInTheDocument(); + await user.click(nameCopyButton!); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool"); + }); + }); + + it("should copy search tool ID to clipboard when copy button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + render(); + + const toolIdContainer = screen.getByText("test-tool-id-123").closest("div"); + expect(toolIdContainer).toBeInTheDocument(); + + const copyButtons = within(toolIdContainer!).getAllByRole("button"); + const idCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(idCopyButton).toBeInTheDocument(); + await user.click(idCopyButton!); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith("test-tool-id-123"); + }); + }); + + it("should show check icon after copying search tool name", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + vi.mocked(copyToClipboard).mockResolvedValue(true); + + render(); + + const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); + const copyButtons = within(toolNameContainer!).getAllByRole("button"); + const nameCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(nameCopyButton).toBeInTheDocument(); + + const initialSvg = nameCopyButton!.querySelector("svg"); + expect(initialSvg).toBeInTheDocument(); + + await user.click(nameCopyButton!); + + await waitFor(() => { + const updatedSvg = nameCopyButton!.querySelector("svg"); + expect(updatedSvg).toBeInTheDocument(); + expect(nameCopyButton).toHaveClass("text-green-600"); + }); + }); + + + it("should not show check icon when copy fails", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + vi.mocked(copyToClipboard).mockResolvedValue(false); + + render(); + + const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); + const copyButtons = within(toolNameContainer!).getAllByRole("button"); + const nameCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(nameCopyButton).toBeInTheDocument(); + await user.click(nameCopyButton!); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool"); + }, { timeout: 3000 }); + + expect(nameCopyButton).not.toHaveClass("text-green-600"); + }); + + it("should render SearchToolTester when accessToken is provided", () => { + render(); + expect(screen.getByTestId("search-tool-tester")).toBeInTheDocument(); + expect(screen.getByText(/Search Tool Tester for Test Search Tool/)).toBeInTheDocument(); + }); + + it("should not render SearchToolTester when accessToken is null", () => { + render(); + expect(screen.queryByTestId("search-tool-tester")).not.toBeInTheDocument(); + }); + + it("should pass correct props to SearchToolTester", () => { + render(); + expect(screen.getByText("Access Token: test-token")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx index c07041471e..ad88acd127 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx @@ -1,11 +1,11 @@ -import React, { useState } from "react"; -import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { Title, Card, Button, Text, Grid } from "@tremor/react"; -import { SearchTool, AvailableSearchProvider } from "./types"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; -import { CheckIcon, CopyIcon } from "lucide-react"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; +import { Button, Card, Grid, Text, Title } from "@tremor/react"; import { Button as AntdButton } from "antd"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import React, { useState } from "react"; import { SearchToolTester } from "./SearchToolTester"; +import { AvailableSearchProvider, SearchTool } from "./types"; interface SearchToolViewProps { searchTool: SearchTool; @@ -54,8 +54,8 @@ export const SearchToolView: React.FC = ({ icon={copiedStates["search-tool-name"] ? : } onClick={() => copyToClipboard(searchTool.search_tool_name, "search-tool-name")} className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-name"] - ? "text-green-600 bg-green-50 border-green-200" - : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" + ? "text-green-600 bg-green-50 border-green-200" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" }`} />
@@ -67,8 +67,8 @@ export const SearchToolView: React.FC = ({ icon={copiedStates["search-tool-id"] ? : } onClick={() => copyToClipboard(searchTool.search_tool_id, "search-tool-id")} className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-id"] - ? "text-green-600 bg-green-50 border-green-200" - : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" + ? "text-green-600 bg-green-50 border-green-200" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" }`} /> diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx new file mode 100644 index 0000000000..f1f7bf8ab4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx @@ -0,0 +1,234 @@ +import * as roles from "@/utils/roles"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import SearchTools from "./SearchTools"; +import { AvailableSearchProvider, SearchTool } from "./types"; + +vi.mock("../networking", () => ({ + fetchSearchTools: vi.fn(), + updateSearchTool: vi.fn(), + deleteSearchTool: vi.fn(), + fetchAvailableSearchProviders: vi.fn(), +})); + +vi.mock("@/utils/roles", () => ({ + isAdminRole: vi.fn(), +})); + +vi.mock("./SearchToolView", () => ({ + SearchToolView: ({ searchTool, onBack }: { searchTool: SearchTool; onBack: () => void }) => ( +
+
Search Tool View: {searchTool.search_tool_name}
+ +
+ ), +})); + +vi.mock("./CreateSearchTools", () => ({ + default: ({ + isModalVisible, + setModalVisible, + }: { + isModalVisible: boolean; + setModalVisible: (visible: boolean) => void; + }) => + isModalVisible ? ( +
+ +
+ ) : null, +})); + +vi.mock("../common_components/DeleteResourceModal", () => ({ + default: ({ + isOpen, + onOk, + onCancel, + }: { + isOpen: boolean; + onOk: () => void; + onCancel: () => void; + }) => + isOpen ? ( +
+ + +
+ ) : null, +})); + +const mockSearchTools: SearchTool[] = [ + { + search_tool_id: "tool-1", + search_tool_name: "Perplexity Search", + litellm_params: { + search_provider: "perplexity", + api_key: "sk-test-key", + }, + search_tool_info: { + description: "Test description", + }, + created_at: "2024-01-15T10:30:00Z", + }, + { + search_tool_id: "tool-2", + search_tool_name: "Tavily Search", + litellm_params: { + search_provider: "tavily", + }, + created_at: "2024-01-16T10:30:00Z", + }, +]; + +const mockAvailableProviders: AvailableSearchProvider[] = [ + { + provider_name: "perplexity", + ui_friendly_name: "Perplexity AI", + }, + { + provider_name: "tavily", + ui_friendly_name: "Tavily Search", + }, +]; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe("SearchTools", () => { + const defaultProps = { + accessToken: "test-token", + userRole: "Admin", + userID: "user-1", + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(networking.fetchSearchTools).mockResolvedValue({ search_tools: mockSearchTools }); + vi.mocked(networking.fetchAvailableSearchProviders).mockResolvedValue({ providers: mockAvailableProviders }); + vi.mocked(roles.isAdminRole).mockReturnValue(true); + }); + + it("should render", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Search Tools")).toBeInTheDocument(); + }); + }); + + it("should display missing authentication parameters message when accessToken is missing", () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument(); + }); + + it("should display missing authentication parameters message when userRole is missing", () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument(); + }); + + it("should display missing authentication parameters message when userID is missing", () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument(); + }); + + it("should display search tools table with tools", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + expect(screen.getAllByText("Tavily Search").length).toBeGreaterThan(0); + }); + + it("should display empty state when no search tools are available", async () => { + vi.mocked(networking.fetchSearchTools).mockResolvedValue({ search_tools: [] }); + + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("No search tools configured")).toBeInTheDocument(); + }); + }); + + it("should show Add New Search Tool button when user is admin", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument(); + }); + }); + + it("should not show Add New Search Tool button when user is not admin", async () => { + vi.mocked(roles.isAdminRole).mockReturnValue(false); + + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Search Tools")).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: /add new search tool/i })).not.toBeInTheDocument(); + }); + + it("should open create modal when Add New Search Tool button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument(); + }); + + const addButton = screen.getByRole("button", { name: /add new search tool/i }); + await user.click(addButton); + + expect(screen.getByTestId("create-search-tool-modal")).toBeInTheDocument(); + }); + + it("should navigate to tool view when tool ID is clicked", async () => { + const user = userEvent.setup({ delay: null }); + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + + const toolIdButton = screen.getByRole("button", { name: /tool-1/i }); + await user.click(toolIdButton); + + await waitFor(() => { + expect(screen.getByTestId("search-tool-view")).toBeInTheDocument(); + }); + expect(screen.getByText(/Search Tool View: Perplexity Search/i)).toBeInTheDocument(); + }); + + it("should navigate back from tool view to table", async () => { + const user = userEvent.setup({ delay: null }); + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + + const toolIdButton = screen.getByRole("button", { name: /tool-1/i }); + await user.click(toolIdButton); + + await waitFor(() => { + expect(screen.getByTestId("search-tool-view")).toBeInTheDocument(); + }); + + const backButton = screen.getByRole("button", { name: /back/i }); + await user.click(backButton); + + await waitFor(() => { + expect(screen.queryByTestId("search-tool-view")).not.toBeInTheDocument(); + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx index ba42ea22cd..dd2033fc18 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx @@ -1,21 +1,21 @@ -import React, { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Modal, Form, Input, Select, Table, Spin } from "antd"; +import { isAdminRole } from "@/utils/roles"; import { LoadingOutlined } from "@ant-design/icons"; -import { Button, Title, Text } from "@tremor/react"; -import { searchToolColumns } from "./SearchToolColumn"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Text, Title } from "@tremor/react"; +import { Form, Input, Modal, Select, Spin, Table } from "antd"; +import React, { useState } from "react"; +import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import NotificationsManager from "../molecules/notifications_manager"; import { - fetchSearchTools, - updateSearchTool, deleteSearchTool, fetchAvailableSearchProviders, + fetchSearchTools, + updateSearchTool, } from "../networking"; -import { SearchTool, AvailableSearchProvider } from "./types"; -import { isAdminRole } from "@/utils/roles"; -import NotificationsManager from "../molecules/notifications_manager"; -import { SearchToolView } from "./SearchToolView"; import CreateSearchTool from "./CreateSearchTools"; -import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import { searchToolColumns } from "./SearchToolColumn"; +import { SearchToolView } from "./SearchToolView"; +import { AvailableSearchProvider, SearchTool } from "./types"; interface SearchToolsProps { accessToken: string | null;