Merge pull request #36902 from BerriAI/litellm_shadcn_navbar_0814

refactor(ui): migrate Navbar off antd to shadcn
This commit is contained in:
yuneng-jiang 2026-08-14 09:36:38 -07:00 committed by GitHub
commit 5374e0b0c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 459 additions and 346 deletions

View file

@ -1855,39 +1855,11 @@
"count": 12
}
},
"src/components/Navbar/BlogDropdown/BlogDropdown.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Navbar/NotificationsBell/NotificationsBell.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Navbar/UserDropdown/UserDropdown.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Navbar/ViewSwitcher.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/SCIM.tsx": {
"no-restricted-imports": {
"count": 2

View file

@ -66,6 +66,27 @@ describe("BlogDropdown", () => {
expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument();
});
it("should not render menu content before the trigger is hovered", () => {
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } };
renderWithProviders(<BlogDropdown />);
expect(screen.queryByRole("link", { name: /view all posts/i })).not.toBeInTheDocument();
expect(screen.queryByText("Post One")).not.toBeInTheDocument();
});
it("should open the menu on hover", async () => {
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } };
renderWithProviders(<BlogDropdown />);
expect(screen.queryByText("Post One")).not.toBeInTheDocument();
await openDropdown();
await waitFor(() => {
expect(screen.getByText("Post One")).toBeInTheDocument();
});
});
describe("loading state", () => {
it("should show a loading spinner", async () => {
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true };
@ -74,7 +95,7 @@ describe("BlogDropdown", () => {
await openDropdown();
await waitFor(() => {
expect(document.querySelector(".anticon-loading")).toBeInTheDocument();
expect(screen.getByRole("img", { name: /loading/i })).toBeInTheDocument();
});
});
});

View file

@ -1,13 +1,17 @@
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { DownOutlined, LoadingOutlined } from "@ant-design/icons";
import { Button, Dropdown, Space, Typography } from "antd";
import type { MenuProps } from "antd";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown, LoaderCircle } from "lucide-react";
import React from "react";
const { Text, Title, Paragraph } = Typography;
function formatDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
@ -26,63 +30,70 @@ export const BlogDropdown: React.FC = () => {
return null;
}
let items: MenuProps["items"];
const renderMenuContent = () => {
if (isLoading) {
return (
<div className="flex items-center px-2 py-1.5 text-sm">
<LoaderCircle role="img" aria-label="loading" className="size-4 animate-spin" />
</div>
);
}
if (isLoading) {
items = [{ key: "loading", label: <LoadingOutlined />, disabled: true }];
} else if (isError) {
items = [
{
key: "error",
label: (
<Space>
<Text type="danger">Failed to load posts</Text>
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
</Space>
),
disabled: true,
},
];
} else if (!data || data.posts.length === 0) {
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
} else {
items = [
...data.posts.slice(0, 5).map((post: BlogPost) => ({
key: post.url,
label: (
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
<Title level={5} style={{ marginBottom: 2 }}>
{post.title}
</Title>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDate(post.date)}
</Text>
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
</a>
),
})),
{ type: "divider" as const },
{
key: "view-all",
label: (
if (isError) {
return (
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
<span className="text-destructive">Failed to load posts</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
);
}
if (!data || data.posts.length === 0) {
return <div className="px-2 py-1.5 text-sm text-muted-foreground">No posts available</div>;
}
return (
<>
{data.posts.slice(0, 5).map((post: BlogPost) => (
<DropdownMenuItem key={post.url}>
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
<h5 className="text-sm font-semibold" style={{ marginBottom: 2 }}>
{post.title}
</h5>
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
{formatDate(post.date)}
</span>
<p className="line-clamp-2">{post.description}</p>
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem>
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
View all posts
</a>
),
},
];
}
</DropdownMenuItem>
</>
);
};
// Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment.
return (
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
<Button type="text" className={`${NAV_PRODUCT_LINK_CLASS} border-0! bg-transparent!`}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
openOnHover
closeDelay={100}
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0! bg-transparent!`} />}
>
Blog
<DownOutlined className="text-[10px] text-gray-500" aria-hidden />
</Button>
</Dropdown>
<ChevronDown className="size-2.5 text-gray-500" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-auto">
{renderMenuContent()}
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -1,6 +1,6 @@
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { GithubOutlined, SlackOutlined } from "@ant-design/icons";
import { Tooltip } from "antd";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Github, Slack } from "lucide-react";
import React from "react";
const iconBtnClass =
@ -18,28 +18,40 @@ export const CommunityEngagementButtons: React.FC = () => {
className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0"
aria-label="Community links"
>
<Tooltip title="LiteLLM Slack community">
<a
href="https://www.litellm.ai/support"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="Join Slack"
>
<SlackOutlined className="text-lg" />
</a>
</Tooltip>
<Tooltip title="LiteLLM on GitHub">
<a
href="https://github.com/BerriAI/litellm"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="LiteLLM on GitHub"
>
<GithubOutlined className="text-lg" />
</a>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<a
href="https://www.litellm.ai/support"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="Join Slack"
/>
}
>
<Slack className="size-[18px]" />
</TooltipTrigger>
<TooltipContent>LiteLLM Slack community</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<a
href="https://github.com/BerriAI/litellm"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="LiteLLM on GitHub"
/>
}
>
<Github className="size-[18px]" />
</TooltipTrigger>
<TooltipContent>LiteLLM on GitHub</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

View file

@ -5,8 +5,11 @@ import {
useHideAutoRouterAnnouncement,
} from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement";
import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
import { BellOutlined } from "@ant-design/icons";
import { Badge, Button, Popover, Typography } from "antd";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/cva.config";
import { Bell } from "lucide-react";
import React, { useState } from "react";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
@ -24,18 +27,21 @@ export const NotificationsBell: React.FC = () => {
const content = (
<div className="max-w-[280px]">
<Typography.Title level={5} className="mt-0! mb-2!">
LiteLLM Auto Router
</Typography.Title>
<Typography.Paragraph type="secondary" className="mb-3! text-sm leading-snug">
<PopoverTitle className="mt-0! mb-2!">LiteLLM Auto Router</PopoverTitle>
<PopoverDescription className="mb-3! text-sm leading-snug">
Route every request to the cheapest model that can handle it, no prompt changes needed.
</Typography.Paragraph>
</PopoverDescription>
<div className="flex flex-wrap items-center gap-2">
<Button type="primary" size="small" href={AUTO_ROUTER_DOCS_URL} target="_blank" rel="noopener noreferrer">
<a
className={cn(buttonVariants({ size: "sm" }))}
href={AUTO_ROUTER_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
Read the docs
</Button>
</a>
{hasUnread ? (
<Button type="link" size="small" className="px-1!" onClick={markDismissed}>
<Button variant="link" size="sm" className="px-1!" onClick={markDismissed}>
Mark as read
</Button>
) : null}
@ -44,16 +50,17 @@ export const NotificationsBell: React.FC = () => {
);
return (
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
<Button
type="text"
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className="flex! h-9! w-9! items-center justify-center rounded-md! text-gray-600 transition-colors hover:bg-gray-100! hover:text-gray-900!"
aria-label="Notifications"
>
<Badge dot={hasUnread} color="#1677ff" size="small" offset={[8, 2]}>
<BellOutlined className="text-base" aria-hidden />
</Badge>
</Button>
<span className="relative inline-flex">
<Bell className="size-4" aria-hidden />
{hasUnread ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
</span>
</PopoverTrigger>
<PopoverContent align="end">{content}</PopoverContent>
</Popover>
);
};

View file

@ -9,23 +9,17 @@ import {
setLocalStorageItem,
} from "@/utils/localStorageUtils";
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
import {
CrownOutlined,
DownOutlined,
LogoutOutlined,
MailOutlined,
SafetyOutlined,
UserOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
import { ChevronsUpDown } from "lucide-react";
import { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import CopyButton from "@/components/shared/CopyButton";
import { cn } from "@/lib/cva.config";
import React, { useEffect, useState } from "react";
const { Text } = Typography;
function hueFromString(seed: string): number {
let h = 0;
for (let i = 0; i < seed.length; i += 1) {
@ -80,60 +74,57 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
setDisableShowNewBadge(storedValue === "true");
}, []);
const userItems: MenuProps["items"] = [
{
key: "logout",
label: (
<Space>
<LogoutOutlined />
Logout
</Space>
),
onClick: onLogout,
},
];
const renderUserInfoSection = () => (
<Space direction="vertical" size="small" style={{ width: "100%", padding: "12px" }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<MailOutlined />
<Text type="secondary">{userEmail || "-"}</Text>
</Space>
<div className="flex w-full flex-col gap-2 p-3 text-sm">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Mail className="size-4" />
<span className="text-muted-foreground">{userEmail || "-"}</span>
</div>
{premiumUser ? (
<Tag icon={<CrownOutlined />} color="gold">
<Badge>
<Crown className="size-3" />
Premium
</Tag>
</Badge>
) : (
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
<Tag icon={<CrownOutlined />}>Standard</Tag>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Badge variant="outline" />}>
<Crown className="size-3" />
Standard
</TooltipTrigger>
<TooltipContent side="left">Upgrade to Premium for advanced features</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Space>
<Divider style={{ margin: "8px 0" }} />
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<UserOutlined />
<Text type="secondary">User ID</Text>
</Space>
<Text copyable ellipsis style={{ maxWidth: "150px" }} title={userId || "-"}>
{userId || "-"}
</Text>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<SafetyOutlined />
<Text type="secondary">Role</Text>
</Space>
<Text>{userRole}</Text>
</Space>
<Divider style={{ margin: "8px 0" }} />
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide New Feature Indicators</Text>
</div>
<Separator className="my-2" />
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<User className="size-4" />
<span className="text-muted-foreground">User ID</span>
</div>
<div className="flex items-center gap-1">
<span className="max-w-[150px] truncate" title={userId || "-"}>
{userId || "-"}
</span>
<CopyButton value={userId} label="Copy User ID" />
</div>
</div>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ShieldCheck className="size-4" />
<span className="text-muted-foreground">Role</span>
</div>
<span>{userRole}</span>
</div>
<Separator className="my-2" />
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide New Feature Indicators</span>
<Switch
size="small"
size="sm"
checked={disableShowNewBadge}
onChange={(checked) => {
onCheckedChange={(checked) => {
setDisableShowNewBadge(checked);
if (checked) {
setLocalStorageItem("disableShowNewBadge", "true");
@ -145,13 +136,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide new feature indicators"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide All Prompts</Text>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide All Prompts</span>
<Switch
size="small"
size="sm"
checked={disableShowPrompts}
onChange={(checked) => {
onCheckedChange={(checked) => {
if (checked) {
setLocalStorageItem("disableShowPrompts", "true");
emitLocalStorageChange("disableShowPrompts");
@ -162,13 +153,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide all prompts"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Blog Posts</Text>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide Blog Posts</span>
<Switch
size="small"
size="sm"
checked={disableBlogPosts}
onChange={(checked) => {
onCheckedChange={(checked) => {
if (checked) {
setLocalStorageItem("disableBlogPosts", "true");
emitLocalStorageChange("disableBlogPosts");
@ -179,13 +170,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide blog posts"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Bouncing Icon</Text>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide Bouncing Icon</span>
<Switch
size="small"
size="sm"
checked={disableBouncingIcon}
onChange={(checked) => {
onCheckedChange={(checked) => {
if (checked) {
setLocalStorageItem("disableBouncingIcon", "true");
emitLocalStorageChange("disableBouncingIcon");
@ -196,8 +187,8 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide bouncing icon"
/>
</Space>
</Space>
</div>
</div>
);
const seed = userEmail || userId || "user";
@ -206,30 +197,21 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
const displayName = navAccountDisplayName(userEmail, userId);
return (
<Dropdown
trigger={["click"]}
placement={variant === "sidebar" ? "topLeft" : "bottomRight"}
menu={{ items: userItems }}
popupRender={(menu) => (
<div className="rounded-lg bg-white shadow-lg" data-testid="user-dropdown-panel">
{renderUserInfoSection()}
<Divider style={{ margin: 0 }} />
{React.cloneElement(menu as React.ReactElement, {
style: { boxShadow: "none" },
})}
</div>
)}
>
<Popover>
{variant === "sidebar" ? (
<button
type="button"
className={cn(
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
)}
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="menu"
title={collapsed ? displayName : undefined}
<PopoverTrigger
render={
<button
type="button"
className={cn(
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
)}
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="dialog"
title={collapsed ? displayName : undefined}
/>
}
>
<Avatar className="size-[30px] shadow-inner ring-1 ring-black/5" aria-hidden>
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
@ -245,13 +227,17 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
<ChevronsUpDown size={16} strokeWidth={1.75} className="shrink-0 text-muted-foreground" aria-hidden />
</>
)}
</button>
</PopoverTrigger>
) : (
<Button
type="text"
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="menu"
<PopoverTrigger
render={
<button
type="button"
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="dialog"
/>
}
>
<Avatar className="shadow-inner ring-1 ring-black/5" aria-hidden>
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
@ -261,10 +247,27 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
<span className="hidden min-w-0 truncate text-left text-sm font-medium leading-none text-gray-900 md:inline">
{displayName}
</span>
<DownOutlined className="hidden shrink-0 text-[10px] text-gray-400 md:inline" aria-hidden />
</Button>
<ChevronDown className="hidden size-2.5 shrink-0 text-gray-400 md:inline" aria-hidden />
</PopoverTrigger>
)}
</Dropdown>
<PopoverContent
align={variant === "sidebar" ? "start" : "end"}
side={variant === "sidebar" ? "top" : "bottom"}
className="w-auto gap-0 rounded-lg bg-white p-1 shadow-lg"
data-testid="user-dropdown-panel"
>
{renderUserInfoSection()}
<Separator />
<button
type="button"
onClick={onLogout}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
>
<LogOut className="size-4" />
Logout
</button>
</PopoverContent>
</Popover>
);
};

View file

@ -1,9 +1,12 @@
import React from "react";
import { usePathname } from "next/navigation";
import { Dropdown } from "antd";
import { AppstoreOutlined, CheckOutlined } from "@ant-design/icons";
import { ChevronsUpDown } from "lucide-react";
import type { MenuProps } from "antd";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { migratedHref } from "@/utils/migratedPages";
@ -11,6 +14,13 @@ import { migratedHref } from "@/utils/migratedPages";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
interface ViewSwitcherItem {
key: string;
label: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
}
export default function ViewSwitcher() {
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
@ -29,15 +39,25 @@ export default function ViewSwitcher() {
...plugins.map((p) => ({ key: p.name, label: p.display_name })),
];
const chatItem = chatEnabled
const selectMode = (key: string) => {
setMode(key);
// The chat route lives outside the dashboard SPA shell that reacts to `mode`,
// so switching modes from there needs a real navigation, not just state.
if (isChatRoute) {
window.location.assign(migratedHref(""));
}
};
const chatItem: ViewSwitcherItem = chatEnabled
? {
key: CHAT,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">Chat</span>
{isChatRoute && <CheckOutlined className="text-blue-600" />}
{isChatRoute && <Check className="size-4 text-blue-600" />}
</div>
),
onClick: () => window.location.assign(migratedHref(CHAT)),
}
: {
key: CHAT,
@ -52,44 +72,43 @@ export default function ViewSwitcher() {
),
};
const items: MenuProps["items"] = [
const items: ViewSwitcherItem[] = [
...modeEntries.map((e) => ({
key: e.key,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">{e.label}</span>
{!isChatRoute && e.key === mode && <CheckOutlined className="text-blue-600" />}
{!isChatRoute && e.key === mode && <Check className="size-4 text-blue-600" />}
</div>
),
onClick: () => selectMode(e.key),
})),
chatItem,
];
const onClick: MenuProps["onClick"] = ({ key }) => {
if (key === CHAT) {
window.location.assign(migratedHref(CHAT));
return;
}
setMode(key);
// The chat route lives outside the dashboard SPA shell that reacts to `mode`,
// so switching modes from there needs a real navigation, not just state.
if (isChatRoute) {
window.location.assign(migratedHref(""));
}
};
return (
<Dropdown menu={{ items, onClick, selectedKeys: [isChatRoute ? CHAT : mode] }} trigger={["click"]}>
<button
type="button"
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
/>
}
>
<span className="flex size-5 flex-none items-center justify-center rounded bg-muted text-muted-foreground">
<AppstoreOutlined className="text-[13px]" />
<LayoutGrid className="size-[13px]" />
</span>
<span className="truncate">{activeLabel}</span>
<ChevronsUpDown className="size-3.5 flex-none text-muted-foreground" />
</button>
</Dropdown>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto">
{items.map((item) => (
<DropdownMenuItem key={item.key} disabled={item.disabled} onClick={item.onClick}>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,32 +1,21 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
// Mock the useWorker hook
const mockUseWorker = vi.fn();
vi.mock("@/hooks/useWorker", () => ({
useWorker: () => mockUseWorker(),
}));
// Mock antd Select
vi.mock("antd", () => ({
Select: ({ value, options, onChange, style, disabled, ...props }: any) => (
<select data-testid="worker-select" value={value} style={style} onChange={(e) => onChange?.(e.target.value)}>
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
),
}));
// Mock icon
vi.mock("@ant-design/icons", () => ({
CloudServerOutlined: () => <span data-testid="cloud-icon" />,
}));
import WorkerDropdown from "./WorkerDropdown";
async function openWorkerList(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("combobox"));
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true");
});
}
describe("WorkerDropdown", () => {
const mockOnWorkerSwitch = vi.fn();
const workers = [
@ -61,31 +50,7 @@ describe("WorkerDropdown", () => {
expect(container).toBeEmptyDOMElement();
});
it("renders the select when isControlPlane and selectedWorker exist", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(screen.getByTestId("worker-select")).toBeInTheDocument();
});
it("renders all worker options", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(screen.getByText("Worker 1")).toBeInTheDocument();
expect(screen.getByText("Worker 2")).toBeInTheDocument();
expect(screen.getByText("Worker 3")).toBeInTheDocument();
});
it("sets current worker as selected value", () => {
it("renders a collapsed worker combobox when isControlPlane and selectedWorker exist", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[1],
@ -93,37 +58,109 @@ describe("WorkerDropdown", () => {
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
const select = screen.getByTestId("worker-select") as HTMLSelectElement;
expect(select.value).toBe("w2");
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
it("disables the currently selected worker in options", () => {
it("reveals every worker only once the combobox is opened", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
selectedWorker: workers[1],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
const options = screen.getAllByRole("option");
const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1");
expect(selectedOption).toBeDisabled();
});
it("calls onWorkerSwitch when selection changes", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
const select = screen.getByTestId("worker-select");
const { default: userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
await user.selectOptions(select, "w2");
expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2");
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(screen.queryAllByRole("option")).toHaveLength(0);
expect(screen.queryByText("Worker 1")).not.toBeInTheDocument();
expect(screen.queryByText("Worker 3")).not.toBeInTheDocument();
await openWorkerList(user);
await waitFor(() => {
expect(screen.getByText("Worker 1")).toBeInTheDocument();
});
expect(screen.getAllByText("Worker 2").length).toBeGreaterThan(0);
expect(screen.getByText("Worker 3")).toBeInTheDocument();
});
it("marks exactly one option as selected, the current worker", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[1],
workers,
});
const user = userEvent.setup();
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
await openWorkerList(user);
await waitFor(() => {
const selected = screen.getAllByRole("option").filter((o) => o.getAttribute("aria-selected") === "true");
expect(selected).toHaveLength(1);
expect(selected[0]).toHaveAccessibleName("Worker 2");
});
});
it("calls onWorkerSwitch with the id of the worker that was picked", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[1],
workers,
});
const user = userEvent.setup();
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
await openWorkerList(user);
await waitFor(() => {
expect(screen.getByText("Worker 3")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Worker 3"));
expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w3");
});
it("does not call onWorkerSwitch when the already-current worker is picked", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[1],
workers,
});
const user = userEvent.setup();
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
await openWorkerList(user);
await waitFor(() => {
expect(screen.getByText("Worker 3")).toBeInTheDocument();
});
for (const currentWorkerNode of screen.getAllByText("Worker 2")) {
fireEvent.click(currentWorkerNode);
}
expect(mockOnWorkerSwitch).not.toHaveBeenCalled();
});
it("filters the worker options by the typed search text", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[1],
workers,
});
const user = userEvent.setup();
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
await openWorkerList(user);
await waitFor(() => {
expect(screen.getByText("Worker 1")).toBeInTheDocument();
});
await user.clear(screen.getByRole("combobox"));
await user.type(screen.getByRole("combobox"), "worker 3");
await waitFor(() => {
expect(screen.queryByText("Worker 1")).not.toBeInTheDocument();
});
expect(screen.getByText("Worker 3")).toBeInTheDocument();
});
});

View file

@ -1,35 +1,66 @@
"use client";
import React from "react";
import { Select } from "antd";
import { CloudServerOutlined } from "@ant-design/icons";
import { Server } from "lucide-react";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { InputGroupAddon } from "@/components/ui/input-group";
import { useWorker } from "@/hooks/useWorker";
interface WorkerDropdownProps {
onWorkerSwitch: (workerId: string) => void;
}
interface WorkerOption {
label: string;
value: string;
disabled: boolean;
}
const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch }) => {
const { isControlPlane, selectedWorker, workers } = useWorker();
if (!isControlPlane || !selectedWorker) return null;
const options: WorkerOption[] = workers.map((w) => ({
label: w.name,
value: w.worker_id,
disabled: w.worker_id === selectedWorker.worker_id,
}));
return (
<Select
showSearch
filterOption={(input, option) => ((option?.label as string) ?? "").toLowerCase().includes(input.toLowerCase())}
value={selectedWorker.worker_id}
style={{ minWidth: 180 }}
suffixIcon={<CloudServerOutlined />}
options={workers.map((w) => ({
label: w.name,
value: w.worker_id,
disabled: w.worker_id === selectedWorker.worker_id,
}))}
onChange={(newWorkerId) => {
onWorkerSwitch(newWorkerId);
<Combobox
items={options}
value={options.find((option) => option.value === selectedWorker.worker_id) ?? null}
itemToStringLabel={(option: WorkerOption) => option.label}
onValueChange={(option: WorkerOption | null) => {
if (option) {
onWorkerSwitch(option.value);
}
}}
/>
>
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
<InputGroupAddon align="inline-start">
<Server className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent>
<ComboboxEmpty>No matching workers</ComboboxEmpty>
<ComboboxList>
{(option: WorkerOption) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};