Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/viktor-test-finding-255255

This commit is contained in:
Yuneng Jiang 2026-08-18 09:02:26 -07:00
commit da8e2c4d99
No known key found for this signature in database
8 changed files with 807 additions and 264 deletions

View file

@ -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 });
});
});

View file

@ -0,0 +1,18 @@
const PRECISION_FIELDS: ReadonlySet<string> = 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 = <TValues extends Record<string, unknown>>(formValues: TValues): TValues =>
Object.fromEntries(
Object.entries(formValues).map(([key, value]) => [
key,
PRECISION_FIELDS.has(key) && typeof value === "number" ? roundToPrecision(value) : value,
]),
) as TValues;

View file

@ -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(<BudgetModal isModalVisible={true} setIsModalVisible={vi.fn()} />);
const create = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("button", { name: "Create Budget" }));
const openOptionalSettings = async (user: ReturnType<typeof userEvent.setup>) => {
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 });
});
});

View file

@ -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<typeof budgetSchema>;
const BUDGET_DURATION_OPTIONS = [
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
{ value: "30d", label: "monthly" },
];
interface BudgetModalProps {
isModalVisible: boolean;
setIsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
const BudgetModal: React.FC<BudgetModalProps> = ({ 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<string, any>) => {
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<BudgetModalProps> = ({ isModalVisible, setIsModalVis
onOk={handleOk}
onCancel={handleCancel}
>
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<>
<Form.Item
label="Budget ID"
<form onSubmit={form.handleSubmit(handleCreate)} noValidate>
<FieldGroup>
<FormField
control={form.control}
name="budget_id"
rules={[
{
required: true,
message: "Please input a human-friendly name for the budget",
},
]}
help="A human-friendly name for the budget"
label="Budget ID"
description="A human-friendly name for the budget"
>
<TextInput placeholder="" />
</Form.Item>
<Form.Item label="Max Tokens per minute" name="tpm_limit" help="Default is model limit.">
<InputNumber step={1} precision={2} width={200} />
</Form.Item>
<Form.Item label="Max Requests per minute" name="rpm_limit" help="Default is model limit.">
<InputNumber step={1} precision={2} width={200} />
</Form.Item>
{({ ref, ...field }) => <Input {...field} ref={ref} value={field.value ?? ""} placeholder="" />}
</FormField>
<FormField
control={form.control}
name="tpm_limit"
label="Max Tokens per minute"
description="Default is model limit."
>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
/>
)}
</FormField>
<FormField
control={form.control}
name="rpm_limit"
label="Max Requests per minute"
description="Default is model limit."
>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
/>
)}
</FormField>
<Accordion className="mt-20 mb-8">
<AccordionHeader>
<Collapsible open={optionalSettingsOpen} onOpenChange={setOptionalSettingsOpen} className="mt-20 mb-8">
<CollapsibleTrigger className="group flex w-full items-center justify-between py-2 text-left">
<b>Optional Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item label="Max Budget (USD)" name="max_budget">
<InputNumber step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item className="mt-8" label="Reset Budget" name="budget_duration">
<Select defaultValue={null} placeholder="n/a">
<Select.Option value="24h">daily</Select.Option>
<Select.Option value="7d">weekly</Select.Option>
<Select.Option value="30d">monthly</Select.Option>
</Select>
</Form.Item>
</AccordionBody>
</Accordion>
</>
<ChevronRight className="size-4 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step={0.01}
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
/>
)}
</FormField>
<FormField className="mt-8" control={form.control} name="budget_duration" label="Reset Budget">
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select items={BUDGET_DURATION_OPTIONS} value={value ?? null} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
<SelectValue placeholder="n/a" />
</SelectTrigger>
<SelectContent>
{BUDGET_DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
</CollapsibleContent>
</Collapsible>
</FieldGroup>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Create Budget</Button2>
<Button type="submit">Create Budget</Button>
</div>
</Form>
</form>
</Modal>
);
};

View file

@ -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(<EditBudgetModal isModalVisible={true} setIsModalVisible={vi.fn()} existingBudget={EXISTING_BUDGET} />);
const save = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("button", { name: "Save" }));
const openOptionalSettings = async (user: ReturnType<typeof userEvent.setup>) => {
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 });
});
});

View file

@ -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<EditBudgetModalProps> = ({ isModalVisible, setIsModalVisible, existingBudget }) => {
const [form] = Form.useForm();
const [optionalSettingsOpen, setOptionalSettingsOpen] = React.useState(false);
const form = useForm<EditBudgetFormValues>({ 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<string, any>) => {
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<EditBudgetModalProps> = ({ isModalVisible, setIs
return (
<Modal title="Edit Budget" open={isModalVisible} width={800} footer={null} onOk={handleOk} onCancel={handleCancel}>
<Form
form={form}
onFinish={handleUpdate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
initialValues={existingBudget}
>
<>
<Form.Item label="Budget ID" name="budget_id" help="Budget ID cannot be changed after creation">
<TextInput placeholder="" disabled={true} />
</Form.Item>
<Form.Item label="Max Tokens per minute" name="tpm_limit" help="Default is model limit.">
<InputNumber step={1} precision={2} width={200} />
</Form.Item>
<Form.Item label="Max Requests per minute" name="rpm_limit" help="Default is model limit.">
<InputNumber step={1} precision={2} width={200} />
</Form.Item>
<form onSubmit={form.handleSubmit(handleUpdate)} noValidate>
<FieldGroup>
<FormField
control={form.control}
name="budget_id"
label="Budget ID"
description="Budget ID cannot be changed after creation"
>
{({ ref, ...field }) => <Input {...field} ref={ref} value={field.value ?? ""} disabled />}
</FormField>
<FormField
control={form.control}
name="tpm_limit"
label="Max Tokens per minute"
description="Default is model limit."
>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
/>
)}
</FormField>
<FormField
control={form.control}
name="rpm_limit"
label="Max Requests per minute"
description="Default is model limit."
>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
/>
)}
</FormField>
<Accordion className="mt-20 mb-8">
<AccordionHeader>
<Collapsible open={optionalSettingsOpen} onOpenChange={setOptionalSettingsOpen} className="mt-20 mb-8">
<CollapsibleTrigger className="group flex w-full items-center justify-between py-2 text-left">
<b>Optional Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item label="Max Budget (USD)" name="max_budget">
<InputNumber step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item className="mt-8" label="Reset Budget" name="budget_duration">
<Select defaultValue={null} placeholder="n/a">
<Select.Option value="24h">daily</Select.Option>
<Select.Option value="7d">weekly</Select.Option>
<Select.Option value="30d">monthly</Select.Option>
</Select>
</Form.Item>
</AccordionBody>
</Accordion>
</>
<ChevronRight className="size-4 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step={0.01}
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
/>
)}
</FormField>
<FormField className="mt-8" control={form.control} name="budget_duration" label="Reset Budget">
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select items={BUDGET_DURATION_OPTIONS} value={value ?? null} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
<SelectValue placeholder="n/a" />
</SelectTrigger>
<SelectContent>
{BUDGET_DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
</CollapsibleContent>
</Collapsible>
</FieldGroup>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Save</Button2>
<Button type="submit">Save</Button>
</div>
</Form>
</form>
</Modal>
);
};

View file

@ -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;
}

View file

@ -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<typeof addPluginSchema>;
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}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
const [form] = Form.useForm();
const form = useZodForm(addPluginSchema, { defaultValues: EMPTY_VALUES });
const [isSubmitting, setIsSubmitting] = useState(false);
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
const [urlEncodesSubdir, setUrlEncodesSubdir] = useState(false);
@ -85,24 +134,16 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ 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<HTMLInputElement>) => {
recomputePreview(e.target.value, form.getFieldValue("subPath") ?? "");
};
const handleSubPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<AddPluginFormProps> = ({ 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<AddPluginFormProps> = ({ visible, onClose, accessT
};
const handleCancel = () => {
form.resetFields();
form.reset(EMPTY_VALUES);
setUrlPreview(null);
setUrlEncodesSubdir(false);
onClose();
@ -160,150 +196,192 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
return (
<Modal title="Add New Skill" open={visible} onCancel={handleCancel} footer={null} width={700} className="top-8">
<Form form={form} layout="vertical" onFinish={handleSubmit} className="mt-4">
{/* Smart URL Input */}
<Form.Item
label="Repository URL"
name="skillUrl"
rules={[{ required: true, message: "Please enter a repository URL" }]}
tooltip="Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host. E.g. github.com/org/repo, gitlab.com/org/repo, or github.com/org/repo/tree/main/my-skill"
>
<Input
placeholder="https://github.com/org/repo or https://gitlab.com/org/repo"
className="rounded-lg"
onChange={handleUrlChange}
/>
</Form.Item>
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate className="mt-4">
<FieldGroup>
<FormField
control={form.control}
name="skillUrl"
label={labelWithHint(
"Repository URL",
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host. E.g. github.com/org/repo, gitlab.com/org/repo, or github.com/org/repo/tree/main/my-skill",
)}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="https://github.com/org/repo or https://gitlab.com/org/repo"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(event.target.value, form.getValues("subPath"));
}}
/>
)}
</FormField>
{/* Optional subfolder for monorepos */}
<Form.Item
label="Subfolder path (Optional)"
name="subPath"
rules={[
{
validator: (_, value) =>
!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}
>
<Input
placeholder="plugins/my-skill"
className="rounded-lg"
onChange={handleSubPathChange}
disabled={urlEncodesSubdir}
/>
</Form.Item>
<FormField
control={form.control}
name="subPath"
label={labelWithHint(
"Subfolder path (Optional)",
"Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
)}
description={
urlEncodesSubdir ? "The URL already points to a subfolder, so this field is disabled" : undefined
}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="plugins/my-skill"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(form.getValues("skillUrl"), event.target.value);
}}
disabled={urlEncodesSubdir}
/>
)}
</FormField>
{/* Parsed preview */}
{urlPreview && (
<div className="mb-4 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
Detected: {urlPreview.label}
</div>
)}
{urlPreview && (
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300">
Detected: {urlPreview.label}
</div>
)}
{/* Skill Name */}
<Form.Item
label="Skill Name"
name="name"
rules={[
{ required: true, message: "Please enter skill name" },
{
pattern: /^[a-z0-9-]+$/,
message: "Name must be kebab-case (lowercase, numbers, hyphens only)",
},
]}
tooltip="Unique identifier in kebab-case format (e.g., my-skill)"
>
<Input placeholder="my-skill" className="rounded-lg" />
</Form.Item>
<FormField
control={form.control}
name="name"
label={labelWithHint("Skill Name", "Unique identifier in kebab-case format (e.g., my-skill)")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="my-skill" className="rounded-lg" />}
</FormField>
{/* Domain and Namespace — side by side */}
<div className="flex gap-4">
<Form.Item
label="Domain (Optional)"
name="domain"
tooltip="Top-level grouping in the Skill Hub (e.g., Productivity)"
className="flex-1"
>
<Input placeholder="Productivity" className="rounded-lg" />
</Form.Item>
<Form.Item
label="Namespace (Optional)"
name="namespace"
tooltip="Sub-grouping within domain (e.g., workflows)"
className="flex-1"
>
<Input placeholder="workflows" className="rounded-lg" />
</Form.Item>
</div>
<div className="flex gap-4">
<FormField
control={form.control}
name="domain"
label={labelWithHint("Domain (Optional)", "Top-level grouping in the Skill Hub (e.g., Productivity)")}
className="flex-1"
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder="Productivity" className="rounded-lg" />
)}
</FormField>
<FormField
control={form.control}
name="namespace"
label={labelWithHint("Namespace (Optional)", "Sub-grouping within domain (e.g., workflows)")}
className="flex-1"
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="workflows" className="rounded-lg" />}
</FormField>
</div>
{/* Description */}
<Form.Item label="Description (Optional)" name="description" tooltip="Brief description of what the skill does">
<TextArea rows={3} placeholder="A skill that helps with..." maxLength={500} className="rounded-lg" />
</Form.Item>
<FormField
control={form.control}
name="description"
label={labelWithHint("Description (Optional)", "Brief description of what the skill does")}
>
{({ ref, ...field }) => (
<Textarea
{...field}
ref={ref}
rows={3}
placeholder="A skill that helps with..."
maxLength={500}
className="rounded-lg"
/>
)}
</FormField>
{/* Category */}
<Form.Item label="Category (Optional)" name="category" tooltip="Select a category or enter a custom one">
<Select
placeholder="Select or type a category"
allowClear
showSearch
optionFilterProp="children"
className="rounded-lg"
>
{PREDEFINED_CATEGORIES.map((cat) => (
<Option key={cat} value={cat}>
{cat}
</Option>
))}
</Select>
</Form.Item>
<FormField
control={form.control}
name="category"
label={labelWithHint("Category (Optional)", "Select a category or enter a custom one")}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Combobox
items={PREDEFINED_CATEGORIES}
value={value === "" ? null : value}
onValueChange={(category: string | null) => onChange(category ?? "")}
>
<ComboboxInput
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder="Select or type a category"
className="w-full rounded-lg"
showClear={value !== ""}
/>
<ComboboxContent>
<ComboboxEmpty>No matching categories</ComboboxEmpty>
<ComboboxList>
{(category: string) => (
<ComboboxItem key={category} value={category}>
{category}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</FormField>
{/* Keywords */}
<Form.Item label="Keywords (Optional)" name="keywords" tooltip="Comma-separated list of keywords for search">
<Input placeholder="search, web, api" className="rounded-lg" />
</Form.Item>
<FormField
control={form.control}
name="keywords"
label={labelWithHint("Keywords (Optional)", "Comma-separated list of keywords for search")}
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder="search, web, api" className="rounded-lg" />
)}
</FormField>
{/* Version */}
<Form.Item label="Version (Optional)" name="version" tooltip="Semantic version (e.g., 1.0.0)">
<Input placeholder="1.0.0" className="rounded-lg" />
</Form.Item>
<FormField
control={form.control}
name="version"
label={labelWithHint("Version (Optional)", "Semantic version (e.g., 1.0.0)")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="1.0.0" className="rounded-lg" />}
</FormField>
{/* Author Name */}
<Form.Item label="Author Name (Optional)" name="authorName" tooltip="Name of the skill author or organization">
<Input placeholder="Your Name or Organization" className="rounded-lg" />
</Form.Item>
<FormField
control={form.control}
name="authorName"
label={labelWithHint("Author Name (Optional)", "Name of the skill author or organization")}
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder="Your Name or Organization" className="rounded-lg" />
)}
</FormField>
{/* Author Email */}
<Form.Item
label="Author Email (Optional)"
name="authorEmail"
rules={[{ type: "email", message: "Please enter a valid email" }]}
tooltip="Contact email for the skill author"
>
<Input type="email" placeholder="author@example.com" className="rounded-lg" />
</Form.Item>
<FormField
control={form.control}
name="authorEmail"
label={labelWithHint("Author Email (Optional)", "Contact email for the skill author")}
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} type="email" placeholder="author@example.com" className="rounded-lg" />
)}
</FormField>
</FieldGroup>
{/* Submit Buttons */}
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{isSubmitting ? "Adding..." : "Add Skill"}
</Button>
</div>
</Form.Item>
</Form>
</form>
</TooltipProvider>
</Modal>
);
};