diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx index 977258c4dcf..d668890aba8 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx @@ -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 ( +
+
{description}
+
+ ); +} + +function CopyableId({ value }: { value: string }) { + return ( + + + + + + Copy + + + ); +} + 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; @@ -65,263 +96,257 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { if (isLoading) { return ( - - - } size="large" /> - - +
+
+
+
); } if (!project) { return ( - - + + ); } return ( - +
{/* Header */} -
-
-
- - + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-semibold m-0"> {project.project_alias ?? project.project_id} - - {project.blocked ? "Blocked" : "Active"} - - - ID: {project.project_id} - + + + {project.blocked ? "Blocked" : "Active"} + +
+
+ ID: +
- {/* Project Details */} - - - - {project.description || "\u2014"} - + +

Project Details

+
+
+
Description
+
{project.description || "\u2014"}
+
+
+
Created
+
{new Date(project.created_at).toLocaleString()} {project.created_by && ( - -  {"by"}  + <> +  by  - + )} - - +
+
+
+
Last Updated
+
{new Date(project.updated_at).toLocaleString()} {project.updated_by && ( - -  {"by"}  + <> +  by  - + )} - - - - +
+
+
+
{/* Spend / Budget */} - - - - - Budget - - } - style={{ height: "100%" }} - > - +
+ +
+ + Budget +
+
+
+ + ${spend.toFixed(2)} + +
+ + {hasLimit + ? `of $${maxBudget.toFixed(2)} budget` + : "No budget limit"} + +
+ {hasLimit && (
- - ${spend.toFixed(2)} - -
- {hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"} + + + {(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized +
- {hasLimit && ( -
- - - {(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized - -
- )} - - - - - - {modelSpendData.length > 0 ? ( - `$${value.toFixed(4)}`} - yAxisWidth={140} - showLegend={false} - style={{ height: Math.max(modelSpendData.length * 40, 120) }} - /> - ) : ( - )} - - - +
+
+ +
Spend by Model
+ {modelSpendData.length > 0 ? ( + `$${value.toFixed(4)}`} + yAxisWidth={140} + showLegend={false} + style={{ height: Math.max(modelSpendData.length * 40, 120) }} + /> + ) : ( + + )} +
+
{/* Keys & Team */} - - - - - Keys -
- } - style={{ height: "100%" }} - > - -
- - - - - Team - - } - 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"; +
+ +
+ + Keys +
+ +
+ +
+ + Team +
+ {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 ( - - {/* Team name + ID */} -
- - {teamInfo.team_alias || teamInfo.team_id} - -
- - ID:{" "} - - {teamInfo.team_id} - - + return ( +
+ {/* Team name + ID */} +
+
+ {teamInfo.team_alias || teamInfo.team_id}
- - {/* Models */} -
- - Models - - {(teamInfo.models?.length ?? 0) > 0 ? ( - - {teamInfo.models?.map((m: string) => ( - - {m} - - ))} - - ) : ( - All models - )} +
+ ID:
+
- {/* Budget + Spend compact */} -
- - - Spend - - - ${teamSpend.toFixed(2)} - {teamHasLimit ? ( - - {" "} - / ${teamBudget.toFixed(2)} - - ) : ( - - {" "} - (Unlimited) - - )} - - - {teamHasLimit && ( - - )} + {/* Models */} +
+
+ Models
+ {(teamInfo.models?.length ?? 0) > 0 ? ( +
+ {teamInfo.models?.map((m: string) => ( + + {m} + + ))} +
+ ) : ( + + All models + + )} +
- {/* Members */} - - - Members - - {teamInfo.members_with_roles?.length ?? 0} - - - ); - })() - ) : project.team_id ? ( - - } size="small" /> - - ) : ( - - )} - - - + {/* Budget + Spend compact */} +
+
+ + Spend + + + ${teamSpend.toFixed(2)} + {teamHasLimit ? ( + + {" "} + / ${teamBudget.toFixed(2)} + + ) : ( + + {" "} + (Unlimited) + + )} + +
+ {teamHasLimit && ( + + )} +
+ + {/* Members */} +
+ + Members + + + {teamInfo.members_with_roles?.length ?? 0} + +
+
+ ); + })() + ) : project.team_id ? ( +
+ +
+ ) : ( + + )} + +
{/* Edit Modal */} - setIsEditModalVisible(false)} /> - + setIsEditModalVisible(false)} + /> +
); } diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx index 12fd80179b0..4c18aab9fb2 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx @@ -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: () =>
, + emptyProjectFormValues: { + project_alias: "", + team_id: "", + description: "", + models: [], + max_budget: undefined, + isBlocked: false, + guardrails: undefined, + modelLimits: undefined, + metadata: undefined, + }, })); describe("CreateProjectModal", () => { diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx index 000a722a434..805ad4531d9 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx @@ -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(); + const form = useForm({ + 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 ( (!o ? handleCancel() : undefined)} > - + Create New Project + + Create a new project within a team. + - - - - - + +
+ + + + + + +
); diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx index 905dbcd985e..f4d9db0a458 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx @@ -11,6 +11,17 @@ vi.mock("@/app/(dashboard)/hooks/projects/useUpdateProject", () => ({ vi.mock("./ProjectBaseForm", () => ({ ProjectBaseForm: () =>
, + emptyProjectFormValues: { + project_alias: "", + team_id: "", + description: "", + models: [], + max_budget: undefined, + isBlocked: false, + guardrails: undefined, + modelLimits: undefined, + metadata: undefined, + }, })); const mockProject: ProjectResponse = { diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx index abb080c7a32..d74c14ff9be 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx @@ -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; + 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(); + const form = useForm({ + defaultValues: emptyProjectFormValues, + mode: "onSubmit", + }); const updateMutation = useUpdateProject(); useEffect(() => { if (isOpen && project) { - const metadataObj = (project.metadata ?? {}) as Record; - 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 ( (!o ? onClose() : undefined)} > - + Edit Project + + Edit an existing project. + - - - - - + +
+ + + + + + +
); diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx index d8532146566..7323683d2fd 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx @@ -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(); - return ; +function FormWrapper({ + defaults, +}: { + defaults?: Partial; +} = {}) { + const form = useForm({ + defaultValues: { ...emptyProjectFormValues, ...defaults }, + }); + return ( + +
+ + +
+ ); } describe("ProjectBaseForm", () => { @@ -34,7 +50,7 @@ describe("ProjectBaseForm", () => { it("should render", () => { renderWithProviders(); - 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(); - 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(); - 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(); - // 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(); - // 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(); }); diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx index 490f44b247b..6db9894e928 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx @@ -1,28 +1,38 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { - Alert, - Col, - Collapse, - Divider, - Flex, - Form, - Input, - InputNumber, - Row, - Select, - Space, - Switch, - Typography, - Button, -} from "antd"; -import type { FormInstance } from "antd"; -import { Plus as PlusOutlined, MinusCircle as MinusCircleOutlined } from "lucide-react"; + Controller, + useFieldArray, + useFormContext, + useWatch, +} from "react-hook-form"; +import { ChevronDown, Minus, Plus, X } from "lucide-react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { Team } from "../../key_team_helpers/key_list"; import { fetchTeamModels } from "../../organisms/create_key_button"; import { getModelDisplayName } from "../../key_team_helpers/fetch_available_models_team_key"; import { getGuardrailsList } from "@/components/networking"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; export interface ProjectFormValues { project_alias: string; @@ -36,19 +46,239 @@ export interface ProjectFormValues { metadata?: { key: string; value: string }[]; } -interface ProjectBaseFormProps { - form: FormInstance; +export const emptyProjectFormValues: ProjectFormValues = { + project_alias: "", + team_id: "", + description: "", + models: [], + max_budget: undefined, + isBlocked: false, + guardrails: undefined, + modelLimits: undefined, + metadata: undefined, +}; + +const ALL_TEAM_MODELS = "all-team-models"; + +/** + * Multi-select rendered with shadcn Select + chip list below. Accepts a fixed + * list of options. Mirrors the canonical pattern in + * `AccessGroupBaseForm.tsx`. + */ +function MultiSelect({ + value, + onChange, + options, + placeholder, + emptyText, + disabled, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string }[]; + placeholder: string; + emptyText: string; + disabled?: boolean; +}) { + const selected = useMemo(() => value ?? [], [value]); + const remaining = useMemo( + () => options.filter((o) => !selected.includes(o.value)), + [options, selected], + ); + + return ( +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); } -export function ProjectBaseForm({ - form, -}: ProjectBaseFormProps) { +/** + * Free-form tag input. Renders a combobox with known options and accepts + * new entries on Enter / comma. Used for Guardrails. + */ +function TagsSelect({ + value, + onChange, + options, + placeholder, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string }[]; + placeholder: string; +}) { + const [draft, setDraft] = useState(""); + const selected = value ?? []; + const remaining = options.filter((o) => !selected.includes(o.value)); + + const addDraft = () => { + const v = draft.trim(); + if (!v) return; + if (selected.includes(v)) { + setDraft(""); + return; + } + onChange([...selected, v]); + setDraft(""); + }; + + return ( +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addDraft(); + } + }} + onBlur={() => { + if (draft.trim()) addDraft(); + }} + /> + {remaining.length > 0 && ( + + )} +
+ {selected.length > 0 && ( +
+ {selected.map((v) => ( + + {v} + + + ))} +
+ )} +
+ ); +} + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Fields-only block — expected to be rendered inside a parent form managed + * by `react-hook-form` via a `FormProvider`. See `CreateProjectModal` / + * `EditProjectModal` for the integration. + */ +export function ProjectBaseForm() { + const { + control, + register, + setValue, + formState: { errors }, + } = useFormContext(); + const { accessToken, userId, userRole } = useAuthorized(); const { data: teams } = useTeams(); const [selectedTeam, setSelectedTeam] = useState(null); const [modelsToPick, setModelsToPick] = useState([]); const [guardrailsList, setGuardrailsList] = useState([]); + const [advancedOpen, setAdvancedOpen] = useState(false); + + const teamIdValue = useWatch({ control, name: "team_id" }); + const isBlockedValue = useWatch({ control, name: "isBlocked" }); + + const { + fields: modelLimitFields, + append: appendModelLimit, + remove: removeModelLimit, + } = useFieldArray({ control, name: "modelLimits" as const }); + const { + fields: metadataFields, + append: appendMetadata, + remove: removeMetadata, + } = useFieldArray({ control, name: "metadata" as const }); useEffect(() => { const fetchGuardrails = async () => { @@ -56,7 +286,7 @@ export function ProjectBaseForm({ try { const response = await getGuardrailsList(accessToken); const names = response.guardrails.map( - (g: { guardrail_name: string }) => g.guardrail_name + (g: { guardrail_name: string }) => g.guardrail_name, ); setGuardrailsList(names); } catch (error) { @@ -66,18 +296,17 @@ export function ProjectBaseForm({ fetchGuardrails(); }, [accessToken]); - // Sync selectedTeam from form value (needed for edit mode pre-fill) - const teamIdValue = Form.useWatch("team_id", form); useEffect(() => { if (teamIdValue && teams) { const team = teams.find((t) => t.team_id === teamIdValue) ?? null; if (team && team.team_id !== selectedTeam?.team_id) { setSelectedTeam(team); } + } else if (!teamIdValue && selectedTeam) { + setSelectedTeam(null); } - }, [teamIdValue, teams, selectedTeam?.team_id]); + }, [teamIdValue, teams, selectedTeam]); - // Fetch team-scoped models when team selection changes useEffect(() => { if (userId && userRole && accessToken && selectedTeam) { fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then( @@ -93,346 +322,410 @@ export function ProjectBaseForm({ } }, [selectedTeam, accessToken, userId, userRole]); - const handleTeamChange = (teamId: string) => { - const team = teams?.find((t) => t.team_id === teamId) ?? null; - setSelectedTeam(team); - form.setFieldValue("models", []); - }; + const teamOptions = (teams ?? []).map((team) => ({ + label: team.team_alias || team.team_id, + value: team.team_id, + subLabel: team.team_id, + })); + + const modelOptions = [ + { label: "All Team Models", value: ALL_TEAM_MODELS }, + ...modelsToPick.map((m) => ({ + label: getModelDisplayName(m), + value: m, + })), + ]; + + const guardrailOptions = guardrailsList.map((name) => ({ + label: name, + value: name, + })); return ( -
+
{/* Basic Info */} - - Basic Information - - + Basic Information + - - - - - - - - +
+ + + {errors.project_alias && ( +

+ {errors.project_alias.message as string} +

+ )} +
+
+ + - - - - + rules={{ required: "Please select a team" }} + render={({ field }) => ( + + )} + /> + {errors.team_id && ( +

+ {errors.team_id.message as string} +

+ )} +
+
- - - - - - - +
+ +