mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36902 from BerriAI/litellm_shadcn_navbar_0814
refactor(ui): migrate Navbar off antd to shadcn
This commit is contained in:
commit
5374e0b0c7
9 changed files with 459 additions and 346 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue