mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/viktor-test-finding-255255
This commit is contained in:
commit
da8e2c4d99
8 changed files with 807 additions and 264 deletions
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue