feat(ui): migrate Projects subtree to shadcn

- ProjectsPage: antd Table → shadcn Table + @tanstack/react-table with
  TableHeaderSortDropdown for sortable columns.
- ProjectDetailsPage: removed antd Layout/Card/Descriptions/Row/Col/Spin/
  Tag/Typography/Empty/Flex/Progress; switched to shadcn Card, Badge,
  Progress, Skeleton, Tooltip + a CopyableId molecule mirroring
  AccessGroupsDetailsPage.
- ProjectBaseForm: antd Form/Form.Item/Form.List → react-hook-form
  FormProvider + Controller + useFieldArray. Dynamic model-limit and
  metadata rows use useFieldArray. Guardrails tag input implemented as
  a TagsSelect (shadcn Input + Select + Badge chips). Multi-model
  selection uses the canonical MultiSelect from AccessGroupBaseForm.
  Advanced settings section uses shadcn Collapsible.
- CreateProjectModal / EditProjectModal: share a single FormProvider;
  submit via form.handleSubmit instead of form.validateFields; reset
  defaults on open.
- ProjectBaseForm.test.tsx: wrap ProjectBaseForm in RHF FormProvider
  (mock prop replaced by context). Existing Create/Edit modal tests
  updated to include the new emptyProjectFormValues export in their
  vi.mock factories.

No antd / @ant-design imports remain in src/components/Projects/. The
ProjectsPage status / ID chip keeps its categorical palette (blue/red/
emerald) via the existing .eslintrc.json override.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
cursor-agent 2026-04-24 08:07:58 +00:00
parent ae8b10cf8d
commit a2ed3848aa
No known key found for this signature in database
8 changed files with 1362 additions and 847 deletions

View file

