From e241e6fd45cf83f24c77aa34f7e757347b8b75ac Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 17:10:25 -0800 Subject: [PATCH] feat: add BlogDropdown component with react-query and error/retry state Co-Authored-By: Claude Sonnet 4.6 --- .../Navbar/BlogDropdown/BlogDropdown.tsx | 145 ++++++++++++++++++ .../__tests__/BlogDropdown.test.tsx | 113 ++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx new file mode 100644 index 00000000000..6f8b29f8805 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -0,0 +1,145 @@ +import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog"; +import { getProxyBaseUrl } from "@/components/networking"; +import { + DownOutlined, + LoadingOutlined, + ReadOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Dropdown, Space, Typography } from "antd"; +import React from "react"; + +const { Text } = Typography; + +interface BlogPost { + title: string; + description: string; + date: string; + url: string; +} + +interface BlogPostsResponse { + posts: BlogPost[]; +} + +async function fetchBlogPosts(): Promise { + const baseUrl = getProxyBaseUrl(); + const response = await fetch(`${baseUrl}/public/litellm_blog_posts`); + if (!response.ok) { + throw new Error(`Failed to fetch blog posts: ${response.statusText}`); + } + return response.json(); +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export const BlogDropdown: React.FC = () => { + const disableShowBlog = useDisableShowBlog(); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["blogPosts"], + queryFn: fetchBlogPosts, + staleTime: 60 * 60 * 1000, // 1 hour — matches server-side TTL + }); + + if (disableShowBlog) { + return null; + } + + const dropdownContent = () => { + if (isError) { + return ( +
+ + Failed to load blog posts + + +
+ ); + } + + if (!data || data.posts.length === 0) { + return ( +
+ No posts available +
+ ); + } + + return ( +
+ {data.posts.map((post, index) => ( + +
+
+ {post.title} +
+
+ {formatDate(post.date)} +
+
+ {post.description} +
+
+
+ ))} +
+ ); + }; + + return ( + + + + ); +}; + +export default BlogDropdown; diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx new file mode 100644 index 00000000000..f5a79648c77 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { BlogDropdown } from "../BlogDropdown"; +import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog"; + +// Mock hooks +vi.mock("@/app/(dashboard)/hooks/useDisableShowBlog", () => ({ + useDisableShowBlog: vi.fn(() => false), +})); + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "http://localhost:4000", +})); + +const SAMPLE_POSTS = { + posts: [ + { + title: "Test Post 1", + description: "First test post description.", + date: "2026-02-01", + url: "https://www.litellm.ai/blog/test-1", + }, + { + title: "Test Post 2", + description: "Second test post description.", + date: "2026-01-15", + url: "https://www.litellm.ai/blog/test-2", + }, + ], +}; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe("BlogDropdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the Blog button", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_POSTS, + }); + + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Blog")).toBeInTheDocument(); + }); + + it("shows posts on success", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_POSTS, + }); + + render(, { wrapper: createWrapper() }); + + // Open the dropdown + fireEvent.click(screen.getByText("Blog")); + + await waitFor(() => { + expect(screen.getByText("Test Post 1")).toBeInTheDocument(); + expect(screen.getByText("Test Post 2")).toBeInTheDocument(); + }); + }); + + it("shows error message and Retry button on fetch failure", async () => { + global.fetch = vi.fn().mockRejectedValueOnce(new Error("Network error")); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByText("Blog")); + + await waitFor(() => { + expect(screen.getByText(/Failed to load blog posts/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it("calls refetch when Retry is clicked", async () => { + global.fetch = vi + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_POSTS }); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByText("Blog")); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + + await waitFor(() => { + expect(screen.getByText("Test Post 1")).toBeInTheDocument(); + }); + }); + + it("returns null when useDisableShowBlog is true", () => { + vi.mocked(useDisableShowBlog).mockReturnValue(true); + + const { container } = render(, { wrapper: createWrapper() }); + expect(container.firstChild).toBeNull(); + }); +});