From 0896015927caf2e38988c7add242fb3b6f96d3e2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 08:43:25 -0700 Subject: [PATCH 1/2] test(ui): await the playground model combobox before clicking it (#36850) The ChatUI playground test helper looked up the model combobox with a synchronous getByPlaceholderText. That control renders its placeholder from the model-loading flag, so the text is "Loading models..." until the mocked fetch resolves, and the element the helper wants does not exist yet. Under a loaded full-suite run the query could land inside that window and fail with "Unable to find an element with the placeholder text of: Select a Model", while the same test passed in isolation every time. Switch the helper to findByPlaceholderText so it waits for the control to come back after loading. --- .../(dashboard)/playground/components/chat_ui/ChatUI.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx index 24c56b276af..9d9c5dc86ab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx @@ -35,7 +35,7 @@ const STREAMING_ENABLED_ARG_INDEX = 25; async function openComboboxByPlaceholder(placeholder: string) { const user = userEvent.setup(); - const combobox = screen.getByPlaceholderText(placeholder); + const combobox = await screen.findByPlaceholderText(placeholder); await user.click(combobox); return combobox; } From b08032c5f743b5e5e009f5e1b4086177c2cf1403 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 08:51:22 -0700 Subject: [PATCH 2/2] refactor(ui): migrate budget and skill forms to react-hook-form and shadcn (#37262) * refactor(ui): migrate budget and skill forms to react-hook-form and shadcn Moves three dashboard forms off antd Form onto react-hook-form plus the shared shadcn field kit, and onto semantic color tokens so they render correctly in dark mode. The submitted request bodies are unchanged. Budget create and edit previously relied on antd InputNumber precision={2}, which rounds the submitted value rather than only the display. That rounding is now an explicit shared helper so the wire payload stays identical, and the helper carries unit tests covering key presence, null passthrough, negatives and non-finite input. Characterization tests for both budget modals were written against the antd implementation first and pass unchanged against the migrated components, which is what pins the payload. They are named .integration.test.tsx per the dashboard test tiers, with the pure rounding logic unit tested separately. * fix(ui): keep collapsed Optional Settings values so reopening does not lose them react-hook-form shouldUnregister deletes a field's value when its section unmounts, so typing a budget, collapsing Optional Settings and reopening it submitted the seeded default instead of what was typed. antd reported only mounted fields in onFinish but preserved their values in its store, so the two behaviours have to be reproduced separately. Drop shouldUnregister, drive the section from controlled state, and blank the section's fields at submit while it is closed. Seed the edit form from the five form fields rather than the whole budget record, which shouldUnregister had been masking. --- .../_components/budgetPrecision.test.ts | 45 ++ .../budgets/_components/budgetPrecision.ts | 18 + .../budget_modal.integration.test.tsx | 139 ++++++ .../budgets/_components/budget_modal.tsx | 166 +++++-- .../edit_budget_modal.integration.test.tsx | 112 +++++ .../budgets/_components/edit_budget_modal.tsx | 169 +++++-- .../skills/_components/add_plugin_form.tsx | 420 +++++++++++------- 7 files changed, 806 insertions(+), 263 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.test.ts new file mode 100644 index 00000000000..358ea2980a6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { applyBudgetPrecision } from "./budgetPrecision"; + +describe("applyBudgetPrecision", () => { + it("rounds each precision field to two decimals, matching antd InputNumber precision={2}", () => { + const typed = { budget_id: "b", tpm_limit: 500.567, rpm_limit: 7.005, max_budget: 42.567 }; + const rounded = { budget_id: "b", tpm_limit: 500.57, rpm_limit: 7.01, max_budget: 42.57 }; + + expect(applyBudgetPrecision(typed)).toEqual(rounded); + }); + + it("leaves non-precision fields untouched even when numeric", () => { + expect(applyBudgetPrecision({ soft_budget: 1.239, budget_duration: "30d" })).toEqual({ + soft_budget: 1.239, + budget_duration: "30d", + }); + }); + + it("preserves key presence exactly, so an omitted field is not reintroduced as undefined", () => { + expect(Object.keys(applyBudgetPrecision({ budget_id: "b", tpm_limit: 1 }))).toEqual(["budget_id", "tpm_limit"]); + }); + + it("passes null and undefined through without coercing them to a number", () => { + expect(applyBudgetPrecision({ tpm_limit: null, rpm_limit: undefined, max_budget: 1.005 })).toEqual({ + tpm_limit: null, + rpm_limit: undefined, + max_budget: 1.01, + }); + }); + + it("rounds negatives away from zero the way antd does", () => { + expect(applyBudgetPrecision({ max_budget: -1.005 })).toEqual({ max_budget: -1.01 }); + }); + + it("returns non-finite values unchanged rather than emitting NaN", () => { + expect(applyBudgetPrecision({ max_budget: Number.POSITIVE_INFINITY })).toEqual({ + max_budget: Number.POSITIVE_INFINITY, + }); + }); + + it("does not disturb a value that already has two or fewer decimals", () => { + expect(applyBudgetPrecision({ max_budget: 42.5, tpm_limit: 500 })).toEqual({ max_budget: 42.5, tpm_limit: 500 }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.ts b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.ts new file mode 100644 index 00000000000..51930a6f604 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budgetPrecision.ts @@ -0,0 +1,18 @@ +const PRECISION_FIELDS: ReadonlySet = new Set(["tpm_limit", "rpm_limit", "max_budget"]); + +const roundToPrecision = (value: number): number => { + const shifted = Number(`${Math.abs(value)}e2`); + if (!Number.isFinite(shifted)) { + return value; + } + const rounded = Number(`${Math.round(shifted)}e-2`); + return value < 0 ? -rounded : rounded; +}; + +export const applyBudgetPrecision = >(formValues: TValues): TValues => + Object.fromEntries( + Object.entries(formValues).map(([key, value]) => [ + key, + PRECISION_FIELDS.has(key) && typeof value === "number" ? roundToPrecision(value) : value, + ]), + ) as TValues; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx new file mode 100644 index 00000000000..419da23af3a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx @@ -0,0 +1,139 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import BudgetModal from "./budget_modal"; + +const { createMock } = vi.hoisted(() => ({ createMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/budgets/useBudgets", () => ({ + useCreateBudget: () => ({ mutateAsync: createMock }), +})); + +const FULL_PAYLOAD = { + budget_id: "budget-alpha", + tpm_limit: 500.57, + rpm_limit: 7, + max_budget: 42.57, + budget_duration: "30d", +}; + +const renderModal = () => render(); + +const create = async (user: ReturnType) => + user.click(screen.getByRole("button", { name: "Create Budget" })); + +const openOptionalSettings = async (user: ReturnType) => { + await user.click(screen.getByText("Optional Settings")); + await screen.findByLabelText("Max Budget (USD)"); +}; + +describe("BudgetModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + createMock.mockResolvedValue(undefined); + }); + + it("submits only the mounted fields when Optional Settings stays collapsed", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Budget ID"), "budget-alpha"); + await user.type(screen.getByLabelText("Max Tokens per minute"), "500.567"); + await user.type(screen.getByLabelText("Max Requests per minute"), "7"); + await create(user); + + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); + expect(createMock.mock.calls[0][0]).toEqual({ + budget_id: "budget-alpha", + tpm_limit: 500.57, + rpm_limit: 7, + }); + }); + + it("submits every field once Optional Settings is expanded", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Budget ID"), "budget-alpha"); + await user.type(screen.getByLabelText("Max Tokens per minute"), "500.567"); + await user.type(screen.getByLabelText("Max Requests per minute"), "7"); + + await openOptionalSettings(user); + await user.type(screen.getByLabelText("Max Budget (USD)"), "42.567"); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("monthly")); + + await create(user); + + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); + expect(createMock.mock.calls[0][0]).toEqual(FULL_PAYLOAD); + }); + + it("drops Optional Settings values again when the section is collapsed before submit", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Budget ID"), "budget-alpha"); + + await openOptionalSettings(user); + await user.type(screen.getByLabelText("Max Budget (USD)"), "42.567"); + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("monthly")); + + await user.click(screen.getByText("Optional Settings")); + await waitFor(() => expect(screen.queryByLabelText("Max Budget (USD)")).not.toBeInTheDocument()); + await create(user); + + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); + expect(createMock.mock.calls[0][0]).toEqual({ budget_id: "budget-alpha" }); + }); + + it("submits a cleared number field as null", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Budget ID"), "budget-alpha"); + await user.type(screen.getByLabelText("Max Tokens per minute"), "5"); + await user.clear(screen.getByLabelText("Max Tokens per minute")); + await create(user); + + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); + expect(createMock.mock.calls[0][0]).toEqual({ + budget_id: "budget-alpha", + tpm_limit: null, + }); + }); + + it("blocks submit while Budget ID is empty", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Max Tokens per minute"), "5"); + await create(user); + + await waitFor(() => expect(screen.getByLabelText("Budget ID")).toHaveAttribute("aria-invalid", "true")); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("keeps a typed Optional Setting when the section is collapsed and reopened, as antd's store did", async () => { + const user = userEvent.setup(); + renderModal(); + await user.type(screen.getByLabelText("Budget ID"), "probe-budget"); + + await openOptionalSettings(user); + await user.type(screen.getByLabelText("Max Budget (USD)"), "42.5"); + + await user.click(screen.getByText("Optional Settings")); + await user.click(screen.getByText("Optional Settings")); + + expect(await screen.findByLabelText("Max Budget (USD)")).toHaveValue(42.5); + + await create(user); + + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); + expect(createMock.mock.calls[0][0]).toMatchObject({ budget_id: "probe-budget", max_budget: 42.5 }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx index b4658aa9991..a0ca8bc7ae9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx @@ -1,33 +1,65 @@ +import { ChevronRight } from "lucide-react"; import React from "react"; -import { TextInput, Accordion, AccordionHeader, AccordionBody } from "@tremor/react"; -import { Button as Button2, Modal, Form, InputNumber, Select } from "antd"; +import { Modal } from "antd"; +import { z } from "zod/v4"; import { useCreateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { applyBudgetPrecision } from "./budgetPrecision"; import NotificationsManager from "@/components/molecules/notifications_manager"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useZodForm } from "@/lib/forms/useZodForm"; + +const budgetShape = { + budget_id: z.string().min(1, "Please input a human-friendly name for the budget"), + tpm_limit: z.number().nullish(), + rpm_limit: z.number().nullish(), + max_budget: z.number().nullish(), + budget_duration: z.string().nullish(), +}; + +const budgetSchema = z.object(budgetShape); + +type BudgetFormValues = z.output; + +const BUDGET_DURATION_OPTIONS = [ + { value: "24h", label: "daily" }, + { value: "7d", label: "weekly" }, + { value: "30d", label: "monthly" }, +]; interface BudgetModalProps { isModalVisible: boolean; setIsModalVisible: React.Dispatch>; } const BudgetModal: React.FC = ({ isModalVisible, setIsModalVisible }) => { - const [form] = Form.useForm(); + const [optionalSettingsOpen, setOptionalSettingsOpen] = React.useState(false); + const form = useZodForm(budgetSchema, { defaultValues: { budget_id: "" } }); const createBudget = useCreateBudget(); const handleOk = () => { setIsModalVisible(false); - form.resetFields(); + form.reset(); }; const handleCancel = () => { setIsModalVisible(false); - form.resetFields(); + form.reset(); }; - const handleCreate = async (formValues: Record) => { + const handleCreate = async (formValues: BudgetFormValues) => { try { NotificationsManager.info("Making API Call"); - await createBudget.mutateAsync(formValues); + await createBudget.mutateAsync( + applyBudgetPrecision( + optionalSettingsOpen ? formValues : { ...formValues, max_budget: undefined, budget_duration: undefined }, + ), + ); NotificationsManager.success("Budget Created"); - form.resetFields(); + form.reset(); setIsModalVisible(false); } catch (error) { console.error("Error creating the budget:", error); @@ -44,51 +76,93 @@ const BudgetModal: React.FC = ({ isModalVisible, setIsModalVis onOk={handleOk} onCancel={handleCancel} > -
- <> - + + - - - - - - - - + {({ ref, ...field }) => } + + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + - - + + Optional Settings - - - - - - - - - - - + + + + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + )} + + + +
- Create Budget +
- + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx new file mode 100644 index 00000000000..207f789e7f0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { components } from "@/lib/http/schema"; + +import EditBudgetModal from "./edit_budget_modal"; + +const { updateMock } = vi.hoisted(() => ({ updateMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/budgets/useBudgets", () => ({ + useUpdateBudget: () => ({ mutateAsync: updateMock }), +})); + +type BudgetItem = components["schemas"]["BudgetListItem"]; + +const EXISTING_BUDGET: BudgetItem = { + budget_id: "budget-alpha", + max_budget: 100, + budget_duration: "7d", + tpm_limit: 1000, + rpm_limit: 10, + soft_budget: 25, + budget_reset_at: "2026-02-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", +}; + +const renderModal = () => + render(); + +const save = async (user: ReturnType) => + user.click(screen.getByRole("button", { name: "Save" })); + +const openOptionalSettings = async (user: ReturnType) => { + await user.click(screen.getByText("Optional Settings")); + await screen.findByLabelText("Max Budget (USD)"); +}; + +describe("EditBudgetModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + updateMock.mockResolvedValue(undefined); + }); + + it("submits only the mounted fields when Optional Settings stays collapsed", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.clear(screen.getByLabelText("Max Tokens per minute")); + await user.type(screen.getByLabelText("Max Tokens per minute"), "500.567"); + await save(user); + + await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(1)); + expect(updateMock.mock.calls[0][0]).toEqual({ + budget_id: "budget-alpha", + tpm_limit: 500.57, + rpm_limit: 10, + }); + }); + + it("submits every field once Optional Settings is expanded", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.clear(screen.getByLabelText("Max Tokens per minute")); + await user.type(screen.getByLabelText("Max Tokens per minute"), "500.567"); + await user.clear(screen.getByLabelText("Max Requests per minute")); + await user.type(screen.getByLabelText("Max Requests per minute"), "7"); + + await openOptionalSettings(user); + await user.clear(screen.getByLabelText("Max Budget (USD)")); + await user.type(screen.getByLabelText("Max Budget (USD)"), "42.567"); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("monthly")); + + await save(user); + + await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(1)); + const expected = { + budget_id: "budget-alpha", + tpm_limit: 500.57, + rpm_limit: 7, + max_budget: 42.57, + budget_duration: "30d", + }; + + expect(updateMock.mock.calls[0][0]).toEqual(expected); + }); + + it("keeps a typed Optional Setting when the section is collapsed and reopened, as antd's store did", async () => { + const user = userEvent.setup(); + renderModal(); + + await openOptionalSettings(user); + const maxBudget = screen.getByLabelText("Max Budget (USD)"); + await user.clear(maxBudget); + await user.type(maxBudget, "99.25"); + + await user.click(screen.getByText("Optional Settings")); + await user.click(screen.getByText("Optional Settings")); + + expect(await screen.findByLabelText("Max Budget (USD)")).toHaveValue(99.25); + + await save(user); + + await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(1)); + expect(updateMock.mock.calls[0][0]).toMatchObject({ max_budget: 99.25 }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx index 98a6996948f..1ed4e6b4f21 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx @@ -1,9 +1,36 @@ +import { ChevronRight } from "lucide-react"; import React, { useEffect } from "react"; -import { TextInput, Accordion, AccordionHeader, AccordionBody } from "@tremor/react"; -import { Button as Button2, Modal, Form, InputNumber, Select } from "antd"; +import { Modal } from "antd"; +import { useForm } from "react-hook-form"; import { useUpdateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { applyBudgetPrecision } from "./budgetPrecision"; import NotificationsManager from "@/components/molecules/notifications_manager"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +type EditBudgetFormValues = Pick< + budgetItem, + "budget_id" | "tpm_limit" | "rpm_limit" | "max_budget" | "budget_duration" +>; + +const toFormValues = (budget: budgetItem): EditBudgetFormValues => ({ + budget_id: budget.budget_id, + tpm_limit: budget.tpm_limit, + rpm_limit: budget.rpm_limit, + max_budget: budget.max_budget, + budget_duration: budget.budget_duration, +}); + +const BUDGET_DURATION_OPTIONS = [ + { value: "24h", label: "daily" }, + { value: "7d", label: "weekly" }, + { value: "30d", label: "monthly" }, +]; interface EditBudgetModalProps { isModalVisible: boolean; @@ -11,29 +38,34 @@ interface EditBudgetModalProps { existingBudget: budgetItem; } const EditBudgetModal: React.FC = ({ isModalVisible, setIsModalVisible, existingBudget }) => { - const [form] = Form.useForm(); + const [optionalSettingsOpen, setOptionalSettingsOpen] = React.useState(false); + const form = useForm({ defaultValues: toFormValues(existingBudget) }); const updateBudget = useUpdateBudget(); useEffect(() => { - form.setFieldsValue(existingBudget); + form.reset(toFormValues(existingBudget)); }, [existingBudget, form]); const handleOk = () => { setIsModalVisible(false); - form.resetFields(); + form.reset(); }; const handleCancel = () => { setIsModalVisible(false); - form.resetFields(); + form.reset(); }; - const handleUpdate = async (formValues: Record) => { + const handleUpdate = async (formValues: EditBudgetFormValues) => { try { NotificationsManager.info("Making API Call"); - await updateBudget.mutateAsync(formValues); + await updateBudget.mutateAsync( + applyBudgetPrecision( + optionalSettingsOpen ? formValues : { ...formValues, max_budget: undefined, budget_duration: undefined }, + ), + ); NotificationsManager.success("Budget Updated"); - form.resetFields(); + form.reset(); setIsModalVisible(false); } catch (error) { console.error("Error updating the budget:", error); @@ -43,48 +75,93 @@ const EditBudgetModal: React.FC = ({ isModalVisible, setIs return ( -
- <> - - - - - - - - - + + + + {({ ref, ...field }) => } + + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + - - + + Optional Settings - - - - - - - - - - - + + + + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + )} + + + +
- Save +
-
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index 686f7130024..3be4a0fa085 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -1,13 +1,29 @@ import React, { useState } from "react"; -import { Modal, Form, Input, Select } from "antd"; +import { Modal } from "antd"; +import { CircleHelp } from "lucide-react"; +import { z } from "zod/v4"; import MessageManager from "@/components/molecules/message_manager"; -import { Button } from "@tremor/react"; import { registerClaudeCodePlugin } from "@/components/networking"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useZodForm } from "@/lib/forms/useZodForm"; import { validatePluginName, isValidSemanticVersion, isValidEmail, - isValidUrl, parseKeywords, parseSkillSource, isValidSubPath, @@ -15,9 +31,6 @@ import { } from "@/components/claude_code_plugins/helpers"; import { PluginAuthor, PluginSource, SkillRegisterRequest } from "@/components/claude_code_plugins/types"; -const { TextArea } = Input; -const { Option } = Select; - interface AddPluginFormProps { visible: boolean; onClose: () => void; @@ -25,24 +38,51 @@ interface AddPluginFormProps { onSuccess: () => void; } -interface AddPluginFormValues { - name: string; - skillUrl?: string; - subPath?: string; - version?: string; - description?: string; - authorName?: string; - authorEmail?: string; - homepage?: string; - category?: string; - keywords?: string; - domain?: string; - namespace?: string; -} +const addPluginShape = { + skillUrl: z.string().min(1, "Please enter a repository URL"), + subPath: z + .string() + .refine( + (value) => !value || isValidSubPath(value), + "Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)", + ), + name: z + .string() + .min(1, "Please enter skill name") + .regex(/^[a-z0-9-]+$/, "Name must be kebab-case (lowercase, numbers, hyphens only)"), + domain: z.string(), + namespace: z.string(), + description: z.string(), + category: z.string(), + keywords: z.string(), + version: z.string(), + authorName: z.string(), + authorEmail: z + .string() + .refine((value) => value === "" || z.email().safeParse(value).success, "Please enter a valid email"), +}; + +const addPluginSchema = z.object(addPluginShape); + +type AddPluginFormValues = z.infer; + +const EMPTY_VALUES: AddPluginFormValues = { + skillUrl: "", + subPath: "", + name: "", + domain: "", + namespace: "", + description: "", + category: "", + keywords: "", + version: "", + authorName: "", + authorEmail: "", +}; const buildAuthor = (values: AddPluginFormValues): PluginAuthor | undefined => { - const name = values.authorName?.trim(); - const email = values.authorEmail?.trim(); + const name = values.authorName.trim(); + const email = values.authorEmail.trim(); if (!name) { return undefined; } @@ -57,7 +97,6 @@ const buildRegisterRequest = (values: AddPluginFormValues, source: PluginSource) ...(values.version ? { version: values.version.trim() } : {}), ...(values.description ? { description: values.description.trim() } : {}), ...(author ? { author } : {}), - ...(values.homepage ? { homepage: values.homepage.trim() } : {}), ...(values.category ? { category: values.category } : {}), ...(values.keywords ? { keywords: parseKeywords(values.keywords) } : {}), ...(values.domain ? { domain: values.domain.trim() } : {}), @@ -76,8 +115,18 @@ const PREDEFINED_CATEGORIES = [ "Documentation", ]; +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + const AddPluginForm: React.FC = ({ visible, onClose, accessToken, onSuccess }) => { - const [form] = Form.useForm(); + const form = useZodForm(addPluginSchema, { defaultValues: EMPTY_VALUES }); const [isSubmitting, setIsSubmitting] = useState(false); const [urlPreview, setUrlPreview] = useState(null); const [urlEncodesSubdir, setUrlEncodesSubdir] = useState(false); @@ -85,24 +134,16 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT const recomputePreview = (skillUrl: string, subPath: string) => { const encodesSubdir = parseSkillSource(skillUrl)?.parsed.source === "git-subdir"; setUrlEncodesSubdir(encodesSubdir); - if (encodesSubdir && form.getFieldValue("subPath")) { - form.setFieldsValue({ subPath: "" }); + if (encodesSubdir && form.getValues("subPath")) { + form.setValue("subPath", ""); } const preview = parseSkillSource(skillUrl, encodesSubdir ? undefined : subPath); setUrlPreview(preview); - if (preview && !form.getFieldValue("name")) { - form.setFieldsValue({ name: preview.suggestedName }); + if (preview && !form.getValues("name")) { + form.setValue("name", preview.suggestedName); } }; - const handleUrlChange = (e: React.ChangeEvent) => { - recomputePreview(e.target.value, form.getFieldValue("subPath") ?? ""); - }; - - const handleSubPathChange = (e: React.ChangeEvent) => { - recomputePreview(form.getFieldValue("skillUrl") ?? "", e.target.value); - }; - const handleSubmit = async (values: AddPluginFormValues) => { if (!accessToken) { MessageManager.error("No access token available"); @@ -129,16 +170,11 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT return; } - if (values.homepage && !isValidUrl(values.homepage)) { - MessageManager.error("Invalid homepage URL format"); - return; - } - setIsSubmitting(true); try { await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed)); MessageManager.success("Skill registered successfully"); - form.resetFields(); + form.reset(EMPTY_VALUES); setUrlPreview(null); setUrlEncodesSubdir(false); onSuccess(); @@ -152,7 +188,7 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT }; const handleCancel = () => { - form.resetFields(); + form.reset(EMPTY_VALUES); setUrlPreview(null); setUrlEncodesSubdir(false); onClose(); @@ -160,150 +196,192 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT return ( -
- {/* Smart URL Input */} - - - + + + + + {({ ref, onChange, ...field }) => ( + { + onChange(event); + recomputePreview(event.target.value, form.getValues("subPath")); + }} + /> + )} + - {/* Optional subfolder for monorepos */} - - !value || isValidSubPath(value) - ? Promise.resolve() - : Promise.reject( - new Error( - "Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)", - ), - ), - }, - ]} - tooltip="Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root." - extra={urlEncodesSubdir ? "The URL already points to a subfolder, so this field is disabled" : undefined} - > - - + + {({ ref, onChange, ...field }) => ( + { + onChange(event); + recomputePreview(form.getValues("skillUrl"), event.target.value); + }} + disabled={urlEncodesSubdir} + /> + )} + - {/* Parsed preview */} - {urlPreview && ( -
- Detected: {urlPreview.label} -
- )} + {urlPreview && ( +
+ Detected: {urlPreview.label} +
+ )} - {/* Skill Name */} - - - + + {({ ref, ...field }) => } + - {/* Domain and Namespace — side by side */} -
- - - - - - -
+
+ + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => } + +
- {/* Description */} - -