mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
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:
parent
98da524a9f
commit
e241e6fd45
2 changed files with 258 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue