diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts new file mode 100644 index 0000000000..81d55e8765 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts @@ -0,0 +1,32 @@ +import { getProxyBaseUrl } from "@/components/networking"; +import { useQuery } from "@tanstack/react-query"; + +export interface BlogPost { + title: string; + description: string; + date: string; + url: string; +} + +export 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(); +} + +export const useBlogPosts = () => { + return useQuery({ + queryKey: ["blogPosts"], + queryFn: fetchBlogPosts, + staleTime: 60 * 60 * 1000, + retry: 1, + retryDelay: 0, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts new file mode 100644 index 0000000000..a7b37b78d4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts @@ -0,0 +1,33 @@ +import { LOCAL_STORAGE_EVENT, getLocalStorageItem } from "@/utils/localStorageUtils"; +import { useSyncExternalStore } from "react"; + +function subscribe(callback: () => void) { + const onStorage = (e: StorageEvent) => { + if (e.key === "disableBlogPosts") { + callback(); + } + }; + + const onCustom = (e: Event) => { + const { key } = (e as CustomEvent).detail; + if (key === "disableBlogPosts") { + callback(); + } + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(LOCAL_STORAGE_EVENT, onCustom); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom); + }; +} + +function getSnapshot() { + return getLocalStorageItem("disableBlogPosts") === "true"; +} + +export function useDisableBlogPosts() { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts deleted file mode 100644 index cbe1bdc125..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useUISettings } from "./uiSettings/useUISettings"; - -export function useDisableShowBlog(): boolean { - const { data } = useUISettings(); - return (data?.values?.disable_show_blog as boolean) ?? false; -} diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx index fa1baa311d..05cb4f62ab 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -1,140 +1,230 @@ -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 userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; 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", - }, - ], +let mockDisableBlogPosts = false; +let mockRefetch = vi.fn(); +let mockUseBlogPostsResult: { + data: { posts: { title: string; date: string; description: string; url: string }[] } | null | undefined; + isLoading: boolean; + isError: boolean; + refetch: () => void; +} = { + data: undefined, + isLoading: false, + isError: false, + refetch: mockRefetch, }; -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, retryDelay: 0 } }, - }); - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); +vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({ + useDisableBlogPosts: () => mockDisableBlogPosts, +})); + +vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({ + useBlogPosts: () => mockUseBlogPostsResult, +})); + +const MOCK_POSTS = [ + { title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }, + { title: "Post Two", date: "2026-02-02", description: "Description two", url: "https://example.com/2" }, + { title: "Post Three", date: "2026-02-03", description: "Description three", url: "https://example.com/3" }, + { title: "Post Four", date: "2026-02-04", description: "Description four", url: "https://example.com/4" }, + { title: "Post Five", date: "2026-02-05", description: "Description five", url: "https://example.com/5" }, + { title: "Post Six", date: "2026-02-06", description: "Description six", url: "https://example.com/6" }, +]; + +async function openDropdown() { + const user = userEvent.setup(); + await user.hover(screen.getByRole("button", { name: /blog/i })); } describe("BlogDropdown", () => { beforeEach(() => { vi.clearAllMocks(); + mockDisableBlogPosts = false; + mockRefetch = vi.fn(); + mockUseBlogPostsResult = { + data: undefined, + isLoading: false, + isError: false, + refetch: mockRefetch, + }; }); - 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(); + describe("when blog posts are disabled", () => { + it("renders nothing", () => { + mockDisableBlogPosts = true; + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); }); }); - it("shows at most 5 posts", async () => { - const manyPosts = Array.from({ length: 8 }, (_, i) => ({ - title: `Post ${i + 1}`, - description: `Description ${i + 1}`, - date: "2026-02-01", - url: `https://www.litellm.ai/blog/post-${i + 1}`, - })); - - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ posts: manyPosts }), + describe("when blog posts are enabled", () => { + it("renders the Blog trigger button", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); }); - render(, { wrapper: createWrapper() }); - fireEvent.click(screen.getByText("Blog")); + describe("loading state", () => { + it("shows a loading spinner", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true }; + renderWithProviders(); - await waitFor(() => { - expect(screen.getByText("Post 1")).toBeInTheDocument(); - expect(screen.getByText("Post 5")).toBeInTheDocument(); - expect(screen.queryByText("Post 6")).not.toBeInTheDocument(); - }); - }); + await openDropdown(); - it("shows error message and Retry button on fetch failure", async () => { - global.fetch = vi - .fn() - .mockRejectedValueOnce(new Error("Network error")) - .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")) - .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(); + await waitFor(() => { + expect(document.querySelector(".anticon-loading")).toBeInTheDocument(); + }); + }); }); - fireEvent.click(screen.getByRole("button", { name: /retry/i })); + describe("error state", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isError: true }; + }); - await waitFor(() => { - expect(screen.getByText("Test Post 1")).toBeInTheDocument(); + it("shows an error message", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Failed to load posts")).toBeInTheDocument(); + }); + }); + + it("shows a Retry button", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it("calls refetch when Retry is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByRole("button", { name: /blog/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /retry/i })); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); }); - }); - it("returns null when useDisableShowBlog is true", () => { - vi.mocked(useDisableShowBlog).mockReturnValue(true); + describe("empty state", () => { + it("shows 'No posts available' when data is null", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: null }; + renderWithProviders(); - const { container } = render(, { wrapper: createWrapper() }); - expect(container.firstChild).toBeNull(); + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); + + it("shows 'No posts available' when posts array is empty", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [] } }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); + }); + + describe("with posts", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 3) } }; + }); + + it("renders post titles", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + expect(screen.getByText("Post Two")).toBeInTheDocument(); + expect(screen.getByText("Post Three")).toBeInTheDocument(); + }); + }); + + it("renders post descriptions", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Description one")).toBeInTheDocument(); + }); + }); + + it("renders post links with correct attributes", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + const link = screen.getByRole("link", { name: /post one/i }); + expect(link).toHaveAttribute("href", "https://example.com/1"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + it("renders formatted post dates", async () => { + mockUseBlogPostsResult = { + ...mockUseBlogPostsResult, + data: { posts: [{ title: "Date Post", date: "2026-02-15", description: "Desc", url: "https://example.com" }] }, + }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Feb 15, 2026")).toBeInTheDocument(); + }); + }); + + it("renders the 'View all posts' link", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + const viewAllLink = screen.getByRole("link", { name: /view all posts/i }); + expect(viewAllLink).toHaveAttribute("href", "https://docs.litellm.ai/blog"); + expect(viewAllLink).toHaveAttribute("target", "_blank"); + expect(viewAllLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + }); + + describe("post limit", () => { + it("renders at most 5 posts when more than 5 are provided", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS } }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + expect(screen.getByText("Post Five")).toBeInTheDocument(); + expect(screen.queryByText("Post Six")).not.toBeInTheDocument(); + }); + }); + }); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index 20cfe99ae4..ddb2a33cda 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -1,35 +1,11 @@ -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 { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { LoadingOutlined } from "@ant-design/icons"; import { Button, Dropdown, Space, Typography } from "antd"; +import type { MenuProps } 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(); -} +const { Text, Title, Paragraph } = Typography; function formatDate(dateStr: string): string { const date = new Date(dateStr + "T00:00:00"); @@ -41,106 +17,66 @@ function formatDate(dateStr: string): string { } export const BlogDropdown: React.FC = () => { - const disableShowBlog = useDisableShowBlog(); + const disableBlogPosts = useDisableBlogPosts(); - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ["blogPosts"], - queryFn: fetchBlogPosts, - staleTime: 60 * 60 * 1000, // 1 hour — matches server-side TTL - retry: 1, - retryDelay: 0, - enabled: !disableShowBlog, - }); + const { data, isLoading, isError, refetch } = useBlogPosts(); - if (disableShowBlog) { + if (disableBlogPosts) { return null; } - const dropdownContent = () => { - if (isError) { - return ( -
- - Failed to load blog posts - - -
- ); - } + let items: MenuProps["items"]; - if (!data || data.posts.length === 0) { - return ( -
- No posts available -
- ); - } - - return ( - - ); - }; + ), + })), + { type: "divider" as const }, + { + key: "view-all", + label: ( + + View all posts + + ), + }, + ]; + } return ( - - + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 90e02ae447..2bef9a8077 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -1,4 +1,5 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; import { @@ -29,6 +30,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); + const disableBlogPosts = useDisableBlogPosts(); const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); useEffect(() => { @@ -148,6 +150,23 @@ const UserDropdown: React.FC = ({ onLogout }) => { aria-label="Toggle hide usage indicator" /> + + Hide Blog Posts + { + if (checked) { + setLocalStorageItem("disableBlogPosts", "true"); + emitLocalStorageChange("disableBlogPosts"); + } else { + removeLocalStorageItem("disableBlogPosts"); + emitLocalStorageChange("disableBlogPosts"); + } + }} + aria-label="Toggle hide blog posts" + /> + ); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index fe6fbb4c3a..861fe05464 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -3,13 +3,8 @@ import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; -import { - MenuFoldOutlined, - MenuUnfoldOutlined, - MoonOutlined, - SunOutlined, -} from "@ant-design/icons"; -import { Switch, Tag } from "antd"; +import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons"; +import { Button, Switch, Tag } from "antd"; import Link from "next/link"; import React, { useEffect, useState } from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; @@ -43,7 +38,7 @@ const Navbar: React.FC = ({ sidebarCollapsed = false, onToggleSidebar, isDarkMode, - toggleDarkMode + toggleDarkMode, }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); @@ -111,7 +106,7 @@ const Navbar: React.FC = ({ style={{ animationDuration: "2s" }} title="Thanks for using LiteLLM!" > - ❄️ + 🌑 = ({ {/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below. Do not set this to true by default until all components are confirmed to support dark mode styles. */} - {false && } - unCheckedChildren={} - />} - + {false && ( + } + unCheckedChildren={} + /> + )} + - {!isPublicPage && ( - - )} + {!isPublicPage && }