mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
ae8b10cf8d
commit
a2ed3848aa
8 changed files with 1362 additions and 847 deletions
|
|
@ -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>
|
||||
{"by"}
|
||||
<>
|
||||
by
|
||||
<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>
|
||||
{"by"}
|
||||
<>
|
||||
by
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue