diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.integration.test.tsx index d4fbb7e153e..3de88fb6f57 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.integration.test.tsx @@ -40,7 +40,7 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn().mockResolvedValue([]), })); -vi.mock("@/components/HelpLink", () => ({ +vi.mock("@/components/DocsMenu", () => ({ DocsMenu: () => null, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx index a8609aef629..50c8c12c9c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx @@ -41,7 +41,7 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn().mockResolvedValue([]), })); -vi.mock("@/components/HelpLink", () => ({ +vi.mock("@/components/DocsMenu", () => ({ DocsMenu: () => null, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx index 52b66edcbe5..b8e51939dd0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx @@ -19,7 +19,7 @@ import AddProviderForm from "./add_provider_form"; import ProviderMarginTable from "./provider_margin_table"; import AddMarginForm from "./add_margin_form"; import PricingCalculator from "./pricing_calculator/index"; -import { DocsMenu } from "@/components/HelpLink"; +import { DocsMenu } from "@/components/DocsMenu"; import HowItWorks from "./how_it_works"; import { useDiscountConfig } from "./use_discount_config"; import { useMarginConfig } from "./use_margin_config"; diff --git a/ui/litellm-dashboard/src/components/DocsMenu.test.tsx b/ui/litellm-dashboard/src/components/DocsMenu.test.tsx new file mode 100644 index 00000000000..327f1368b5f --- /dev/null +++ b/ui/litellm-dashboard/src/components/DocsMenu.test.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../tests/test-utils"; +import { DocsMenu } from "./DocsMenu"; + +describe("DocsMenu", () => { + const items = [ + { label: "Custom pricing", href: "https://docs.example.com/pricing" }, + { label: "Cost tracking", href: "https://docs.example.com/cost" }, + ]; + + it("should render the menu button with default text", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument(); + }); + + it("should hide menu items initially", () => { + renderWithProviders(); + expect(screen.queryByText("Custom pricing")).not.toBeInTheDocument(); + }); + + it("should show menu items when button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /docs/i })); + + expect(screen.getByText("Custom pricing")).toBeInTheDocument(); + expect(screen.getByText("Cost tracking")).toBeInTheDocument(); + }); + + it("should close the menu when an item is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /docs/i })); + await user.click(screen.getByText("Custom pricing")); + + expect(screen.queryByText("Cost tracking")).not.toBeInTheDocument(); + }); + + it("should set aria-expanded correctly based on menu state", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const button = screen.getByRole("button", { name: /docs/i }); + expect(button).toHaveAttribute("aria-expanded", "false"); + + await user.click(button); + expect(button).toHaveAttribute("aria-expanded", "true"); + }); + + it("should close menu when clicking outside", async () => { + const user = userEvent.setup(); + renderWithProviders( +
+ + +
, + ); + await user.click(screen.getByRole("button", { name: /docs/i })); + expect(screen.getByText("Custom pricing")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /outside/i })); + expect(screen.queryByText("Custom pricing")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/DocsMenu.tsx b/ui/litellm-dashboard/src/components/DocsMenu.tsx new file mode 100644 index 00000000000..81958f70683 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DocsMenu.tsx @@ -0,0 +1,67 @@ +import React, { useState, useRef, useEffect } from "react"; +import { ExternalLink, ChevronDown } from "lucide-react"; + +interface DocMenuItem { + label: string; + href: string; +} + +interface DocsMenuProps { + items: DocMenuItem[]; + children?: React.ReactNode; + className?: string; +} + +export const DocsMenu: React.FC = ({ items, children = "Docs", className = "" }) => { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + return ( +
+ + + {isOpen && ( +
+ {items.map((item, index) => ( + setIsOpen(false)} + > + {item.label} + + ))} +
+ )} +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/HelpLink.test.tsx b/ui/litellm-dashboard/src/components/HelpLink.test.tsx deleted file mode 100644 index e502126477a..00000000000 --- a/ui/litellm-dashboard/src/components/HelpLink.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React from "react"; -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../tests/test-utils"; -import { HelpLink, HelpIcon, DocsMenu } from "./HelpLink"; - -describe("HelpLink", () => { - it("should render with default children and open in new tab", () => { - renderWithProviders(); - - const link = screen.getByRole("link", { name: /learn more/i }); - expect(link).toHaveAttribute("href", "https://docs.example.com"); - expect(link).toHaveAttribute("target", "_blank"); - expect(link).toHaveAttribute("rel", "noopener noreferrer"); - }); - - it("should render custom children text", () => { - renderWithProviders(Custom docs link); - - expect(screen.getByText("Custom docs link")).toBeInTheDocument(); - }); - - it("should have the correct href", () => { - renderWithProviders(); - expect(screen.getByRole("link")).toHaveAttribute("href", "https://docs.example.com/test"); - }); - - it("should include a screen-reader-only label for accessibility", () => { - renderWithProviders(); - - expect(screen.getByText("(opens in a new tab)")).toBeInTheDocument(); - }); -}); - -describe("HelpIcon", () => { - it("should render a help button with accessible label", () => { - renderWithProviders(); - - expect(screen.getByRole("button", { name: /help information/i })).toBeInTheDocument(); - }); - - it("should show tooltip content on hover", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.hover(screen.getByRole("button", { name: /help information/i })); - - expect(screen.getByText("Tooltip help text")).toBeInTheDocument(); - }); - - it("should hide tooltip content when not hovered", () => { - renderWithProviders(); - expect(screen.queryByText("Hidden tooltip")).not.toBeInTheDocument(); - }); - - it("should show learn more link when learnMoreHref is provided", async () => { - const user = userEvent.setup(); - renderWithProviders(); - await user.hover(screen.getByRole("button", { name: /help information/i })); - expect(screen.getByText("Learn more")).toBeInTheDocument(); - }); - - it("should use custom learn more text when provided", async () => { - const user = userEvent.setup(); - renderWithProviders( - , - ); - - await user.hover(screen.getByRole("button", { name: /help information/i })); - - const link = screen.getByRole("link", { name: /read docs/i }); - expect(link).toHaveAttribute("href", "https://docs.example.com"); - }); - - it("should not show learn more link when learnMoreHref is not provided", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.hover(screen.getByRole("button", { name: /help information/i })); - - expect(screen.queryByRole("link")).not.toBeInTheDocument(); - }); -}); - -describe("DocsMenu", () => { - const items = [ - { label: "Custom pricing", href: "https://docs.example.com/pricing" }, - { label: "Cost tracking", href: "https://docs.example.com/cost" }, - ]; - - it("should render the menu button with default text", () => { - renderWithProviders(); - - expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument(); - }); - - it("should hide menu items initially", () => { - renderWithProviders(); - expect(screen.queryByText("Custom pricing")).not.toBeInTheDocument(); - }); - - it("should show menu items when button is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /docs/i })); - - expect(screen.getByText("Custom pricing")).toBeInTheDocument(); - expect(screen.getByText("Cost tracking")).toBeInTheDocument(); - }); - - it("should close the menu when an item is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /docs/i })); - await user.click(screen.getByText("Custom pricing")); - - expect(screen.queryByText("Cost tracking")).not.toBeInTheDocument(); - }); - - it("should set aria-expanded correctly based on menu state", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - const button = screen.getByRole("button", { name: /docs/i }); - expect(button).toHaveAttribute("aria-expanded", "false"); - - await user.click(button); - expect(button).toHaveAttribute("aria-expanded", "true"); - }); - - it("should close menu when clicking outside", async () => { - const user = userEvent.setup(); - renderWithProviders( -
- - -
, - ); - await user.click(screen.getByRole("button", { name: /docs/i })); - expect(screen.getByText("Custom pricing")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /outside/i })); - expect(screen.queryByText("Custom pricing")).not.toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/HelpLink.tsx b/ui/litellm-dashboard/src/components/HelpLink.tsx deleted file mode 100644 index 35e2e5f535f..00000000000 --- a/ui/litellm-dashboard/src/components/HelpLink.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React, { useState, useRef, useEffect } from "react"; -import { ExternalLink, ChevronDown } from "lucide-react"; - -interface HelpLinkProps { - href: string; - children?: React.ReactNode; - variant?: "inline" | "subtle" | "button"; - className?: string; -} - -interface DocMenuItem { - label: string; - href: string; -} - -interface DocsMenuProps { - items: DocMenuItem[]; - children?: React.ReactNode; - className?: string; -} - -/** - * A reusable component for linking to documentation, styled similar to Linear's help links. - * - * @example - * // Inline "Learn more" style - * - * Learn more about custom pricing - * - * - * @example - * // Subtle link (just icon + text, minimal styling) - * - * View docs - * - * - * @example - * // Button style (more prominent) - * - * Custom Pricing Documentation - * - */ -export const HelpLink: React.FC = ({ - href, - children = "Learn more", - variant = "inline", - className = "", -}) => { - const baseClasses = - "inline-flex items-center gap-1.5 transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-1 rounded-sm"; - - const variantClasses = { - inline: "text-info text-sm font-medium hover:underline", - subtle: "text-muted-foreground hover:text-foreground text-xs", - button: - "text-info border border-border px-3 py-1.5 rounded-md bg-card hover:bg-accent text-sm font-medium shadow-xs", - }; - - return ( - - {children} - - ); -}; - -/** - * A minimal help icon with tooltip for inline contextual help. - * Similar to Linear's "?" icons that appear next to labels. - */ -interface HelpIconProps { - content: React.ReactNode; - learnMoreHref?: string; - learnMoreText?: string; -} - -export const HelpIcon: React.FC = ({ content, learnMoreHref, learnMoreText = "Learn more" }) => { - const [showTooltip, setShowTooltip] = React.useState(false); - - return ( -
- - {showTooltip && ( -
-
{content}
- {learnMoreHref && ( - - {learnMoreText} - - )} -
-
- )} -
- ); -}; - -/** - * A dropdown menu for multiple documentation links. - * Linear-style: Single "Docs" button that expands to show multiple relevant links. - * - * @example - * - * Docs - * - */ -export const DocsMenu: React.FC = ({ items, children = "Docs", className = "" }) => { - const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isOpen]); - - return ( -
- - - {isOpen && ( -
- {items.map((item, index) => ( - setIsOpen(false)} - > - {item.label} - - ))} -
- )} -
- ); -};