From baf85d7e3835c0373b8e4a990866c3da6970ee4b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 22 Jul 2026 22:09:04 -0700 Subject: [PATCH] refactor(ui): migrate search-tools info view to shadcn (#34323) * test(ui): pin search-tools info view behavior before shadcn migration Rewrite the markup-coupled copy-button assertions in SearchToolView to role queries plus lucide icon-state, and add a role/text characterization suite for SearchConnectionTest, which had none. Both are green against the current antd/Tremor components so they can act as an unedited regression net across the migration. * refactor(ui): migrate search-tools info view to shadcn Port the search-tools detail view and its two helpers off antd and Tremor onto the installed shadcn primitives plus token utilities: - SearchToolView (the info page reached by clicking a tool) now uses ui/button, ui/card and a plain CSS-grid header instead of Tremor Card/Grid/Title/Text and antd Button - SearchToolTester swaps antd Input/Button/Spin and Tremor Card/Title for ui/input, ui/button and UiLoadingSpinner, with no inline styles - SearchConnectionTest swaps antd Button/Divider/Typography and the inline keyframe spinner for ui/button, ui/separator and UiLoadingSpinner Markup only; no behavior change. The list page (SearchTools) and its create and edit forms stay on antd because they are Form-bearing and blocked until the forms migration. Icons move from antd and heroicons to lucide. The retired antd no-restricted-imports suppressions are pruned from the baseline. * fix(ui): drop redundant vertical padding in SearchToolTester card The shadcn Card already applies py-6 and gap-6 to its flex children, so the pt-6/pb-6/mb-6 added during the migration stacked on top of it and roughly doubled the vertical whitespace. Keep only px-6 (Card has no horizontal padding) and let the Card own the vertical rhythm, which restores the original 24px spacing. * style(ui): format SearchConnectionTest test file with prettier --- ui/litellm-dashboard/eslint-suppressions.json | 23 -- .../_components/SearchConnectionTest.test.tsx | 130 ++++++++++ .../_components/SearchConnectionTest.tsx | 236 ++++++------------ .../_components/SearchToolTester.tsx | 202 +++++---------- .../_components/SearchToolView.test.tsx | 44 +--- .../_components/SearchToolView.tsx | 95 ++++--- 6 files changed, 325 insertions(+), 405 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index e31cdcae596..12f19eaa1fe 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1143,14 +1143,6 @@ "count": 1 } }, - "src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx": { - "no-restricted-imports": { - "count": 3 - }, - "react-hooks/preserve-manual-memoization": { - "count": 4 - } - }, "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx": { "max-params": { "count": 1 @@ -1859,21 +1851,6 @@ "count": 1 } }, - "src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/app/(dashboard)/search-tools/_components/SearchToolView.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/app/(dashboard)/search-tools/_components/SearchTools.tsx": { "local/no-complex-jsx-arrow": { "count": 2 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.test.tsx new file mode 100644 index 00000000000..cfe5d4d843e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import SearchConnectionTest from "./SearchConnectionTest"; +import * as networking from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +vi.mock("@/components/networking", () => ({ + testSearchToolConnection: vi.fn(), +})); + +const defaultProps = { + litellmParams: { search_provider: "tavily" }, + accessToken: "test-token", +}; + +describe("SearchConnectionTest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes the access token and params to the connection test", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + }); + + render(); + + await waitFor(() => { + expect(networking.testSearchToolConnection).toHaveBeenCalledWith( + defaultProps.accessToken, + defaultProps.litellmParams, + ); + }); + }); + + it("shows a loading state naming the provider while the test is pending", () => { + vi.mocked(networking.testSearchToolConnection).mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByText(/Testing connection to tavily/i)).toBeInTheDocument(); + }); + + it("renders a success state with the test query and result count", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + test_query: "hello world", + results_count: 3, + }); + + render(); + + expect(await screen.findByText(/Connection to tavily successful/i)).toBeInTheDocument(); + expect(screen.getByText("hello world")).toBeInTheDocument(); + expect(screen.getByText(/Results retrieved: 3/i)).toBeInTheDocument(); + }); + + it("fires a success notification and completion callback on a successful test", async () => { + const onTestComplete = vi.fn(); + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + }); + + render(); + + await waitFor(() => { + expect(NotificationsManager.success).toHaveBeenCalledWith("Connection test successful!"); + }); + expect(onTestComplete).toHaveBeenCalledTimes(1); + }); + + it("renders a failure state with a cleaned error message and error type", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "error", + message: "litellm.AuthenticationError: Invalid API key\nstack trace: deep internals", + error_type: "AuthenticationError", + }); + + render(); + + expect(await screen.findByText(/Connection to tavily failed/i)).toBeInTheDocument(); + expect(screen.getByText("Invalid API key")).toBeInTheDocument(); + expect(screen.getByText("AuthenticationError")).toBeInTheDocument(); + expect(screen.getByText("Verify your API key is correct and active")).toBeInTheDocument(); + }); + + it("reveals the raw error details when Show Details is toggled", async () => { + const user = userEvent.setup(); + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "error", + message: "litellm.AuthenticationError: Invalid API key\nstack trace: deep internals", + error_type: "AuthenticationError", + }); + + render(); + + const toggle = await screen.findByRole("button", { name: /show details/i }); + expect(screen.queryByText("Full Error Details")).not.toBeInTheDocument(); + + await user.click(toggle); + + expect(screen.getByText("Full Error Details")).toBeInTheDocument(); + expect(screen.getByText(/stack trace: deep internals/i)).toBeInTheDocument(); + }); + + it("treats a rejected request as a connection failure", async () => { + vi.mocked(networking.testSearchToolConnection).mockRejectedValue(new Error("network down")); + + render(); + + expect(await screen.findByText(/Connection to tavily failed/i)).toBeInTheDocument(); + expect(screen.getByText("network down")).toBeInTheDocument(); + }); + + it("links out to the search documentation", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + }); + + render(); + + const docLink = await screen.findByRole("link", { name: /View Search Documentation/i }); + expect(docLink).toHaveAttribute("href", "https://docs.litellm.ai/docs/search"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx index 4e8678ded71..446d9a71517 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx @@ -1,10 +1,10 @@ -import { InfoCircleOutlined, WarningOutlined } from "@ant-design/icons"; -import { Button, Divider, Typography } from "antd"; +import { AlertTriangle, CheckCircle2, Info } from "lucide-react"; import React, { useEffect, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { testSearchToolConnection } from "@/components/networking"; - -const { Text } = Typography; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface SearchConnectionTestProps { litellmParams: Record; @@ -52,30 +52,23 @@ const SearchConnectionTest: React.FC = ({ litellmPara const getCleanErrorMessage = (errorMsg: string) => { if (!errorMsg) return "Unknown error"; - // Remove stack traces const mainError = errorMsg.split("stack trace:")[0].trim(); - // Remove litellm error prefixes const cleanedError = mainError.replace(/^litellm\.(.*?)Error:\s*/, ""); - // Remove AuthenticationError prefix if it exists const finalError = cleanedError.replace(/^AuthenticationError:\s*/, ""); - // If the error contains HTML (like a 401 page), extract just the key info if (finalError.includes("") || finalError.includes("(.*?)<\/title>/); if (titleMatch) { return titleMatch[1]; } - // If it's a 401 error if (finalError.includes("401") || finalError.includes("Authorization Required")) { return "Authentication failed: Invalid API key or credentials"; } return "Authentication error - please check your API key"; } - // Limit very long error messages if (finalError.length > 200) { return finalError.substring(0, 200) + "..."; } @@ -87,34 +80,12 @@ const SearchConnectionTest: React.FC = ({ litellmPara if (isLoading) { return ( -
-
-
-
-
- +
+
+ +

Testing connection to {litellmParams.search_provider || "search provider"}... - - +

); @@ -125,147 +96,88 @@ const SearchConnectionTest: React.FC = ({ litellmPara } return ( -
+
{testResult.status === "success" ? ( -
-
- -
-
- +
+ +
+

Connection to {litellmParams.search_provider} successful! - +

{testResult.test_query && ( - - Test query:{" "} - - {testResult.test_query} - - +

+ Test query: {testResult.test_query} +

)} {testResult.results_count !== undefined && ( - - Results retrieved: {testResult.results_count} - +

Results retrieved: {testResult.results_count}

)}
) : ( - <> -
-
- - - Connection to {litellmParams.search_provider || "search provider"} failed - -
+
+
+ +

+ Connection to {litellmParams.search_provider || "search provider"} failed +

+
-
- - Error:{" "} - - - {errorMessage} - +
+

Error:

+

{errorMessage}

- {testResult.error_type && ( -
- - Error type:{" "} - - {testResult.error_type} - - -
- )} - - {testResult.message && ( -
- -
- )} -
- - {showDetails && ( -
- - Full Error Details - -
-                  {testResult.message}
-                
+ {testResult.error_type && ( +
+

+ Error type:{" "} + + {testResult.error_type} + +

)} -
- - Troubleshooting tips: - -
    -
  • Verify your API key is correct and active
  • -
  • Check if the search provider service is operational
  • -
  • Ensure you have sufficient credits/quota with the provider
  • -
  • - Review the provider's documentation for any additional requirements -
  • -
-
+ {testResult.message && ( +
+ +
+ )}
- + + {showDetails && ( +
+

Full Error Details

+
+                {testResult.message}
+              
+
+ )} + +
+

Troubleshooting tips:

+
    +
  • Verify your API key is correct and active
  • +
  • Check if the search provider service is operational
  • +
  • Ensure you have sufficient credits/quota with the provider
  • +
  • Review the provider's documentation for any additional requirements
  • +
+
+
)} - -
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx index 2fe9f3b5b8c..95772608235 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx @@ -1,12 +1,12 @@ import React, { useState } from "react"; -import { Button, Input, Typography, Spin } from "antd"; +import { ExternalLink, Search } from "lucide-react"; import MessageManager from "@/components/molecules/message_manager"; -import { SearchOutlined, LoadingOutlined } from "@ant-design/icons"; import { searchToolQueryCall } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { Card, Title as TremorTitle } from "@tremor/react"; - -const { Text } = Typography; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface SearchResult { title: string; @@ -36,7 +36,6 @@ export const SearchToolTester: React.FC = ({ searchToolNa }[] >([]); const [expandedResults, setExpandedResults] = useState>({}); - const [isInputFocused, setIsInputFocused] = useState(false); const handleSearch = async () => { if (!query.trim()) { @@ -60,7 +59,6 @@ export const SearchToolTester: React.FC = ({ searchToolNa }; setSearchHistory((prev) => [historyEntry, ...prev]); - // Don't clear query after search so user can modify it } catch (error) { console.error("Error querying search tool:", error); NotificationsManager.fromBackend("Failed to query search tool"); @@ -87,113 +85,79 @@ export const SearchToolTester: React.FC = ({ searchToolNa })); }; - const antIcon = ; - const latestResults = searchHistory.length > 0 ? searchHistory[0] : null; return ( - -
- Test Search Tool + +
+

Test Search Tool

-
- {/* Search Bar at Top */} +
-
- +
+ setQuery(e.target.value)} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onPressEnter={(e) => { - if (!e.shiftKey) { + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSearch(); } }} placeholder="Enter your search query..." disabled={isLoading} - bordered={false} - style={{ fontSize: "15px", padding: 0, height: "100%", boxShadow: "none" }} + className="h-12 pl-11 text-[15px]" />
-
- {/* Results Area */}
{!latestResults && !isLoading ? ( -
-
- +
+
+
- Test your search tool - Enter a query above to see search results +

Test your search tool

+

Enter a query above to see search results

) : (
{isLoading && ( -
- - Searching... +
+ +

Searching...

)} {latestResults && !isLoading && ( <> - {/* Query Info Bar */} -
+
- +

Search Query - -

{latestResults.query}
+

+
{latestResults.query}
-
- {formatTimestamp(latestResults.timestamp)} -
-
+
+

{formatTimestamp(latestResults.timestamp)}

+
+
{latestResults.response?.results?.length || 0}{" "} {latestResults.response?.results?.length === 1 ? "result" : "results"}
{latestResults.latency !== undefined && ( <> - -
{latestResults.latency}ms
+ +
{latestResults.latency}ms
)}
@@ -201,7 +165,6 @@ export const SearchToolTester: React.FC = ({ searchToolNa
- {/* Search Results */} {latestResults.response && latestResults.response.results && latestResults.response.results.length > 0 ? ( @@ -212,73 +175,43 @@ export const SearchToolTester: React.FC = ({ searchToolNa return (
{ - e.currentTarget.style.boxShadow = - "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)"; - e.currentTarget.style.borderColor = "#e0e7ff"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)"; - e.currentTarget.style.borderColor = "#e5e7eb"; - }} + className="rounded-lg border border-border bg-card transition-shadow hover:shadow-md" >
- {/* Title and External Link */} -
+
(e.currentTarget.style.textDecoration = "underline")} - onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")} + className="flex-1 text-lg leading-snug font-semibold text-primary hover:underline" > {result.title}
- {/* URL */} -
{result.url}
+
{result.url}
- {/* Snippet Preview */} -
+
{isResultExpanded ? result.snippet : `${result.snippet.substring(0, 200)}${result.snippet.length > 200 ? "..." : ""}`}
- {/* Expand/Collapse */} {result.snippet.length > 200 && ( @@ -289,31 +222,22 @@ export const SearchToolTester: React.FC = ({ searchToolNa })}
) : ( -
-
- +
+
+
- No results found - Try a different search query +

No results found

+

Try a different search query

)} )} - {/* Search History Sidebar */} {searchHistory.length > 1 && ( -
-
- Previous Searches -
@@ -321,21 +245,21 @@ export const SearchToolTester: React.FC = ({ searchToolNa {searchHistory.slice(1, 6).map((entry, index) => (
{ setQuery(entry.query); }} > -
{entry.query}
-
- +
{entry.query}
+
+ {entry.response?.results?.length || 0}{" "} {entry.response?.results?.length === 1 ? "result" : "results"} {entry.latency !== undefined && ( <> - {entry.latency}ms + {entry.latency}ms )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx index 049523c0254..e4b2cf62940 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx @@ -155,13 +155,8 @@ describe("SearchToolView", () => { 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!); + const nameCopyButton = within(toolNameContainer!).getByRole("button"); + await user.click(nameCopyButton); await waitFor(() => { expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool"); @@ -176,13 +171,8 @@ describe("SearchToolView", () => { 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!); + const idCopyButton = within(toolIdContainer!).getByRole("button"); + await user.click(idCopyButton); await waitFor(() => { expect(copyToClipboard).toHaveBeenCalledWith("test-tool-id-123"); @@ -197,22 +187,14 @@ describe("SearchToolView", () => { 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; - }); + const nameCopyButton = within(toolNameContainer!).getByRole("button"); - expect(nameCopyButton).toBeInTheDocument(); + expect(nameCopyButton.querySelector(".lucide-copy")).toBeInTheDocument(); - const initialSvg = nameCopyButton!.querySelector("svg"); - expect(initialSvg).toBeInTheDocument(); - - await user.click(nameCopyButton!); + await user.click(nameCopyButton); await waitFor(() => { - const updatedSvg = nameCopyButton!.querySelector("svg"); - expect(updatedSvg).toBeInTheDocument(); - expect(nameCopyButton).toHaveClass("text-green-600"); + expect(nameCopyButton.querySelector(".lucide-check")).toBeInTheDocument(); }); }); @@ -224,13 +206,9 @@ describe("SearchToolView", () => { 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; - }); + const nameCopyButton = within(toolNameContainer!).getByRole("button"); - expect(nameCopyButton).toBeInTheDocument(); - await user.click(nameCopyButton!); + await user.click(nameCopyButton); await waitFor( () => { @@ -239,7 +217,7 @@ describe("SearchToolView", () => { { timeout: 3000 }, ); - expect(nameCopyButton).not.toHaveClass("text-green-600"); + expect(nameCopyButton.querySelector(".lucide-check")).not.toBeInTheDocument(); }); it("should render SearchToolTester when accessToken is provided", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx index e77234aa3a0..1f7c992aa98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx @@ -1,9 +1,8 @@ import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; -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 { ArrowLeft, Check, Copy } from "lucide-react"; import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { SearchToolTester } from "./SearchToolTester"; import { AvailableSearchProvider, SearchTool } from "./types"; @@ -43,73 +42,73 @@ export const SearchToolView: React.FC = ({
- -
- {searchTool.search_tool_name} - : } +
+

{searchTool.search_tool_name}

+
-
- {searchTool.search_tool_id} - : } +
+

{searchTool.search_tool_id}

+
- +
- Provider -
- {getProviderDisplayName(searchTool.litellm_params.search_provider)} -
+ +

Provider

+

+ {getProviderDisplayName(searchTool.litellm_params.search_provider)} +

+
- API Key -
- {searchTool.litellm_params.api_key ? "****" : "Not set"} -
+ +

API Key

+

{searchTool.litellm_params.api_key ? "****" : "Not set"}

+
- Created At -
- {searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"} -
+ +

Created At

+

+ {searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"} +

+
- +
{searchTool.search_tool_info?.description && ( - Description -
- {searchTool.search_tool_info.description} -
+ +

Description

+

{searchTool.search_tool_info.description}

+
)} - {/* Search Tool Tester */}
{accessToken && }