mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 15:05:01 +00:00
Merge pull request #20436 from BerriAI/litellm_search_tools_config_ui
[Feature] UI - Search Tools: Show Config Defined Search Tools
This commit is contained in:
@@ -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";
|
||||
|
||||
+13
-13
@@ -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 "./search_connection_test";
|
||||
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;
|
||||
|
||||
@@ -97,8 +97,8 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
||||
},
|
||||
search_tool_info: formValues.description
|
||||
? {
|
||||
description: formValues.description,
|
||||
}
|
||||
description: formValues.description,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -130,7 +130,7 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
||||
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<CreateSearchToolProps> = ({
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
value={provider.provider_name}
|
||||
label={
|
||||
<SearchProviderLabel
|
||||
+3
-3
@@ -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;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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,
|
||||
onEdit: (searchToolId: string) => void,
|
||||
onDelete: (searchToolId: string) => void,
|
||||
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
|
||||
): ColumnsType<SearchTool> => [
|
||||
{
|
||||
title: "Search Tool ID",
|
||||
dataIndex: "search_tool_id",
|
||||
key: "search_tool_id",
|
||||
render: (_, tool) => {
|
||||
const isFromConfig = tool.is_from_config;
|
||||
|
||||
if (isFromConfig) {
|
||||
return <span className="text-xs">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onView(tool.search_tool_id!)}
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left cursor-pointer max-w-40"
|
||||
>
|
||||
<span className="truncate block">{tool.search_tool_id}</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "search_tool_name",
|
||||
key: "search_tool_name",
|
||||
render: (name: string) => <span className="font-medium">{name}</span>,
|
||||
},
|
||||
{
|
||||
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 <span className="text-sm">{displayName}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Created At",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (_, tool) => {
|
||||
return <span className="text-xs">{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Updated At",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
render: (_, tool) => {
|
||||
return <span className="text-xs">{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Source",
|
||||
key: "source",
|
||||
render: (_, tool) => {
|
||||
const isFromConfig = tool.is_from_config ?? false;
|
||||
|
||||
return (
|
||||
<Tag color={isFromConfig ? "default" : "blue"}>
|
||||
{isFromConfig ? "Config" : "DB"}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
render: (_, tool) => {
|
||||
const toolId = tool.search_tool_id;
|
||||
const isFromConfig = tool.is_from_config ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be edited on the dashboard. Please edit it from the config file."
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
onEdit(toolId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be deleted on the dashboard. Please delete it from the config file."
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
onDelete(toolId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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 }) => (
|
||||
<div data-testid="search-tool-tester">
|
||||
<span>Search Tool Tester for {searchToolName}</span>
|
||||
<span>Access Token: {accessToken}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByText("Test Search Tool")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display search tool name", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByText("Test Search Tool")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display search tool ID", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByText("test-tool-id-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display provider name using UI-friendly name when available", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
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(
|
||||
<SearchToolView
|
||||
{...defaultProps}
|
||||
searchTool={searchToolWithoutProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("unknown-provider")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display masked API key when API key is set", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
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(
|
||||
<SearchToolView
|
||||
{...defaultProps}
|
||||
searchTool={searchToolWithoutApiKey}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Not set")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display formatted created_at date", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
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(
|
||||
<SearchToolView
|
||||
{...defaultProps}
|
||||
searchTool={searchToolWithoutDate}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display description when search_tool_info.description is provided", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
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(
|
||||
<SearchToolView
|
||||
{...defaultProps}
|
||||
searchTool={searchToolWithoutDescription}
|
||||
/>,
|
||||
);
|
||||
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(<SearchToolView {...defaultProps} onBack={onBack} />);
|
||||
|
||||
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(<SearchToolView {...defaultProps} />);
|
||||
|
||||
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(<SearchToolView {...defaultProps} />);
|
||||
|
||||
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(<SearchToolView {...defaultProps} />);
|
||||
|
||||
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(<SearchToolView {...defaultProps} />);
|
||||
|
||||
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(<SearchToolView {...defaultProps} />);
|
||||
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(<SearchToolView {...defaultProps} accessToken={null} />);
|
||||
expect(screen.queryByTestId("search-tool-tester")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass correct props to SearchToolTester", () => {
|
||||
render(<SearchToolView {...defaultProps} />);
|
||||
expect(screen.getByText("Access Token: test-token")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+14
-16
@@ -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 { SearchToolTester } from "./search_tool_tester";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { SearchToolTester } from "./SearchToolTester";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
interface SearchToolViewProps {
|
||||
searchTool: SearchTool;
|
||||
@@ -53,11 +53,10 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
||||
size="small"
|
||||
icon={copiedStates["search-tool-name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
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"
|
||||
}`}
|
||||
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"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
@@ -67,11 +66,10 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
||||
size="small"
|
||||
icon={copiedStates["search-tool-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
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"
|
||||
}`}
|
||||
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"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 }) => (
|
||||
<div data-testid="search-tool-view">
|
||||
<div>Search Tool View: {searchTool.search_tool_name}</div>
|
||||
<button onClick={onBack}>Back</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./CreateSearchTools", () => ({
|
||||
default: ({
|
||||
isModalVisible,
|
||||
setModalVisible,
|
||||
}: {
|
||||
isModalVisible: boolean;
|
||||
setModalVisible: (visible: boolean) => void;
|
||||
}) =>
|
||||
isModalVisible ? (
|
||||
<div data-testid="create-search-tool-modal">
|
||||
<button onClick={() => setModalVisible(false)}>Close Create Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/DeleteResourceModal", () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
onOk,
|
||||
onCancel,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div data-testid="delete-resource-modal">
|
||||
<button onClick={onOk}>Confirm Delete</button>
|
||||
<button onClick={onCancel}>Cancel Delete</button>
|
||||
</div>
|
||||
) : 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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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(<SearchTools {...defaultProps} />, { wrapper: createWrapper() });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display missing authentication parameters message when accessToken is missing", () => {
|
||||
render(<SearchTools {...defaultProps} accessToken={null} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display missing authentication parameters message when userRole is missing", () => {
|
||||
render(<SearchTools {...defaultProps} userRole={null} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display missing authentication parameters message when userID is missing", () => {
|
||||
render(<SearchTools {...defaultProps} userID={null} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display search tools table with tools", async () => {
|
||||
render(<SearchTools {...defaultProps} />, { 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(<SearchTools {...defaultProps} />, { 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(<SearchTools {...defaultProps} />, { 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(<SearchTools {...defaultProps} />, { 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(<SearchTools {...defaultProps} />, { 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(<SearchTools {...defaultProps} />, { 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(<SearchTools {...defaultProps} />, { 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+56
-44
@@ -1,20 +1,21 @@
|
||||
import React, { useState } from "react";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
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 { 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 "./search_tool_view";
|
||||
import CreateSearchTool from "./create_search_tool";
|
||||
import CreateSearchTool from "./CreateSearchTools";
|
||||
import { searchToolColumns } from "./SearchToolColumn";
|
||||
import { SearchToolView } from "./SearchToolView";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
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 (
|
||||
<Modal open={isModalOpen} onOk={confirmDelete} okType="danger" onCancel={cancelDelete}>
|
||||
<Grid numItems={1} className="gap-2 w-full">
|
||||
<Title>{title}</Title>
|
||||
<Col numColSpan={1}>
|
||||
<p>Are you sure you want to delete this search tool?</p>
|
||||
</Col>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID }) => {
|
||||
const {
|
||||
@@ -72,6 +55,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
||||
// State
|
||||
const [toolIdToDelete, setToolToDelete] = useState<string | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
|
||||
const [editTool, setEditTool] = useState(false);
|
||||
const [isCreateModalVisible, setCreateModalVisible] = useState(false);
|
||||
@@ -116,16 +100,19 @@ const SearchTools: React.FC<SearchToolsProps> = ({ 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<SearchToolsProps> = ({ 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<SearchToolsProps> = ({ accessToken, userRole, userID
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full px-6 mt-6">
|
||||
<DataTable
|
||||
data={searchTools || []}
|
||||
<Spin spinning={isLoadingTools} indicator={<LoadingOutlined spin />} size="large">
|
||||
<Table
|
||||
bordered
|
||||
dataSource={searchTools || []}
|
||||
columns={columns}
|
||||
renderSubComponent={() => <div></div>}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6">
|
||||
<DeleteModal
|
||||
isModalOpen={isDeleteModalOpen}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Search Tool"
|
||||
confirmDelete={confirmDelete}
|
||||
cancelDelete={cancelDelete}
|
||||
message="Are you sure you want to delete this search tool? This action cannot be undone."
|
||||
resourceInformationTitle="Search Tool Information"
|
||||
resourceInformation={
|
||||
toolToDelete
|
||||
? [
|
||||
{ label: "Name", value: toolToDelete.search_tool_name },
|
||||
{ label: "ID", value: toolToDelete.search_tool_id, code: true },
|
||||
{
|
||||
label: "Provider",
|
||||
value: providerInfo?.ui_friendly_name || toolToDelete.litellm_params.search_provider,
|
||||
},
|
||||
{ label: "Description", value: toolToDelete.search_tool_info?.description || "-" },
|
||||
]
|
||||
: []
|
||||
}
|
||||
onCancel={cancelDelete}
|
||||
onOk={confirmDelete}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
|
||||
<CreateSearchTool
|
||||
@@ -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';
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ export interface SearchTool {
|
||||
search_tool_info?: SearchToolInfo;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
is_from_config?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchToolsResponse {
|
||||
@@ -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';
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { SearchTool } from "./types";
|
||||
import { Icon } from "@tremor/react";
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
|
||||
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<SearchTool>[] => [
|
||||
{
|
||||
accessorKey: "search_tool_id",
|
||||
header: "Search Tool ID",
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
onClick={() => onView(row.original.search_tool_id!)}
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
|
||||
>
|
||||
{row.original.search_tool_id?.slice(0, 7)}...
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "search_tool_name",
|
||||
header: "Name",
|
||||
cell: ({ getValue }) => <span className="font-medium">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
return <span className="text-sm">{displayName}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const tool = row.original;
|
||||
return <span className="text-xs">{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const tool = row.original;
|
||||
return <span className="text-xs">{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => onEdit(row.original.search_tool_id!)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => onDelete(row.original.search_tool_id!)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user