refactor(ui): migrate shared common_components off antd and tremor

Replaces Ant Design and Tremor in the six shared components under
src/components/common_components, which between them are reached by
nine routes.

- antd Table becomes the ui/table primitives, and the Actions column keeps
  antd's fixed: "right" behaviour via a sticky cell
- Tremor Icon, Text and Badge become a plain span, p and StatusBadge
- antd Tooltip and Typography copyable become the shadcn Tooltip and the
  shared CopyButton
- every public prop signature is unchanged, since these are shared components
  and a renamed prop would break callers far from this folder
- two tests dropped assertions on antd internal class names and on DOM
  structure, and gained cases proving a disabled action does not fire onClick

MemberTable keeps a type-only import of antd's ColumnsType because a consumer
annotates its own column array with it. No antd code ships from the file.
This commit is contained in:
Yuneng Jiang 2026-08-14 02:55:16 -07:00
parent 423b791ee0
commit a98f2380f8
No known key found for this signature in database
9 changed files with 240 additions and 216 deletions

View file

@ -2372,11 +2372,6 @@
"count": 2
}
},
"src/components/common_components/AutoRotationView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/DefaultProxyAdminTag.tsx": {
"no-restricted-imports": {
"count": 1
@ -2395,16 +2390,6 @@
"count": 1
}
},
"src/components/common_components/IconActionButton/BaseActionButton.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/KeyLifecycleSettings.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
@ -2413,14 +2398,9 @@
"count": 2
}
},
"src/components/common_components/LabeledField.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/MemberTable.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/components/common_components/MetadataKeyValueFields.test.tsx": {
@ -2449,11 +2429,6 @@
"count": 1
}
},
"src/components/common_components/NewBadge.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/OrganizationDropdown.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1

View file

