mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(ui): migrate common_components MemberTable + RateLimitTypeFormItem to shadcn
Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
bf0870758f
commit
ce404da62a
6 changed files with 177 additions and 158 deletions
|
|
@ -1,8 +1,14 @@
|
|||
import { Member } from "@/components/networking";
|
||||
import { Table } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -13,6 +19,12 @@ import { Crown, Info, User, UserPlus } from "lucide-react";
|
|||
import React from "react";
|
||||
import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
|
||||
export interface MemberTableExtraColumn {
|
||||
title: React.ReactNode;
|
||||
key: string;
|
||||
render: (_: unknown, record: Member) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface MemberTableProps {
|
||||
members: Member[];
|
||||
canEdit: boolean;
|
||||
|
|
@ -21,7 +33,7 @@ export interface MemberTableProps {
|
|||
onAddMember?: () => void;
|
||||
roleColumnTitle?: string;
|
||||
roleTooltip?: string;
|
||||
extraColumns?: ColumnsType<Member>;
|
||||
extraColumns?: MemberTableExtraColumn[];
|
||||
showDeleteForMember?: (member: Member) => boolean;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
|
@ -38,100 +50,115 @@ export default function MemberTable({
|
|||
showDeleteForMember,
|
||||
emptyText,
|
||||
}: MemberTableProps) {
|
||||
const baseColumns: ColumnsType<Member> = [
|
||||
{
|
||||
title: "User Email",
|
||||
dataIndex: "user_email",
|
||||
key: "user_email",
|
||||
render: (email: string | null) => <span>{email || "-"}</span>,
|
||||
},
|
||||
{
|
||||
title: "User ID",
|
||||
dataIndex: "user_id",
|
||||
key: "user_id",
|
||||
render: (userId: string | null) =>
|
||||
userId === "default_user_id" ? (
|
||||
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||
Default Proxy Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<span>{userId || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: roleTooltip ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{roleColumnTitle}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{roleTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : (
|
||||
roleColumnTitle
|
||||
),
|
||||
dataIndex: "role",
|
||||
key: "role",
|
||||
render: (role: string) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{role?.toLowerCase() === "admin" ||
|
||||
role?.toLowerCase() === "org_admin" ? (
|
||||
<Crown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<User className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="capitalize">{role || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...extraColumns,
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
fixed: "right" as const,
|
||||
width: 120,
|
||||
render: (_: unknown, record: Member) =>
|
||||
canEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
dataTestId="edit-member"
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
{(!showDeleteForMember || showDeleteForMember(record)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
dataTestId="delete-member"
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
const rowKey = (record: Member) =>
|
||||
record.user_id ?? record.user_email ?? JSON.stringify(record);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<span className="inline-flex text-sm text-foreground/80">
|
||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Table
|
||||
columns={baseColumns}
|
||||
dataSource={members}
|
||||
rowKey={(record) =>
|
||||
record.user_id ?? record.user_email ?? JSON.stringify(record)
|
||||
}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: "max-content" }}
|
||||
locale={emptyText ? { emptyText } : undefined}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User Email</TableHead>
|
||||
<TableHead>User ID</TableHead>
|
||||
<TableHead>
|
||||
{roleTooltip ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{roleColumnTitle}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{roleTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : (
|
||||
roleColumnTitle
|
||||
)}
|
||||
</TableHead>
|
||||
{extraColumns.map((col) => (
|
||||
<TableHead key={col.key}>{col.title}</TableHead>
|
||||
))}
|
||||
{canEdit && (
|
||||
<TableHead className="w-[120px] text-right">Actions</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
3 + extraColumns.length + (canEdit ? 1 : 0)
|
||||
}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
{emptyText || "No members"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((record) => (
|
||||
<TableRow key={rowKey(record)}>
|
||||
<TableCell>
|
||||
<span>{record.user_email || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{record.user_id === "default_user_id" ? (
|
||||
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||
Default Proxy Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<span>{record.user_id || "-"}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{record.role?.toLowerCase() === "admin" ||
|
||||
record.role?.toLowerCase() === "org_admin" ? (
|
||||
<Crown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<User className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="capitalize">{record.role || "-"}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{extraColumns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
{col.render(undefined, record)}
|
||||
</TableCell>
|
||||
))}
|
||||
{canEdit && (
|
||||
<TableCell className="w-[120px]">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
dataTestId="edit-member"
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
{(!showDeleteForMember ||
|
||||
showDeleteForMember(record)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
dataTestId="delete-member"
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{onAddMember && canEdit && (
|
||||
<div>
|
||||
<Button onClick={onAddMember}>
|
||||
|
|
|
|||
|
|
@ -1,66 +1,53 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { Form } from "antd";
|
||||
import React from "react";
|
||||
import { RateLimitTypeFormItem } from "./RateLimitTypeFormItem";
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<Form>{children}</Form>
|
||||
);
|
||||
|
||||
describe("RateLimitTypeFormItem", () => {
|
||||
it("should render", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
|
||||
</Wrapper>,
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />,
|
||||
);
|
||||
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display TPM label for tpm type", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
|
||||
</Wrapper>,
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />,
|
||||
);
|
||||
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display RPM label for rpm type", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="rpm" name="rpm_type" />
|
||||
</Wrapper>,
|
||||
<RateLimitTypeFormItem type="rpm" name="rpm_type" />,
|
||||
);
|
||||
expect(screen.getByText(/RPM Rate Limit Type/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a combobox trigger with a selectable value", () => {
|
||||
it("should render a combobox trigger", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
|
||||
</Wrapper>,
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />,
|
||||
);
|
||||
// The shadcn Select trigger renders the default value label ('Default')
|
||||
// instead of the placeholder since defaultValue="default" is set for
|
||||
// detailed-description mode.
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a combobox that can receive onChange", () => {
|
||||
/**
|
||||
* Radix Select + JSDOM doesn't support pointer-capture which breaks
|
||||
* user.click() on the trigger. The old test drilled into antd's
|
||||
* rendered options; the new shadcn version renders options in a
|
||||
* portal with pointer-event semantics that JSDOM can't exercise.
|
||||
* Radix Select + JSDOM doesn't support pointer-capture well. The old
|
||||
* test drilled into antd's rendered options; the new shadcn version
|
||||
* renders options in a portal with pointer-event semantics that JSDOM
|
||||
* can only partially exercise.
|
||||
*
|
||||
* We keep onChange wiring covered by this simpler structural check;
|
||||
* full select-interaction coverage lives in Playwright.
|
||||
*/
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" onChange={() => {}} />
|
||||
</Wrapper>,
|
||||
<RateLimitTypeFormItem
|
||||
type="tpm"
|
||||
name="tpm_type"
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Form } from "antd";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -26,11 +26,15 @@ interface RateLimitTypeFormItemProps {
|
|||
className?: string;
|
||||
/** Initial value for the field */
|
||||
initialValue?: string | null;
|
||||
/** Form instance for setting field values */
|
||||
/** Antd form instance (optional; for setFieldValue compatibility with
|
||||
* call sites that still use antd forms). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form?: any;
|
||||
/** Custom onChange handler */
|
||||
onChange?: (value: string) => void;
|
||||
/** Controlled value (optional). When used with react-hook-form, pass
|
||||
* `value` and `onChange` via a Controller render prop. */
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
|
||||
|
|
@ -41,47 +45,48 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
|
|||
initialValue = null,
|
||||
form,
|
||||
onChange,
|
||||
value,
|
||||
}) => {
|
||||
const limitTypeUpper = type.toUpperCase();
|
||||
const limitTypeLower = type.toLowerCase();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (form) {
|
||||
form.setFieldValue(name, value);
|
||||
const [internalValue, setInternalValue] = React.useState<string | undefined>(
|
||||
value ?? initialValue ?? undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
setInternalValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setInternalValue(next);
|
||||
if (form && typeof form.setFieldValue === "function") {
|
||||
form.setFieldValue(name, next);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
const tooltipTitle = `Select 'guaranteed_throughput' to prevent overallocating ${limitTypeUpper} limit when the key belongs to a Team with specific ${limitTypeUpper} limits.`;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
{limitTypeUpper} Rate Limit Type{" "}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-1 h-3 w-3 inline" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{tooltipTitle}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
}
|
||||
name={name}
|
||||
initialValue={initialValue}
|
||||
className={className}
|
||||
>
|
||||
<Select
|
||||
defaultValue={showDetailedDescriptions ? "default" : undefined}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<div className={className}>
|
||||
<Label htmlFor={name} className="flex items-center gap-1 mb-2">
|
||||
{limitTypeUpper} Rate Limit Type
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-1 h-3 w-3 inline" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{tooltipTitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Select value={internalValue} onValueChange={handleChange}>
|
||||
<SelectTrigger id={name} className="w-full">
|
||||
<SelectValue placeholder="Select rate limit type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -132,7 +137,7 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
|
|||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -65,9 +65,8 @@ type OrgEditValues = {
|
|||
mcp_servers_and_groups: { servers: string[]; accessGroups: string[] };
|
||||
};
|
||||
|
||||
// Member-table extra columns rendered as plain JSX (MemberTable receives them
|
||||
// as antd ColumnsType under the hood, but the cell renderer is just JSX so
|
||||
// shadcn-friendly).
|
||||
// Member-table extra columns rendered as plain JSX. MemberTable's
|
||||
// MemberTableExtraColumn type matches this shape structurally.
|
||||
type MemberExtraColumn = {
|
||||
title: string;
|
||||
key: string;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Info } from "lucide-react";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import MemberTable from "@/components/common_components/MemberTable";
|
||||
import MemberTable, {
|
||||
MemberTableExtraColumn,
|
||||
} from "@/components/common_components/MemberTable";
|
||||
import { TeamData } from "./TeamInfo";
|
||||
|
||||
const InfoTip: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
|
|
@ -106,7 +107,7 @@ export default function TeamMemberTab({
|
|||
return models && models.length > 0 ? models : null;
|
||||
};
|
||||
|
||||
const extraColumns: ColumnsType<Member> = [
|
||||
const extraColumns: MemberTableExtraColumn[] = [
|
||||
{
|
||||
title: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue