feat: add BlogDropdown component with react-query and error/retry state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-02-21 17:10:25 -08:00
parent 98da524a9f
commit e241e6fd45
2 changed files with 258 additions and 0 deletions

View file

@ -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<BlogPostsResponse> {
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<BlogPostsResponse>({
queryKey: ["blogPosts"],
queryFn: fetchBlogPosts,
staleTime: 60 * 60 * 1000, // 1 hour — matches server-side TTL
});
if (disableShowBlog) {
return null;
}
const dropdownContent = () => {
if (isError) {
return (
<div style={{ padding: "12px 16px", minWidth: 200 }}>
<Text type="danger" style={{ display: "block", marginBottom: 8 }}>
Failed to load blog posts
</Text>
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
</div>
);
}
if (!data || data.posts.length === 0) {
return (
<div style={{ padding: "12px 16px" }}>
<Text type="secondary">No posts available</Text>
</div>
);
}
return (
<div style={{ minWidth: 280, maxWidth: 360 }}>
{data.posts.map((post, index) => (
<a
key={index}
href={post.url}
target="_blank"
rel="noopener noreferrer"
style={{ display: "block", textDecoration: "none", color: "inherit" }}
>
<div
style={{ padding: "10px 16px" }}
className="hover:bg-gray-50 transition-colors cursor-pointer"
>
<div
style={{
fontWeight: 500,
fontSize: 13,
marginBottom: 2,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{post.title}
</div>
<div style={{ fontSize: 11, color: "#8c8c8c", marginBottom: 3 }}>
{formatDate(post.date)}
</div>
<div
style={{
fontSize: 12,
color: "#595959",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{post.description}
</div>
</div>
</a>
))}
</div>
);
};
return (
<Dropdown
popupRender={dropdownContent}
trigger={["click"]}
placement="bottomRight"
>
<Button
type="text"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
icon={<ReadOutlined />}
>
<Space>
Blog
{isLoading ? <LoadingOutlined /> : <DownOutlined />}
</Space>
</Button>
</Dropdown>
);
};
export default BlogDropdown;

View file

@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
describe("BlogDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the Blog button", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_POSTS,
});
render(<BlogDropdown />, { 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(<BlogDropdown />, { 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(<BlogDropdown />, { 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(<BlogDropdown />, { 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(<BlogDropdown />, { wrapper: createWrapper() });
expect(container.firstChild).toBeNull();
});
});