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)/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; } 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 */} - -