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:
cursor 2026-04-24 05:55:11 +00:00
parent bf0870758f
commit ce404da62a
No known key found for this signature in database
6 changed files with 177 additions and 158 deletions

View file

@ -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}>

View file

@ -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();
});

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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