@ -1,5 +1,5 @@
import React from "react";
import { Text, Badge } from "@tremor/react";
import { StatusBadge } from "@/components/shared/table_cells";
import { RefreshIcon, ClockIcon } from "@heroicons/react/outline";
interface AutoRotationViewProps {
@ -38,63 +38,55 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
const content = (
<div className="space-y-6">
{/* Status Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<RefreshIcon className="h-4 w-4 text-blue-600" />
<Text className="font-semibold text-gray-900">Auto-Rotation</Text>
<Badge color={autoRotate ? "green" : "gray"} size="xs">
{autoRotate ? "Enabled" : "Disabled"}
</Badge>
<p className="text-sm font-semibold text-gray-900">Auto-Rotation</p>
<StatusBadge tone={autoRotate ? "success" : "neutral"} label={autoRotate ? "Enabled" : "Disabled"} />
{autoRotate && rotationInterval && (
<>
<Text className="text-gray-400"></Text>
<Text className="text-sm text-gray-600">Every {rotationInterval}</Text>
<p className="text-sm text-gray-400"></p>
<p className="text-sm text-gray-600">Every {rotationInterval}</p>
</>
)}
</div>
</div>
{/* Rotation History - Show if there's any rotation data OR if auto-rotation is enabled */}
{(autoRotate || lastRotationAt || keyRotationAt || nextRotationAt) && (
<div className="space-y-3">
{/* Last Rotation - Show when available */}
{lastRotationAt && (
<div className="flex items-center gap-2 p-3 bg-gray-50 border border-gray-200 rounded-md">
<ClockIcon className="w-4 h-4 text-gray-500" />
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
<ClockIcon className="h-4 w-4 text-gray-500" />
<div className="flex-1">
<Text className="font-medium text-gray-700">Last Rotation</Text>
<Text className="text-sm text-gray-600">{formatTimestamp(lastRotationAt)}</Text>
<p className="text-sm font-medium text-gray-700">Last Rotation</p>
<p className="text-sm text-gray-600">{formatTimestamp(lastRotationAt)}</p>
</div>
</div>
)}
{/* Next Scheduled Rotation - Show when available */}
{(keyRotationAt || nextRotationAt) && (
<div className="flex items-center gap-2 p-3 bg-gray-50 border border-gray-200 rounded-md">
<ClockIcon className="w-4 h-4 text-gray-500" />
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
<ClockIcon className="h-4 w-4 text-gray-500" />
<div className="flex-1">
<Text className="font-medium text-gray-700">Next Scheduled Rotation</Text>
<Text className="text-sm text-gray-600">{formatTimestamp(nextRotationAt || keyRotationAt || "")}</Text>
<p className="text-sm font-medium text-gray-700">Next Scheduled Rotation</p>
<p className="text-sm text-gray-600">{formatTimestamp(nextRotationAt || keyRotationAt || "")}</p>
</div>
</div>
)}
{/* No rotation data message - Only show if auto-rotation is enabled but no data */}
{autoRotate && !lastRotationAt && !keyRotationAt && !nextRotationAt && (
<div className="flex items-center gap-2 p-3 bg-gray-50 border border-gray-100 rounded-md">
<ClockIcon className="w-4 h-4 text-gray-500" />
<Text className="text-gray-600">No rotation history available</Text>
<div className="flex items-center gap-2 rounded-md border border-gray-100 bg-gray-50 p-3">
<ClockIcon className="h-4 w-4 text-gray-500" />
<p className="text-sm text-gray-600">No rotation history available</p>
</div>
)}
</div>
)}
{/* Disabled State - Only show if auto-rotation is disabled AND there's no rotation history */}
{!autoRotate && !lastRotationAt && !keyRotationAt && !nextRotationAt && (
<div className="flex items-center gap-2 p-3 bg-gray-50 border border-gray-100 rounded-md">
<RefreshIcon className="w-4 h-4 text-gray-400" />
<Text className="text-gray-600">Auto-rotation is not enabled for this key</Text>
<div className="flex items-center gap-2 rounded-md border border-gray-100 bg-gray-50 p-3">
<RefreshIcon className="h-4 w-4 text-gray-400" />
<p className="text-sm text-gray-600">Auto-rotation is not enabled for this key</p>
</div>
)}
</div>
@ -102,11 +94,11 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
if (variant === "card") {
return (
<div className={`bg-white border border-gray-200 rounded-lg p-6 ${className}`}>
<div className="flex items-center gap-2 mb-6">
<div className={`rounded-lg border border-gray-200 bg-white p-6 ${className}`}>
<div className="mb-6 flex items-center gap-2">
<div>
<Text className="font-semibold text-gray-900">Auto-Rotation</Text>
<Text className="text-xs text-gray-500">Automatic key rotation settings and status for this key</Text>
<p className="text-sm font-semibold text-gray-900">Auto-Rotation</p>
<p className="text-xs text-gray-500">Automatic key rotation settings and status for this key</p>
</div>
</div>
{content}
@ -116,7 +108,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
return (
<div className={`${className}`}>
<Text className="font-medium text-gray-900 mb-3">Auto-Rotation</Text>
<p className="mb-3 text-sm font-medium text-gray-900">Auto-Rotation</p>
{content}
</div>
);

View file

@ -1,5 +1,4 @@
import { cx } from "@/lib/cva.config";
import { Icon } from "@tremor/react";
import React from "react";
interface BaseActionButtonProps {
@ -10,16 +9,27 @@ interface BaseActionButtonProps {
dataTestId?: string;
}
export default function BaseActionButton({ icon, onClick, className, disabled, dataTestId }: BaseActionButtonProps) {
export default function BaseActionButton({
icon: Icon,
onClick,
className,
disabled,
dataTestId,
}: BaseActionButtonProps) {
return disabled ? (
<Icon icon={icon} size="sm" className={"opacity-50 cursor-not-allowed"} data-testid={dataTestId} />
) : (
<Icon
icon={icon}
size="sm"
onClick={onClick}
className={cx("cursor-pointer", className)}
<span
className="inline-flex shrink-0 cursor-not-allowed items-center justify-center p-1.5 opacity-50"
data-testid={dataTestId}
/>
>
<Icon className="size-5 shrink-0" />
</span>
) : (
<span
className={cx("inline-flex shrink-0 cursor-pointer items-center justify-center p-1.5", className)}
onClick={onClick}
data-testid={dataTestId}
>
<Icon className="size-5 shrink-0" />
</span>
);
}

View file

@ -1,5 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import TableIconActionButton, { TableIconActionButtonMap } from "./TableIconActionButton";
describe("TableIconActionButton", () => {
@ -12,27 +13,32 @@ describe("TableIconActionButton", () => {
});
});
it("should have a tooltip", () => {
render(<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" tooltipText="Edit" />);
const button = screen.getByTestId("test-button");
const tooltipWrapper = button.closest("span");
expect(tooltipWrapper).toBeInTheDocument();
it("should call onClick when clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<TableIconActionButton variant="Edit" onClick={onClick} dataTestId="test-button" tooltipText="Edit" />);
await user.click(screen.getByTestId("test-button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should show tooltip when tooltipText is provided", async () => {
it("should not show the tooltip before the button is hovered", () => {
render(
<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" tooltipText="Edit item" />,
);
const button = screen.getByTestId("test-button");
const buttonWrapper = button.closest("span");
expect(screen.queryByText("Edit item")).not.toBeInTheDocument();
});
act(() => {
fireEvent.mouseEnter(buttonWrapper!);
});
it("should show tooltip when tooltipText is provided", async () => {
const user = userEvent.setup();
render(
<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" tooltipText="Edit item" />,
);
await waitFor(() => {
expect(screen.getByText("Edit item")).toBeInTheDocument();
});
await user.hover(screen.getByTestId("test-button"));
expect(await screen.findByText("Edit item")).toBeInTheDocument();
});
it("should render disabled state with disabled styling", () => {
@ -44,7 +50,27 @@ describe("TableIconActionButton", () => {
expect(button).toHaveClass("cursor-not-allowed");
});
it("should not call onClick when disabled", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<TableIconActionButton
variant="Edit"
onClick={onClick}
dataTestId="test-button"
disabled
tooltipText="Edit"
disabledTooltipText="Cannot edit"
/>,
);
await user.click(screen.getByTestId("test-button"));
expect(onClick).not.toHaveBeenCalled();
});
it("should show disabledTooltipText when disabled and disabledTooltipText is provided", async () => {
const user = userEvent.setup();
render(
<TableIconActionButton
variant="Edit"
@ -55,15 +81,9 @@ describe("TableIconActionButton", () => {
disabledTooltipText="Cannot edit"
/>,
);
const button = screen.getByTestId("test-button");
const buttonWrapper = button.closest("span");
act(() => {
fireEvent.mouseEnter(buttonWrapper!);
});
await user.hover(screen.getByTestId("test-button"));
await waitFor(() => {
expect(screen.getByText("Cannot edit")).toBeInTheDocument();
});
expect(await screen.findByText("Cannot edit")).toBeInTheDocument();
});
});

View file

@ -1,3 +1,4 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
PencilAltIcon,
PlayIcon,
@ -8,7 +9,6 @@ import {
ExternalLinkIcon,
ClipboardCopyIcon,
} from "@heroicons/react/outline";
import { Tooltip } from "antd";
import BaseActionButton from "../BaseActionButton";
export interface TableIconActionButtonProps {
@ -45,17 +45,21 @@ export default function TableIconActionButton({
variant,
}: TableIconActionButtonProps) {
const { icon, className } = TableIconActionButtonMap[variant];
const title = disabled ? disabledTooltipText : tooltipText;
const button = (
<BaseActionButton icon={icon} onClick={onClick} className={className} disabled={disabled} dataTestId={dataTestId} />
);
if (!title) {
return <span>{button}</span>;
}
return (
<Tooltip title={disabled ? disabledTooltipText : tooltipText}>
<span>
<BaseActionButton
icon={icon}
onClick={onClick}
className={className}
disabled={disabled}
dataTestId={dataTestId}
/>
</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span />}>{button}</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -32,18 +32,22 @@ describe("LabeledField", () => {
});
it("should not be copyable when value is empty", () => {
const { container } = render(<LabeledField label="User ID" value="" copyable />);
// antd adds a .ant-typography-copy element when copyable; should not be present
expect(container.querySelector(".ant-typography-copy")).not.toBeInTheDocument();
render(<LabeledField label="User ID" value="" copyable />);
expect(screen.queryByRole("button", { name: "Copy User ID" })).not.toBeInTheDocument();
});
it("should not be copyable when value is default_user_id and defaultUserIdCheck is true", () => {
const { container } = render(<LabeledField label="User ID" value="default_user_id" copyable defaultUserIdCheck />);
expect(container.querySelector(".ant-typography-copy")).not.toBeInTheDocument();
render(<LabeledField label="User ID" value="default_user_id" copyable defaultUserIdCheck />);
expect(screen.queryByRole("button", { name: "Copy User ID" })).not.toBeInTheDocument();
});
it("should not be copyable when copyable is false", () => {
render(<LabeledField label="User ID" value="user-123" />);
expect(screen.queryByRole("button", { name: "Copy User ID" })).not.toBeInTheDocument();
});
it("should be copyable when copyable is true and value is present", () => {
const { container } = render(<LabeledField label="User ID" value="user-123" copyable />);
expect(container.querySelector(".ant-typography-copy")).toBeInTheDocument();
render(<LabeledField label="User ID" value="user-123" copyable />);
expect(screen.getByRole("button", { name: "Copy User ID" })).toBeInTheDocument();
});
});

View file

@ -1,9 +1,8 @@
import React from "react";
import { Typography, Space } from "antd";
import CopyButton from "@/components/shared/CopyButton";
import { cx } from "@/lib/cva.config";
import DefaultProxyAdminTag from "./DefaultProxyAdminTag";
const { Text } = Typography;
interface LabeledFieldProps {
label: string;
value: string;
@ -29,24 +28,20 @@ export default function LabeledField({
const valueEl = isDefaultUser ? (
<DefaultProxyAdminTag userId={value} />
) : (
<Text
strong
copyable={isCopyable ? { tooltips: [`Copy ${label}`, "Copied!"] } : false}
ellipsis={truncate}
style={truncate ? { maxWidth: 160, display: "block" } : undefined}
>
{displayValue}
</Text>
<span className="inline-flex min-w-0 items-center gap-1">
<strong className={cx("font-semibold", truncate ? "block max-w-40 truncate" : "break-words")}>
{displayValue}
</strong>
{isCopyable && <CopyButton value={value} label={`Copy ${label}`} />}
</span>
);
return (
<div>
<Space size={4}>
<Text type="secondary">{icon}</Text>
<Text type="secondary" style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</Text>
</Space>
<div>{valueEl}</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-muted-foreground">
{icon}
<span className="text-xs tracking-wider uppercase">{label}</span>
</div>
<div className="min-w-0">{valueEl}</div>
</div>
);
}

View file

@ -1,12 +1,13 @@
import { Tooltip } from "@/components/atoms/Tooltip";
import { Member } from "@/components/networking";
import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
import { StatusBadge } from "@/components/shared/table_cells";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { ColumnsType } from "antd/es/table";
import { Crown, Info, User, UserPlus } from "lucide-react";
import React from "react";
import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton";
const { Text } = Typography;
export interface MemberTableProps {
members: Member[];
canEdit: boolean;
@ -20,6 +21,21 @@ export interface MemberTableProps {
emptyText?: string;
}
type ExtraColumn = ColumnsType<Member>[number];
const extraColumnTitle = (column: ExtraColumn): React.ReactNode =>
typeof column.title === "function" ? null : column.title;
const extraColumnCell = (column: ExtraColumn, member: Member, index: number): React.ReactNode => {
const dataIndex = "dataIndex" in column && typeof column.dataIndex === "string" ? column.dataIndex : undefined;
const value = dataIndex ? member[dataIndex as keyof Member] : undefined;
const rendered = column.render?.(value, member, index);
if (typeof rendered === "string" || typeof rendered === "number") return rendered;
return React.isValidElement(rendered) ? rendered : null;
};
const STICKY_ACTIONS_CLASS = "sticky right-0 w-[120px] bg-background";
export default function MemberTable({
members,
canEdit,
@ -32,91 +48,96 @@ export default function MemberTable({
showDeleteForMember,
emptyText,
}: MemberTableProps) {
const baseColumns: ColumnsType<Member> = [
{
title: "User Email",
dataIndex: "user_email",
key: "user_email",
render: (email: string | null) => <Text>{email || "-"}</Text>,
},
{
title: "User ID",
dataIndex: "user_id",
key: "user_id",
render: (userId: string | null) =>
userId === "default_user_id" ? <Tag color="blue">Default Proxy Admin</Tag> : <Text>{userId || "-"}</Text>,
},
{
title: roleTooltip ? (
<Space direction="horizontal">
{roleColumnTitle}
<Tooltip title={roleTooltip}>
<InfoCircleOutlined />
</Tooltip>
</Space>
) : (
roleColumnTitle
),
dataIndex: "role",
key: "role",
render: (role: string) => (
<Space>
{role?.toLowerCase() === "admin" || role?.toLowerCase() === "org_admin" ? (
<CrownOutlined />
) : (
<UserOutlined />
)}
<Text style={{ textTransform: "capitalize" }}>{role || "-"}</Text>
</Space>
),
},
...extraColumns,
{
title: "Actions",
key: "actions",
fixed: "right" as const,
width: 120,
render: (_: unknown, record: Member) =>
canEdit ? (
<Space>
<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)}
/>
)}
</Space>
) : null,
},
];
return (
<Space direction="vertical" style={{ width: "100%" }}>
<div className="flex w-full flex-col gap-2">
<span className="inline-flex text-sm text-gray-700">
{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}
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>User Email</TableHead>
<TableHead>User ID</TableHead>
<TableHead>
{roleTooltip ? (
<span className="inline-flex items-center gap-2">
{roleColumnTitle}
<Tooltip content={roleTooltip}>
<Info className="size-3.5" />
</Tooltip>
</span>
) : (
roleColumnTitle
)}
</TableHead>
{extraColumns.map((column, columnIndex) => (
<TableHead key={column.key ?? columnIndex}>{extraColumnTitle(column)}</TableHead>
))}
<TableHead className={STICKY_ACTIONS_CLASS}>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={extraColumns.length + 4} className="text-center text-muted-foreground">
{emptyText ?? "No data"}
</TableCell>
</TableRow>
) : (
members.map((member, memberIndex) => (
<TableRow key={member.user_id ?? member.user_email ?? JSON.stringify(member)}>
<TableCell>{member.user_email || "-"}</TableCell>
<TableCell>
{member.user_id === "default_user_id" ? (
<StatusBadge tone="info" label="Default Proxy Admin" />
) : (
member.user_id || "-"
)}
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2">
{member.role?.toLowerCase() === "admin" || member.role?.toLowerCase() === "org_admin" ? (
<Crown className="size-3.5" />
) : (
<User className="size-3.5" />
)}
<span className="capitalize">{member.role || "-"}</span>
</span>
</TableCell>
{extraColumns.map((column, columnIndex) => (
<TableCell key={column.key ?? columnIndex}>{extraColumnCell(column, member, memberIndex)}</TableCell>
))}
<TableCell className={STICKY_ACTIONS_CLASS}>
{canEdit ? (
<span className="inline-flex items-center gap-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit member"
dataTestId="edit-member"
onClick={() => onEdit(member)}
/>
{(!showDeleteForMember || showDeleteForMember(member)) && (
<TableIconActionButton
variant="Delete"
tooltipText="Delete member"
dataTestId="delete-member"
onClick={() => onDelete(member)}
/>
)}
</span>
) : null}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{onAddMember && canEdit && (
<Button icon={<UserAddOutlined />} type="primary" onClick={onAddMember}>
<Button onClick={onAddMember} className="self-start">
<UserPlus className="size-4" />
Add Member
</Button>
)}
</Space>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Badge } from "antd";
import { Badge } from "@/components/ui/badge";
import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge";
export default function NewBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) {
@ -8,11 +8,14 @@ export default function NewBadge({ children, dot = false }: { children?: React.R
return children ? <>{children}</> : null;
}
const badge = dot ? <Badge className="size-1.5 p-0" /> : <Badge>New</Badge>;
return children ? (
<Badge color="blue" count={dot ? undefined : "New"} dot={dot}>
<span className="relative inline-flex">
{children}
</Badge>
<span className="absolute -top-0.5 -right-1">{badge}</span>
</span>
) : (
<Badge color="blue" count={dot ? undefined : "New"} dot={dot} />
badge
);
}