@ -1,31 +1,32 @@
import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Flex,
Layout,
Progress,
Row,
Spin,
Tag,
theme,
Typography,
} from "antd";
import { Loader2 as LoadingOutlined } from "lucide-react";
ArrowLeft,
Copy,
DollarSign,
Edit,
Key,
Loader2,
Users,
} from "lucide-react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { BarChart } from "@tremor/react";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
const { Title, Text } = Typography;
const { Content } = Layout;
interface TeamInfoShape {
team_id: string;
team_alias?: string;
@ -41,20 +42,50 @@ interface ProjectDetailProps {
onBack: () => void;
}
function EmptyState({ description }: { description: string }) {
return (
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground">
<div className="text-sm">{description}</div>
</div>
);
}
function CopyableId({ value }: { value: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(value);
toast.success("Copied to clipboard");
}}
className="inline-flex items-center gap-1 text-xs font-mono rounded bg-muted px-1.5 py-0.5 hover:bg-muted/80"
>
{value}
<Copy size={12} />
</button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamData } = useTeam(project?.team_id ?? undefined);
// teamInfoCall returns { team_id, team_info: {...}, keys, team_memberships }
const teamInfo: TeamInfoShape | undefined = ((teamData as unknown as { team_info?: TeamInfoShape })?.team_info ??
teamData) as TeamInfoShape | undefined;
const { token } = theme.useToken();
const teamInfo: TeamInfoShape | undefined = ((teamData as unknown as {
team_info?: TeamInfoShape;
})?.team_info ?? teamData) as TeamInfoShape | undefined;
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const spend = project?.spend ?? 0;
const maxBudget = project?.litellm_budget_table?.max_budget ?? null;
const hasLimit = maxBudget != null && maxBudget > 0;
const spendPercent = hasLimit ? Math.min((spend / maxBudget) * 100, 100) : 0;
const spendColor = spendPercent >= 90 ? "#f5222d" : spendPercent >= 70 ? "#faad14" : "#52c41a";
const modelSpendData = useMemo(() => {
const raw = (project?.model_spend ?? {}) as Record<string, number>;
@ -65,263 +96,257 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
if (isLoading) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
<Spin indicator={<LoadingOutlined className="animate-spin" />} size="large" />
</Flex>
</Content>
<div className="p-6 md:px-12">
<div className="flex justify-center items-center min-h-[300px]">
<Loader2
role="img"
aria-hidden="true"
className="h-10 w-10 animate-spin text-muted-foreground"
/>
</div>
</div>
);
}
if (!project) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
<Empty description="Project not found" />
</Content>
<div className="p-6 md:px-12">
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
<ArrowLeft size={16} />
</Button>
<EmptyState description="Project not found" />
</div>
);
}
return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
<main className="p-6 md:px-12">
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" />
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft size={16} />
</Button>
<div>
<Flex align="center" gap={8}>
<Title level={2} style={{ margin: 0 }}>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-semibold m-0">
{project.project_alias ?? project.project_id}
</Title>
<Tag color={project.blocked ? "red" : "green"}>{project.blocked ? "Blocked" : "Active"}</Tag>
</Flex>
<Text type="secondary">
ID: <Text copyable>{project.project_id}</Text>
</Text>
</h2>
<Badge variant={project.blocked ? "destructive" : "secondary"}>
{project.blocked ? "Blocked" : "Active"}
</Badge>
</div>
<div className="text-sm text-muted-foreground flex items-center gap-2">
ID: <CopyableId value={project.project_id} />
</div>
</div>
</div>
<Button type="primary" icon={<EditIcon size={16} />} onClick={() => setIsEditModalVisible(true)}>
<Button onClick={() => setIsEditModalVisible(true)}>
<Edit size={16} />
Edit Project
</Button>
</div>
{/* Project Details */}
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Project Details" column={1}>
<Descriptions.Item label="Description">{project.description || "\u2014"}</Descriptions.Item>
<Descriptions.Item label="Created">
<Card className="p-6 mb-6">
<h3 className="text-lg font-semibold mb-3">Project Details</h3>
<dl className="space-y-2 text-sm">
<div className="flex gap-2">
<dt className="w-36 text-muted-foreground">Description</dt>
<dd>{project.description || "\u2014"}</dd>
</div>
<div className="flex gap-2">
<dt className="w-36 text-muted-foreground">Created</dt>
<dd>
{new Date(project.created_at).toLocaleString()}
{project.created_by && (
<Text>
&nbsp;{"by"}&nbsp;
<>
&nbsp;by&nbsp;
<DefaultProxyAdminTag userId={project.created_by} />
</Text>
</>
)}
</Descriptions.Item>
<Descriptions.Item label="Last Updated">
</dd>
</div>
<div className="flex gap-2">
<dt className="w-36 text-muted-foreground">Last Updated</dt>
<dd>
{new Date(project.updated_at).toLocaleString()}
{project.updated_by && (
<Text>
&nbsp;{"by"}&nbsp;
<>
&nbsp;by&nbsp;
<DefaultProxyAdminTag userId={project.updated_by} />
</Text>
</>
)}
</Descriptions.Item>
</Descriptions>
</Card>
</Row>
</dd>
</div>
</dl>
</Card>
{/* Spend / Budget */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={8}>
<Card
title={
<Flex align="center" gap={8}>
<DollarSignIcon size={16} />
Budget
</Flex>
}
style={{ height: "100%" }}
>
<Flex vertical gap={16}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
<Card className="p-6 lg:col-span-1">
<div className="flex items-center gap-2 mb-4 font-semibold">
<DollarSign size={16} />
Budget
</div>
<div className="space-y-4">
<div>
<span className="text-3xl font-semibold leading-none">
${spend.toFixed(2)}
</span>
<br />
<span className="text-sm text-muted-foreground">
{hasLimit
? `of $${maxBudget.toFixed(2)} budget`
: "No budget limit"}
</span>
</div>
{hasLimit && (
<div>
<Text strong style={{ fontSize: 28, lineHeight: 1 }}>
${spend.toFixed(2)}
</Text>
<br />
<Text type="secondary">{hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"}</Text>
<Progress value={Math.round(spendPercent * 10) / 10} />
<span className="text-xs text-muted-foreground">
{(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
</span>
</div>
{hasLimit && (
<div>
<Progress percent={Math.round(spendPercent * 10) / 10} strokeColor={spendColor} showInfo={false} />
<Text type="secondary" style={{ fontSize: 12 }}>
{(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
</Text>
</div>
)}
</Flex>
</Card>
</Col>
<Col xs={24} lg={16}>
<Card title="Spend by Model" style={{ height: "100%" }}>
{modelSpendData.length > 0 ? (
<BarChart
data={modelSpendData}
index="model"
categories={["spend"]}
colors={["cyan"]}
layout="vertical"
valueFormatter={(value) => `$${value.toFixed(4)}`}
yAxisWidth={140}
showLegend={false}
style={{ height: Math.max(modelSpendData.length * 40, 120) }}
/>
) : (
<Empty description="No model spend recorded yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
</div>
</Card>
<Card className="p-6 lg:col-span-2">
<div className="mb-4 font-semibold">Spend by Model</div>
{modelSpendData.length > 0 ? (
<BarChart
data={modelSpendData}
index="model"
categories={["spend"]}
colors={["cyan"]}
layout="vertical"
valueFormatter={(value) => `$${value.toFixed(4)}`}
yAxisWidth={140}
showLegend={false}
style={{ height: Math.max(modelSpendData.length * 40, 120) }}
/>
) : (
<EmptyState description="No model spend recorded yet" />
)}
</Card>
</div>
{/* Keys & Team */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Keys
</Flex>
}
style={{ height: "100%" }}
>
<Empty description="No keys to display" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Team
</Flex>
}
style={{ height: "100%" }}
>
{teamInfo ? (
(() => {
const teamBudget = teamInfo.max_budget ?? null;
const teamSpend = teamInfo.spend ?? 0;
const teamHasLimit = teamBudget != null && teamBudget > 0;
const teamPercent = teamHasLimit ? Math.min((teamSpend / teamBudget) * 100, 100) : 0;
const teamColor = teamPercent >= 90 ? "#f5222d" : teamPercent >= 70 ? "#faad14" : "#52c41a";
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<Card className="p-6">
<div className="flex items-center gap-2 mb-4 font-semibold">
<Key size={16} />
Keys
</div>
<EmptyState description="No keys to display" />
</Card>
<Card className="p-6">
<div className="flex items-center gap-2 mb-4 font-semibold">
<Users size={16} />
Team
</div>
{teamInfo ? (
(() => {
const teamBudget = teamInfo.max_budget ?? null;
const teamSpend = teamInfo.spend ?? 0;
const teamHasLimit = teamBudget != null && teamBudget > 0;
const teamPercent = teamHasLimit
? Math.min((teamSpend / teamBudget) * 100, 100)
: 0;
return (
<Flex vertical gap={12}>
{/* Team name + ID */}
<div>
<Text strong style={{ fontSize: 16 }}>
{teamInfo.team_alias || teamInfo.team_id}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
ID:{" "}
<Text copyable style={{ fontSize: 12 }}>
{teamInfo.team_id}
</Text>
</Text>
return (
<div className="flex flex-col gap-3">
{/* Team name + ID */}
<div>
<div className="font-semibold text-base">
{teamInfo.team_alias || teamInfo.team_id}
</div>
{/* Models */}
<div>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
Models
</Text>
{(teamInfo.models?.length ?? 0) > 0 ? (
<Flex wrap="wrap" gap={4} style={{ maxHeight: 60, overflow: "hidden" }}>
{teamInfo.models?.map((m: string) => (
<Tag key={m} style={{ margin: 0 }}>
{m}
</Tag>
))}
</Flex>
) : (
<Text type="secondary">All models</Text>
)}
<div className="text-xs text-muted-foreground flex items-center gap-1">
ID: <CopyableId value={teamInfo.team_id} />
</div>
</div>
{/* Budget + Spend compact */}
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Spend
</Text>
<Text style={{ fontSize: 12 }}>
${teamSpend.toFixed(2)}
{teamHasLimit ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{" "}
/ ${teamBudget.toFixed(2)}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{" "}
(Unlimited)
</Text>
)}
</Text>
</Flex>
{teamHasLimit && (
<Progress
percent={Math.round(teamPercent * 10) / 10}
strokeColor={teamColor}
size="small"
showInfo={false}
/>
)}
{/* Models */}
<div>
<div className="text-xs text-muted-foreground mb-1">
Models
</div>
{(teamInfo.models?.length ?? 0) > 0 ? (
<div className="flex flex-wrap gap-1 max-h-[60px] overflow-hidden">
{teamInfo.models?.map((m: string) => (
<Badge
key={m}
variant="secondary"
className="font-normal"
>
{m}
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">
All models
</span>
)}
</div>
{/* Members */}
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>
Members
</Text>
<Text style={{ fontSize: 12 }}>{teamInfo.members_with_roles?.length ?? 0}</Text>
</Flex>
</Flex>
);
})()
) : project.team_id ? (
<Flex justify="center" align="center" style={{ padding: 16 }}>
<Spin indicator={<LoadingOutlined className="animate-spin" />} size="small" />
</Flex>
) : (
<Empty description="No team assigned" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
{/* Budget + Spend compact */}
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-xs text-muted-foreground">
Spend
</span>
<span className="text-xs">
${teamSpend.toFixed(2)}
{teamHasLimit ? (
<span className="text-muted-foreground">
{" "}
/ ${teamBudget.toFixed(2)}
</span>
) : (
<span className="text-muted-foreground">
{" "}
(Unlimited)
</span>
)}
</span>
</div>
{teamHasLimit && (
<Progress
value={Math.round(teamPercent * 10) / 10}
className="h-2"
/>
)}
</div>
{/* Members */}
<div className="flex justify-between">
<span className="text-xs text-muted-foreground">
Members
</span>
<span className="text-xs">
{teamInfo.members_with_roles?.length ?? 0}
</span>
</div>
</div>
);
})()
) : project.team_id ? (
<div className="flex justify-center items-center py-4">
<Skeleton className="h-6 w-32" />
</div>
) : (
<EmptyState description="No team assigned" />
)}
</Card>
</div>
{/* Edit Modal */}
<EditProjectModal isOpen={isEditModalVisible} project={project} onClose={() => setIsEditModalVisible(false)} />
</Content>
<EditProjectModal
isOpen={isEditModalVisible}
project={project}
onClose={() => setIsEditModalVisible(false)}
/>
</main>
);
}

View file

@ -11,6 +11,17 @@ vi.mock("@/app/(dashboard)/hooks/projects/useCreateProject", () => ({
// Mock the form to keep tests focused on modal behavior
vi.mock("./ProjectBaseForm", () => ({
ProjectBaseForm: () => <div data-testid="project-base-form" />,
emptyProjectFormValues: {
project_alias: "",
team_id: "",
description: "",
models: [],
max_budget: undefined,
isBlocked: false,
guardrails: undefined,
modelLimits: undefined,
metadata: undefined,
},
}));
describe("CreateProjectModal", () => {

View file

@ -1,13 +1,15 @@
import { FolderPlus } from "lucide-react";
import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import {
useCreateProject,
@ -16,6 +18,7 @@ import {
import {
ProjectBaseForm,
ProjectFormValues,
emptyProjectFormValues,
} from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
@ -28,61 +31,71 @@ export function CreateProjectModal({
isOpen,
onClose,
}: CreateProjectModalProps) {
const [form] = Form.useForm<ProjectFormValues>();
const form = useForm<ProjectFormValues>({
defaultValues: emptyProjectFormValues,
mode: "onSubmit",
});
const createMutation = useCreateProject();
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const params: ProjectCreateParams = {
...buildProjectApiParams(values),
team_id: values.team_id,
};
createMutation.mutate(params, {
onSuccess: () => {
MessageManager.success("Project created successfully");
form.resetFields();
onClose();
},
onError: (error) => {
MessageManager.error(error.message || "Failed to create project");
},
});
} catch (error) {
console.error("Validation failed:", error);
}
};
useEffect(() => {
if (isOpen) form.reset(emptyProjectFormValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleCancel = () => {
form.resetFields();
form.reset(emptyProjectFormValues);
onClose();
};
const onSubmit = form.handleSubmit((values) => {
const params: ProjectCreateParams = {
...buildProjectApiParams(values),
team_id: values.team_id,
};
createMutation.mutate(params, {
onSuccess: () => {
MessageManager.success("Project created successfully");
form.reset(emptyProjectFormValues);
onClose();
},
onError: (error) => {
MessageManager.error(error.message || "Failed to create project");
},
});
});
return (
<Dialog
open={isOpen}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<DialogContent className="max-w-[720px]">
<DialogContent className="max-w-[720px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">
Create New Project
</DialogTitle>
<DialogDescription className="sr-only">
Create a new project within a team.
</DialogDescription>
</DialogHeader>
<ProjectBaseForm form={form} />
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={createMutation.isPending}
>
<FolderPlus className="h-4 w-4" />
{createMutation.isPending ? "Creating…" : "Create Project"}
</Button>
</DialogFooter>
<FormProvider {...form}>
<form onSubmit={onSubmit}>
<ProjectBaseForm />
<DialogFooter className="mt-6">
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
<FolderPlus className="h-4 w-4" />
{createMutation.isPending ? "Creating…" : "Create Project"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);

View file

@ -11,6 +11,17 @@ vi.mock("@/app/(dashboard)/hooks/projects/useUpdateProject", () => ({
vi.mock("./ProjectBaseForm", () => ({
ProjectBaseForm: () => <div data-testid="project-base-form" />,
emptyProjectFormValues: {
project_alias: "",
team_id: "",
description: "",
models: [],
max_budget: undefined,
isBlocked: false,
guardrails: undefined,
modelLimits: undefined,
metadata: undefined,
},
}));
const mockProject: ProjectResponse = {

View file

@ -1,21 +1,26 @@
import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form } from "antd";
import { Save } from "lucide-react";
import MessageManager from "@/components/molecules/message_manager";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import {
useUpdateProject,
ProjectUpdateParams,
} from "@/app/(dashboard)/hooks/projects/useUpdateProject";
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
import {
ProjectBaseForm,
ProjectFormValues,
emptyProjectFormValues,
} from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
interface EditProjectModalProps {
@ -25,119 +30,128 @@ interface EditProjectModalProps {
onSuccess?: () => void;
}
function buildDefaultsFromProject(
project: ProjectResponse,
): ProjectFormValues {
const metadataObj = (project.metadata ?? {}) as Record<string, unknown>;
const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record<
string,
number
>;
const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<
string,
number
>;
const guardrails = (
Array.isArray(metadataObj.guardrails) ? metadataObj.guardrails : []
) as string[];
const modelLimits: ProjectFormValues["modelLimits"] = [];
const allLimitModels = new Set([
...Object.keys(rpmLimits),
...Object.keys(tpmLimits),
]);
for (const model of allLimitModels) {
modelLimits.push({
model,
rpm: rpmLimits[model],
tpm: tpmLimits[model],
});
}
const internalKeys = new Set([
"model_rpm_limit",
"model_tpm_limit",
"guardrails",
]);
const metadata: ProjectFormValues["metadata"] = [];
for (const [key, value] of Object.entries(metadataObj)) {
if (!internalKeys.has(key)) {
metadata.push({ key, value: String(value) });
}
}
return {
project_alias: project.project_alias ?? "",
team_id: project.team_id ?? "",
description: project.description ?? "",
models: project.models ?? [],
max_budget: project.litellm_budget_table?.max_budget ?? undefined,
isBlocked: project.blocked,
guardrails: guardrails.length > 0 ? guardrails : undefined,
modelLimits: modelLimits.length > 0 ? modelLimits : undefined,
metadata: metadata.length > 0 ? metadata : undefined,
};
}
export function EditProjectModal({
isOpen,
project,
onClose,
onSuccess,
}: EditProjectModalProps) {
const [form] = Form.useForm<ProjectFormValues>();
const form = useForm<ProjectFormValues>({
defaultValues: emptyProjectFormValues,
mode: "onSubmit",
});
const updateMutation = useUpdateProject();
useEffect(() => {
if (isOpen && project) {
const metadataObj = (project.metadata ?? {}) as Record<string, unknown>;
const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record<
string,
number
>;
const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<
string,
number
>;
const guardrails = (
Array.isArray(metadataObj.guardrails) ? metadataObj.guardrails : []
) as string[];
const modelLimits: ProjectFormValues["modelLimits"] = [];
const allLimitModels = new Set([
...Object.keys(rpmLimits),
...Object.keys(tpmLimits),
]);
for (const model of allLimitModels) {
modelLimits.push({
model,
rpm: rpmLimits[model],
tpm: tpmLimits[model],
});
}
const internalKeys = new Set([
"model_rpm_limit",
"model_tpm_limit",
"guardrails",
]);
const metadata: ProjectFormValues["metadata"] = [];
for (const [key, value] of Object.entries(metadataObj)) {
if (!internalKeys.has(key)) {
metadata.push({ key, value: String(value) });
}
}
form.setFieldsValue({
project_alias: project.project_alias ?? "",
team_id: project.team_id ?? "",
description: project.description ?? "",
models: project.models ?? [],
max_budget: project.litellm_budget_table?.max_budget ?? undefined,
isBlocked: project.blocked,
guardrails: guardrails.length > 0 ? guardrails : undefined,
modelLimits: modelLimits.length > 0 ? modelLimits : undefined,
metadata: metadata.length > 0 ? metadata : undefined,
});
form.reset(buildDefaultsFromProject(project));
}
}, [isOpen, project, form]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, project]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const params: ProjectUpdateParams = {
...buildProjectApiParams(values),
team_id: values.team_id,
};
const onSubmit = form.handleSubmit((values) => {
const params: ProjectUpdateParams = {
...buildProjectApiParams(values),
team_id: values.team_id,
};
updateMutation.mutate(
{ projectId: project.project_id, params },
{
onSuccess: () => {
MessageManager.success("Project updated successfully");
onSuccess?.();
onClose();
},
onError: (error) => {
MessageManager.error(error.message || "Failed to update project");
},
updateMutation.mutate(
{ projectId: project.project_id, params },
{
onSuccess: () => {
MessageManager.success("Project updated successfully");
onSuccess?.();
onClose();
},
);
} catch (error) {
console.error("Validation failed:", error);
}
};
onError: (error) => {
MessageManager.error(error.message || "Failed to update project");
},
},
);
});
return (
<Dialog
open={isOpen}
onOpenChange={(o) => (!o ? onClose() : undefined)}
>
<DialogContent className="max-w-[720px]">
<DialogContent className="max-w-[720px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">
Edit Project
</DialogTitle>
<DialogDescription className="sr-only">
Edit an existing project.
</DialogDescription>
</DialogHeader>
<ProjectBaseForm form={form} />
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={updateMutation.isPending}
>
<Save className="h-4 w-4" />
{updateMutation.isPending ? "Saving…" : "Save Changes"}
</Button>
</DialogFooter>
<FormProvider {...form}>
<form onSubmit={onSubmit}>
<ProjectBaseForm />
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
<Save className="h-4 w-4" />
{updateMutation.isPending ? "Saving…" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);

View file

@ -1,9 +1,13 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { FormProvider, useForm } from "react-hook-form";
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
import { Form } from "antd";
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
import {
ProjectBaseForm,
ProjectFormValues,
emptyProjectFormValues,
} from "./ProjectBaseForm";
const mockUseTeams = vi.fn();
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
@ -22,9 +26,21 @@ vi.mock("@/components/key_team_helpers/fetch_available_models_team_key", () => (
getModelDisplayName: (model: string) => model,
}));
function FormWrapper() {
const [form] = Form.useForm<ProjectFormValues>();
return <ProjectBaseForm form={form} />;
function FormWrapper({
defaults,
}: {
defaults?: Partial<ProjectFormValues>;
} = {}) {
const form = useForm<ProjectFormValues>({
defaultValues: { ...emptyProjectFormValues, ...defaults },
});
return (
<FormProvider {...form}>
<form>
<ProjectBaseForm />
</form>
</FormProvider>
);
}
describe("ProjectBaseForm", () => {
@ -34,7 +50,7 @@ describe("ProjectBaseForm", () => {
it("should render", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByLabelText("Project Name")).toBeInTheDocument();
expect(screen.getByLabelText(/Project Name/)).toBeInTheDocument();
});
it("should show a 'Basic Information' section heading", () => {
@ -44,7 +60,9 @@ describe("ProjectBaseForm", () => {
it("should show a Project Name input", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByPlaceholderText("e.g. Customer Support Bot")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("e.g. Customer Support Bot"),
).toBeInTheDocument();
});
it("should show a Team select", () => {
@ -54,12 +72,13 @@ describe("ProjectBaseForm", () => {
it("should show a Description textarea", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByPlaceholderText("Describe the purpose of this project")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("Describe the purpose of this project"),
).toBeInTheDocument();
});
it("should show the models select as disabled when no team is selected", () => {
renderWithProviders(<FormWrapper />);
// The models select should be disabled — its placeholder indicates no team yet
expect(screen.getByText("Select a team first")).toBeInTheDocument();
});
@ -73,8 +92,7 @@ describe("ProjectBaseForm", () => {
isLoading: false,
});
renderWithProviders(<FormWrapper />);
// The form label "Team" is associated with the combobox input inside the Select
await user.click(screen.getByLabelText("Team"));
await user.click(screen.getByLabelText(/Team/));
await waitFor(() => {
expect(screen.getByText("Engineering")).toBeInTheDocument();
});

View file

@ -7,14 +7,33 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Table } from "antd";
import type { ColumnsType } from "antd/es/table";
import {
SortState,
TableHeaderSortDropdown,
} from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import {
ChevronLeft,
ChevronRight,
@ -31,10 +50,13 @@ export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [sorting, setSorting] = useState<SortingState>([]);
const pageSize = 10;
useEffect(() => {
@ -50,7 +72,6 @@ export function ProjectsPage() {
return map;
}, [teams]);
// ---------- filtered data ----------
const filteredProjects = useMemo(() => {
const list = projects ?? [];
if (!searchText) return list;
@ -66,107 +87,143 @@ export function ProjectsPage() {
});
}, [projects, searchText, teamAliasMap]);
// ---------- Ant Design columns ----------
const columns: ColumnsType<ProjectResponse> = [
{
title: "ID",
dataIndex: "project_id",
key: "project_id",
width: 170,
render: (id: string) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
onClick={() => setSelectedProjectId(id)}
className="text-blue-500 bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/30 dark:hover:bg-blue-950/60 text-sm cursor-pointer px-2 py-0.5 rounded inline-block truncate max-w-[160px]"
>
{id}
</span>
</TooltipTrigger>
<TooltipContent>{id}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
{
title: "Name",
dataIndex: "project_alias",
key: "project_alias",
sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""),
render: (alias: string | null) => alias ?? "—",
},
{
title: "Team",
key: "team",
sorter: (a, b) => {
const aAlias = teamAliasMap.get(a.team_id ?? "") ?? "";
const bAlias = teamAliasMap.get(b.team_id ?? "") ?? "";
return aAlias.localeCompare(bAlias);
const columnDefs = useMemo<ColumnDef<ProjectResponse>[]>(
() => [
{
id: "project_id",
accessorKey: "project_id",
header: () => <span>ID</span>,
enableSorting: false,
size: 170,
cell: ({ row }) => {
const id = row.original.project_id;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setSelectedProjectId(id)}
className="text-blue-500 bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/30 dark:hover:bg-blue-950/60 text-sm cursor-pointer px-2 py-0.5 rounded inline-block truncate max-w-[160px]"
>
{id}
</button>
</TooltipTrigger>
<TooltipContent>{id}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
render: (_: unknown, record: ProjectResponse) => {
if (!record.team_id) return "—";
const alias = teamAliasMap.get(record.team_id);
if (alias) return alias;
if (isTeamsLoading)
return <Loader2 className="h-3.5 w-3.5 animate-spin" />;
return record.team_id;
{
id: "project_alias",
accessorKey: "project_alias",
header: () => <span>Name</span>,
enableSorting: true,
sortingFn: (a, b) =>
(a.original.project_alias ?? "").localeCompare(
b.original.project_alias ?? "",
),
cell: ({ getValue }) => (getValue() as string | null) ?? "—",
},
},
{
title: "Models",
key: "models",
render: (_: unknown, record: ProjectResponse) => {
const models = record.models ?? [];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300 gap-1.5">
<Layers className="h-3.5 w-3.5" />
{models.length}
</Badge>
</TooltipTrigger>
<TooltipContent>
{models.length > 0 ? models.join(", ") : "No models"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
{
id: "team",
header: () => <span>Team</span>,
enableSorting: true,
sortingFn: (a, b) => {
const aAlias = teamAliasMap.get(a.original.team_id ?? "") ?? "";
const bAlias = teamAliasMap.get(b.original.team_id ?? "") ?? "";
return aAlias.localeCompare(bAlias);
},
cell: ({ row }) => {
const record = row.original;
if (!record.team_id) return "—";
const alias = teamAliasMap.get(record.team_id);
if (alias) return alias;
if (isTeamsLoading)
return <Loader2 className="h-3.5 w-3.5 animate-spin" />;
return record.team_id;
},
},
},
{
title: "Status",
dataIndex: "blocked",
key: "status",
render: (blocked: boolean) => (
<Badge
className={
blocked
? "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
}
>
{blocked ? "Blocked" : "Active"}
</Badge>
),
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
responsive: ["lg"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
{
title: "Updated",
dataIndex: "updated_at",
key: "updated_at",
responsive: ["xl"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
];
{
id: "models",
header: () => <span>Models</span>,
enableSorting: false,
cell: ({ row }) => {
const models = row.original.models ?? [];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300 gap-1.5">
<Layers className="h-3.5 w-3.5" />
{models.length}
</Badge>
</TooltipTrigger>
<TooltipContent>
{models.length > 0 ? models.join(", ") : "No models"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
id: "status",
accessorKey: "blocked",
header: () => <span>Status</span>,
enableSorting: false,
cell: ({ row }) => (
<Badge
className={
row.original.blocked
? "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
}
>
{row.original.blocked ? "Blocked" : "Active"}
</Badge>
),
},
{
id: "created_at",
accessorKey: "created_at",
header: () => <span>Created</span>,
enableSorting: true,
sortingFn: (a, b) =>
new Date(a.original.created_at).getTime() -
new Date(b.original.created_at).getTime(),
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
},
{
id: "updated_at",
accessorKey: "updated_at",
header: () => <span>Updated</span>,
enableSorting: false,
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
},
],
[teamAliasMap, isTeamsLoading],
);
const table = useReactTable<ProjectResponse>({
data: filteredProjects,
columns: columnDefs,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.project_id,
});
const sortedRows = table.getRowModel().rows;
const paginatedRows = sortedRows.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
if (selectedProjectId) {
return (
@ -177,8 +234,6 @@ export function ProjectsPage() {
);
}
const totalPages = Math.max(1, Math.ceil(filteredProjects.length / pageSize));
return (
<main className="p-6 md:px-12">
<div className="flex justify-between items-center mb-4">
@ -207,7 +262,7 @@ export function ProjectsPage() {
</div>
<div className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">
{filteredProjects.length} projects
{sortedRows.length} projects
</span>
<Button
size="icon"
@ -227,23 +282,98 @@ export function ProjectsPage() {
variant="ghost"
className="h-6 w-6"
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<Table
columns={columns}
dataSource={filteredProjects.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
)}
rowKey="project_id"
loading={isLoading}
pagination={false}
/>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
return (
<TableHead
key={header.id}
style={{ width: header.column.columnDef.size }}
>
<div className={cn("flex items-center gap-1")}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{canSort && (
<TableHeaderSortDropdown
sortState={
isSorted === false
? false
: (isSorted as SortState)
}
onSortChange={(newState) => {
if (newState === false) {
setSorting([]);
} else {
setSorting([
{
id: header.column.id,
desc: newState === "desc",
},
]);
}
}}
columnId={header.column.id}
/>
)}
</div>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columnDefs.length}
className="text-center text-muted-foreground py-8"
>
Loading…
</TableCell>
</TableRow>
) : paginatedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columnDefs.length}
className="text-center text-muted-foreground py-8"
>
No projects found
</TableCell>
</TableRow>
) : (
paginatedRows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
<CreateProjectModal