mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
refactor(ui): migrate CloudZero and cost tracking forms to react-hook-form and shadcn (#37312)
* refactor(ui): migrate CloudZero and cost tracking forms to react-hook-form and shadcn Moves four forms off antd Form onto react-hook-form with the shadcn field primitives, keeping the request payloads byte identical. The two CloudZero modals were near duplicates, so the payload builder and the API key input now live in shared modules next to them. The Update modal keeps its redaction behaviour: the key field arrives empty with a "leave empty to keep existing" hint, and an untouched save omits api_key from the request so the stored secret survives. There is a test that fails if that regresses. add_provider_form had no Form instance of its own, and its Form.Item wrappers carried no name, so nothing was registered in the parent store. The parent in cost_tracking_settings still owns an antd Form element, which stays for now, and the migrated button keeps type="submit" so the parent's onFinish path behaves exactly as before. Each unit got characterization tests written against the antd version first, then re-run unedited against the migration. Payload parity was also checked side by side with toStrictEqual across seven scenarios. * test(ui): guard the CloudZero null connection id against a zod type error The proxy returns connection_id as null rather than omitting it, and a plain z.string() rejects null. The seeding coalesces it to "" so an untouched save reports the friendly required message instead of "expected string, received null". Dropping that coalesce fails this test.
This commit is contained in:
parent
b134c61300
commit
e20e31e985
12 changed files with 851 additions and 254 deletions
|
|
@ -191,11 +191,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -204,9 +199,6 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import PromptCompressionTab from "./PromptCompressionTab";
|
||||
|
||||
const createGuardrailCall = vi.fn();
|
||||
const getGuardrailsList = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createGuardrailCall: (...args: unknown[]) => createGuardrailCall(...args),
|
||||
getGuardrailsList: (...args: unknown[]) => getGuardrailsList(...args),
|
||||
}));
|
||||
|
||||
const submittedPayload = (): Record<string, unknown> => {
|
||||
expect(createGuardrailCall).toHaveBeenCalledTimes(1);
|
||||
return createGuardrailCall.mock.calls[0][1] as Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("PromptCompressionTab submit payload", () => {
|
||||
beforeEach(() => {
|
||||
createGuardrailCall.mockClear().mockResolvedValue({});
|
||||
getGuardrailsList.mockClear().mockResolvedValue({ guardrails: [] });
|
||||
});
|
||||
|
||||
it("sends the trimmed name and api base with default_on true", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptCompressionTab accessToken="test-token" />);
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), " headroom-compression ");
|
||||
await user.type(screen.getByLabelText("Headroom API base"), " https://headroom.example.com ");
|
||||
await user.click(screen.getByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(submittedPayload()).toEqual({
|
||||
guardrail_name: "headroom-compression",
|
||||
litellm_params: {
|
||||
guardrail: "headroom",
|
||||
mode: "pre_call",
|
||||
api_base: "https://headroom.example.com",
|
||||
default_on: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createGuardrailCall.mock.calls[0][0]).toBe("test-token");
|
||||
});
|
||||
|
||||
it("sends default_on false once the apply-to-all switch is turned off", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptCompressionTab accessToken="test-token" />);
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "headroom-optin");
|
||||
await user.type(screen.getByLabelText("Headroom API base"), "https://headroom.example.com");
|
||||
await user.click(screen.getByLabelText("Apply to all requests"));
|
||||
await user.click(screen.getByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(submittedPayload()).toEqual({
|
||||
guardrail_name: "headroom-optin",
|
||||
litellm_params: {
|
||||
guardrail: "headroom",
|
||||
mode: "pre_call",
|
||||
api_base: "https://headroom.example.com",
|
||||
default_on: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks submission and shows both required messages when the form is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptCompressionTab accessToken="test-token" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
expect(await screen.findByText("Name is required")).toBeInTheDocument();
|
||||
expect(screen.getByText("API base is required")).toBeInTheDocument();
|
||||
expect(createGuardrailCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits when Enter is pressed inside a text field", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptCompressionTab accessToken="test-token" />);
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "headroom-compression");
|
||||
await user.type(screen.getByLabelText("Headroom API base"), "https://headroom.example.com{Enter}");
|
||||
|
||||
await vi.waitFor(() => expect(createGuardrailCall).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("clears the name and restores the default switch state after a successful create", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptCompressionTab accessToken="test-token" />);
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "headroom-compression");
|
||||
await user.type(screen.getByLabelText("Headroom API base"), "https://headroom.example.com");
|
||||
await user.click(screen.getByLabelText("Apply to all requests"));
|
||||
await user.click(screen.getByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByLabelText("Name")).toHaveValue(""));
|
||||
expect(screen.getByLabelText("Headroom API base")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Apply to all requests")).toBeChecked();
|
||||
expect(getGuardrailsList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the always-on and opt-in badges for the guardrails it lists", async () => {
|
||||
getGuardrailsList.mockResolvedValue({
|
||||
guardrails: [
|
||||
{
|
||||
guardrail_id: "g-1",
|
||||
guardrail_name: "always-on-one",
|
||||
litellm_params: { guardrail: "headroom", api_base: "https://a.example.com", default_on: true },
|
||||
},
|
||||
{
|
||||
guardrail_id: "g-2",
|
||||
guardrail_name: "opt-in-one",
|
||||
litellm_params: { guardrail: "headroom", api_base: "https://b.example.com", default_on: false },
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<PromptCompressionTab accessToken="test-token" />);
|
||||
|
||||
expect(await screen.findByText("Always on")).toBeInTheDocument();
|
||||
expect(screen.getByText("Opt-in")).toBeInTheDocument();
|
||||
expect(screen.getByText("https://a.example.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Form, Input, Switch } from "antd";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { createGuardrailCall, getGuardrailsList } from "@/components/networking";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
buildCompressionGuardrailPayload,
|
||||
|
|
@ -17,14 +26,32 @@ interface PromptCompressionTabProps {
|
|||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface CompressionFormValues {
|
||||
name: string;
|
||||
apiBase: string;
|
||||
defaultOn: boolean;
|
||||
}
|
||||
const compressionSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
apiBase: z.string().min(1, "API base is required"),
|
||||
defaultOn: z.boolean(),
|
||||
});
|
||||
|
||||
type CompressionFormValues = z.infer<typeof compressionSchema>;
|
||||
|
||||
const EMPTY_VALUES: CompressionFormValues = {
|
||||
name: "",
|
||||
apiBase: "",
|
||||
defaultOn: true,
|
||||
};
|
||||
|
||||
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 PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken }) => {
|
||||
const [form] = Form.useForm<CompressionFormValues>();
|
||||
const form = useZodForm(compressionSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [guardrails, setGuardrails] = useState<GuardrailListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
|
|
@ -61,7 +88,7 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
}),
|
||||
);
|
||||
toast.success("Compression guardrail created");
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_VALUES);
|
||||
await loadGuardrails();
|
||||
} catch (error) {
|
||||
console.error("Failed to create compression guardrail:", error);
|
||||
|
|
@ -85,7 +112,7 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
href="https://docs.litellm.ai/docs/proxy/headroom"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
className="text-blue-600 underline dark:text-blue-400"
|
||||
>
|
||||
Headroom setup docs
|
||||
</a>
|
||||
|
|
@ -97,7 +124,7 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
</p>
|
||||
)}
|
||||
{!isLoading && guardrails.length > 0 && (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
<ul className="divide-y divide-border">
|
||||
{guardrails.map((guardrail) => (
|
||||
<li key={guardrail.guardrail_id} className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
|
|
@ -107,8 +134,8 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
guardrail.litellm_params?.default_on
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{guardrail.litellm_params?.default_on ? "Always on" : "Opt-in"}
|
||||
|
|
@ -125,48 +152,57 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
<CardTitle>Add Headroom compression guardrail</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleAdd}
|
||||
initialValues={{ defaultOn: true }}
|
||||
>
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true, message: "Name is required" }]}>
|
||||
<Input placeholder="headroom-compression" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="apiBase"
|
||||
label="Headroom API base"
|
||||
tooltip="Base URL of your Headroom compression service (LiteLLM calls its /v1/compress endpoint)"
|
||||
extra="The URL where your Headroom compression service is hosted"
|
||||
rules={[{ required: true, message: "API base is required" }]}
|
||||
>
|
||||
<Input placeholder="https://your-headroom-endpoint" />
|
||||
</Form.Item>
|
||||
<Form.Item name="defaultOn" label="Apply to all requests" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<div className="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Applying compression to all requests is available to all users. Enabling it selectively per key or team
|
||||
is a LiteLLM Enterprise feature. Get a trial key{" "}
|
||||
<a
|
||||
href="https://www.litellm.ai/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleAdd)} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="name" label="Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="headroom-compression" />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiBase"
|
||||
label={labelWithHint(
|
||||
"Headroom API base",
|
||||
"Base URL of your Headroom compression service (LiteLLM calls its /v1/compress endpoint)",
|
||||
)}
|
||||
description="The URL where your Headroom compression service is hosted"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
Add guardrail
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="https://your-headroom-endpoint" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="defaultOn" label="Apply to all requests">
|
||||
{({ value, onChange, ref: _ref, ...field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
<div className="mt-6 mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300">
|
||||
Applying compression to all requests is available to all users. Enabling it selectively per key or
|
||||
team is a LiteLLM Enterprise feature. Get a trial key{" "}
|
||||
<a
|
||||
href="https://www.litellm.ai/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="size-4" />}
|
||||
Add guardrail
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
// eslint-disable-next-line no-restricted-imports -- the parent cost_tracking_settings still owns this antd Form, and the point of this test is that AddProviderForm registers nothing in it
|
||||
import { Form } from "antd";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import AddProviderForm from "./add_provider_form";
|
||||
import { DiscountConfig } from "./types";
|
||||
|
||||
const onAddProvider = vi.fn();
|
||||
const onParentFinish = vi.fn();
|
||||
const readParentStore = vi.fn();
|
||||
|
||||
const ParentOwnedForm = () => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form form={form} onFinish={onParentFinish} layout="vertical" className="space-y-6">
|
||||
<AddProviderForm
|
||||
discountConfig={{} as DiscountConfig}
|
||||
selectedProvider="OpenAI"
|
||||
newDiscount="5"
|
||||
onProviderChange={vi.fn()}
|
||||
onDiscountChange={vi.fn()}
|
||||
onAddProvider={() => {
|
||||
readParentStore(form.getFieldsValue());
|
||||
onAddProvider();
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
describe("AddProviderForm inside the antd form its parent owns", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registers no field in the parent FormInstance, so the parent's resetFields is a no-op", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ParentOwnedForm />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add provider discount/i }));
|
||||
|
||||
expect(readParentStore).toHaveBeenCalledTimes(1);
|
||||
expect(readParentStore.mock.calls[0][0]).toEqual({});
|
||||
});
|
||||
|
||||
it("drives both the onAddProvider prop and the parent form submit from one click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ParentOwnedForm />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add provider discount/i }));
|
||||
|
||||
expect(onAddProvider).toHaveBeenCalledTimes(1);
|
||||
expect(onParentFinish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats Enter in the discount field exactly like a click on the add button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ParentOwnedForm />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("5"), "{Enter}");
|
||||
|
||||
await vi.waitFor(() => expect(onParentFinish).toHaveBeenCalledTimes(1));
|
||||
expect(onAddProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,28 @@
|
|||
import React from "react";
|
||||
import { TextInput, Button } from "@tremor/react";
|
||||
import { Select as AntdSelect, Form, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Providers, provider_map } from "@/components/provider_info_helpers";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { Providers, provider_map } from "@/components/provider_info_helpers";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { DiscountConfig } from "./types";
|
||||
|
||||
interface ProviderOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AddProviderFormProps {
|
||||
discountConfig: DiscountConfig;
|
||||
selectedProvider: string | undefined;
|
||||
|
|
@ -15,6 +32,35 @@ interface AddProviderFormProps {
|
|||
onAddProvider: () => void;
|
||||
}
|
||||
|
||||
const PROVIDER_FIELD_ID = "add-provider-discount-provider";
|
||||
const DISCOUNT_FIELD_ID = "add-provider-discount-percentage";
|
||||
|
||||
const providerOptionsWithoutDiscount = (discountConfig: DiscountConfig): ProviderOption[] =>
|
||||
Object.entries(Providers)
|
||||
.filter(([providerEnum]) => {
|
||||
const providerValue = provider_map[providerEnum as keyof typeof provider_map];
|
||||
return !(providerValue && discountConfig[providerValue]);
|
||||
})
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
const selectedProviderOption = (selectedProvider: string | undefined): ProviderOption | null => {
|
||||
if (!selectedProvider) {
|
||||
return null;
|
||||
}
|
||||
const label = Providers[selectedProvider as keyof typeof Providers];
|
||||
return label ? { value: selectedProvider, label } : null;
|
||||
};
|
||||
|
||||
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 AddProviderForm: React.FC<AddProviderFormProps> = ({
|
||||
discountConfig,
|
||||
selectedProvider,
|
||||
|
|
@ -23,79 +69,71 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
|
|||
onDiscountChange,
|
||||
onAddProvider,
|
||||
}) => {
|
||||
const options = providerOptionsWithoutDiscount(discountConfig);
|
||||
const selectedOption = selectedProviderOption(selectedProvider);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Provider
|
||||
<Tooltip title="Select the LLM provider you want to configure a discount for">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please select a provider" }]}
|
||||
>
|
||||
<AntdSelect
|
||||
showSearch
|
||||
placeholder="Select provider"
|
||||
value={selectedProvider}
|
||||
onChange={onProviderChange}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => {
|
||||
const providerValue = provider_map[providerEnum as keyof typeof provider_map];
|
||||
// Only show providers that don't already have a discount configured
|
||||
if (providerValue && discountConfig[providerValue]) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AntdSelect.Option key={providerEnum} value={providerEnum} label={providerDisplayName}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
);
|
||||
})}
|
||||
</AntdSelect>
|
||||
</Form.Item>
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={PROVIDER_FIELD_ID}>
|
||||
{labelWithHint("Provider", "Select the LLM provider you want to configure a discount for")}
|
||||
</FieldLabel>
|
||||
<Combobox
|
||||
items={options}
|
||||
value={selectedOption}
|
||||
onValueChange={(option: ProviderOption | null) => onProviderChange(option?.value)}
|
||||
itemToStringLabel={(option: ProviderOption) => option.label}
|
||||
isItemEqualToValue={(option: ProviderOption, value: ProviderOption) => option.value === value.value}
|
||||
>
|
||||
<ComboboxInput id={PROVIDER_FIELD_ID} placeholder="Select provider" className="w-full">
|
||||
{selectedOption && (
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Logo provider={selectedOption.value} label={selectedOption.label} className="w-5 h-5" />
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</ComboboxInput>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No providers found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: ProviderOption) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
<span className="flex items-center space-x-2">
|
||||
<Logo provider={option.value} label={option.label} className="w-5 h-5" />
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Discount Percentage
|
||||
<Tooltip title="Enter a percentage value (e.g., 5 for 5% discount)">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please enter a discount percentage" }]}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TextInput
|
||||
placeholder="5"
|
||||
value={newDiscount}
|
||||
onValueChange={onDiscountChange}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 flex-1"
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={DISCOUNT_FIELD_ID}>
|
||||
{labelWithHint("Discount Percentage", "Enter a percentage value (e.g., 5 for 5% discount)")}
|
||||
</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={DISCOUNT_FIELD_ID}
|
||||
placeholder="5"
|
||||
value={newDiscount}
|
||||
onChange={(event) => onDiscountChange(event.target.value)}
|
||||
className="flex-1 rounded-lg"
|
||||
/>
|
||||
<span className="text-muted-foreground">%</span>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
|
||||
<Button type="submit" onClick={onAddProvider} disabled={!selectedProvider || !newDiscount}>
|
||||
Add Provider Discount
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||
<Button variant="primary" onClick={onAddProvider} disabled={!selectedProvider || !newDiscount}>
|
||||
Add Provider Discount
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import CloudZeroCreateModal from "./CloudZeroCreateModal";
|
||||
|
||||
const mutate = vi.fn();
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
__esModule: true,
|
||||
default: () => ({ accessToken: "test-token" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate", () => ({
|
||||
useCloudZeroCreate: () => ({ mutate, isPending: false }),
|
||||
}));
|
||||
|
||||
const renderModal = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudZeroCreateModal open={true} onOk={vi.fn()} onCancel={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const submittedPayload = (): Record<string, unknown> => {
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
return mutate.mock.calls[0][0] as Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("CloudZeroCreateModal submit payload", () => {
|
||||
beforeEach(() => {
|
||||
mutate.mockClear();
|
||||
});
|
||||
|
||||
it("sends every filled field verbatim", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByLabelText("CloudZero API Key"), "cz-secret-key");
|
||||
await user.type(screen.getByLabelText("Connection ID"), "conn-42");
|
||||
await user.type(screen.getByLabelText("Timezone"), "America/New_York");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(submittedPayload()).toEqual({
|
||||
connection_id: "conn-42",
|
||||
timezone: "America/New_York",
|
||||
api_key: "cz-secret-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults an untouched timezone to UTC", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByLabelText("CloudZero API Key"), "cz-secret-key");
|
||||
await user.type(screen.getByLabelText("Connection ID"), "conn-42");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(submittedPayload()).toEqual({
|
||||
connection_id: "conn-42",
|
||||
timezone: "UTC",
|
||||
api_key: "cz-secret-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks submission and shows both required messages when the form is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(await screen.findByText("Please enter your CloudZero API key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Please enter your CloudZero connection ID")).toBeInTheDocument();
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit when Enter is pressed inside a text field", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByLabelText("CloudZero API Key"), "cz-secret-key");
|
||||
await user.type(screen.getByLabelText("Connection ID"), "conn-42{Enter}");
|
||||
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
import { Form, Modal, Input } from "antd";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Modal } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import { CloudZeroApiKeyInput, labelWithHint } from "./CloudZeroFormControls";
|
||||
import { buildCloudZeroPayload, EMPTY_CLOUDZERO_FORM_VALUES, type CloudZeroFormValues } from "./cloudZeroPayload";
|
||||
|
||||
interface CloudZeroCreationModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -10,50 +20,38 @@ interface CloudZeroCreationModalProps {
|
|||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
api_key: z.string().min(1, "Please enter your CloudZero API key"),
|
||||
connection_id: z.string().min(1, "Please enter your CloudZero connection ID"),
|
||||
timezone: z.string(),
|
||||
});
|
||||
|
||||
export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZeroCreationModalProps) {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const form = useZodForm(createSchema, { defaultValues: EMPTY_CLOUDZERO_FORM_VALUES });
|
||||
const createMutation = useCloudZeroCreate(accessToken || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
createMutation.mutate(
|
||||
{
|
||||
connection_id: values.connection_id,
|
||||
timezone: values.timezone || "UTC",
|
||||
...(values.api_key && { api_key: values.api_key }),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("CloudZero integration created successfully");
|
||||
form.resetFields();
|
||||
onOk();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.errorFields) {
|
||||
return;
|
||||
}
|
||||
toast.error(error?.message || "Failed to create CloudZero integration");
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
return;
|
||||
}
|
||||
toast.error(error?.message || "Failed to create CloudZero integration");
|
||||
}
|
||||
const handleSubmit = (values: CloudZeroFormValues) => {
|
||||
createMutation.mutate(buildCloudZeroPayload(values), {
|
||||
onSuccess: () => {
|
||||
toast.success("CloudZero integration created successfully");
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
onOk();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create CloudZero integration");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
|
@ -61,7 +59,7 @@ export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZe
|
|||
<Modal
|
||||
title="Create CloudZero Integration"
|
||||
open={open}
|
||||
onOk={handleSubmit}
|
||||
onOk={() => void form.handleSubmit(handleSubmit)()}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={createMutation.isPending}
|
||||
okText={createMutation.isPending ? "Creating..." : "Create"}
|
||||
|
|
@ -73,29 +71,27 @@ export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZe
|
|||
disabled: createMutation.isPending,
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="CloudZero API Key"
|
||||
name="api_key"
|
||||
rules={[{ required: true, message: "Please enter your CloudZero API key" }]}
|
||||
>
|
||||
<Input.Password placeholder="Enter your CloudZero API key" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Connection ID"
|
||||
name="connection_id"
|
||||
rules={[{ required: true, message: "Please enter your CloudZero connection ID" }]}
|
||||
>
|
||||
<Input placeholder="Enter your CloudZero connection ID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
tooltip="Timezone for date handling (defaults to UTC if not provided)"
|
||||
>
|
||||
<Input placeholder="UTC" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="api_key" label="CloudZero API Key">
|
||||
{({ ref, ...field }) => (
|
||||
<CloudZeroApiKeyInput {...field} ref={ref} placeholder="Enter your CloudZero API key" />
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="connection_id" label="Connection ID">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="Enter your CloudZero connection ID" />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
label={labelWithHint("Timezone", "Timezone for date handling (defaults to UTC if not provided)")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="UTC" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { CircleHelp, Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export 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>
|
||||
</>
|
||||
);
|
||||
|
||||
export const CloudZeroApiKeyInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<React.ComponentPropsWithoutRef<"input">, "type">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
|
||||
return (
|
||||
<InputGroup className={className}>
|
||||
<InputGroupInput {...props} ref={ref} type={revealed ? "text" : "password"} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label={revealed ? "Hide API key" : "Show API key"}
|
||||
onClick={() => setRevealed((current) => !current)}
|
||||
>
|
||||
{revealed ? <EyeOff /> : <Eye />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
);
|
||||
});
|
||||
CloudZeroApiKeyInput.displayName = "CloudZeroApiKeyInput";
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import CloudZeroUpdateModal from "./CloudZeroUpdateModal";
|
||||
import { CloudZeroSettings } from "./types";
|
||||
|
||||
const mutate = vi.fn();
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
__esModule: true,
|
||||
default: () => ({ accessToken: "test-token" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings", () => ({
|
||||
useCloudZeroUpdateSettings: () => ({ mutate, isPending: false }),
|
||||
}));
|
||||
|
||||
const STORED_SETTINGS: CloudZeroSettings = {
|
||||
connection_id: "stored-connection-id",
|
||||
api_key_masked: "sk-cz-****last4",
|
||||
timezone: "Europe/Berlin",
|
||||
status: "Active",
|
||||
};
|
||||
|
||||
const renderModal = (settings: CloudZeroSettings = STORED_SETTINGS) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudZeroUpdateModal open={true} onOk={vi.fn()} onCancel={vi.fn()} settings={settings} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const submittedPayload = (): Record<string, unknown> => {
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
return mutate.mock.calls[0][0] as Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("CloudZeroUpdateModal submit payload", () => {
|
||||
beforeEach(() => {
|
||||
mutate.mockClear();
|
||||
});
|
||||
|
||||
it("seeds the stored connection id and timezone but never the stored key", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByLabelText("Connection ID")).toHaveValue("stored-connection-id");
|
||||
expect(screen.getByLabelText("Timezone")).toHaveValue("Europe/Berlin");
|
||||
expect(screen.getByLabelText("CloudZero API Key")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("omits api_key entirely when the key field is left untouched, preserving the stored secret", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(submittedPayload()).toEqual({
|
||||
connection_id: "stored-connection-id",
|
||||
timezone: "Europe/Berlin",
|
||||
}),
|
||||
);
|
||||
expect("api_key" in submittedPayload()).toBe(false);
|
||||
});
|
||||
|
||||
it("sends api_key only once the user types a replacement key", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByLabelText("CloudZero API Key"), "cz-rotated-key");
|
||||
await user.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(submittedPayload()).toEqual({
|
||||
connection_id: "stored-connection-id",
|
||||
timezone: "Europe/Berlin",
|
||||
api_key: "cz-rotated-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to UTC when the stored timezone is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.clear(screen.getByLabelText("Timezone"));
|
||||
await user.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(submittedPayload()).toEqual({
|
||||
connection_id: "stored-connection-id",
|
||||
timezone: "UTC",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to UTC when the stored settings carry no timezone", () => {
|
||||
renderModal({ ...STORED_SETTINGS, timezone: null });
|
||||
|
||||
expect(screen.getByLabelText("Timezone")).toHaveValue("UTC");
|
||||
});
|
||||
|
||||
it("reports a null connection id from the server as the required field, not as a type error", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal({ ...STORED_SETTINGS, connection_id: null, timezone: null });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
expect(await screen.findByText("Please enter your CloudZero connection ID")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/expected string, received null/i)).not.toBeInTheDocument();
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submission when the connection id is cleared, and leaves the key optional", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.clear(screen.getByLabelText("Connection ID"));
|
||||
await user.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
expect(await screen.findByText("Please enter your CloudZero connection ID")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Please enter your CloudZero API key")).not.toBeInTheDocument();
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit when Enter is pressed inside a text field", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByLabelText("CloudZero API Key"), "cz-rotated-key{Enter}");
|
||||
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
import { Modal } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Form, Input, Modal } from "antd";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CloudZeroApiKeyInput, labelWithHint } from "./CloudZeroFormControls";
|
||||
import { buildCloudZeroPayload, EMPTY_CLOUDZERO_FORM_VALUES, type CloudZeroFormValues } from "./cloudZeroPayload";
|
||||
import { CloudZeroSettings } from "./types";
|
||||
|
||||
interface CloudZeroUpdateModalProps {
|
||||
|
|
@ -12,56 +22,44 @@ interface CloudZeroUpdateModalProps {
|
|||
settings: CloudZeroSettings;
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
api_key: z.string(),
|
||||
connection_id: z.string().min(1, "Please enter your CloudZero connection ID"),
|
||||
timezone: z.string(),
|
||||
});
|
||||
|
||||
export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: CloudZeroUpdateModalProps) {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const form = useZodForm(updateSchema, { defaultValues: EMPTY_CLOUDZERO_FORM_VALUES });
|
||||
const updateMutation = useCloudZeroUpdateSettings(accessToken || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (open && settings) {
|
||||
form.setFieldsValue({
|
||||
connection_id: settings.connection_id,
|
||||
form.reset({
|
||||
connection_id: settings.connection_id ?? "",
|
||||
timezone: settings.timezone || "UTC",
|
||||
api_key: "",
|
||||
});
|
||||
} else if (open) {
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
}
|
||||
}, [open, settings, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
updateMutation.mutate(
|
||||
{
|
||||
connection_id: values.connection_id,
|
||||
timezone: values.timezone || "UTC",
|
||||
...(values.api_key && { api_key: values.api_key }),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("CloudZero integration updated successfully");
|
||||
form.resetFields();
|
||||
onOk();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.errorFields) {
|
||||
return;
|
||||
}
|
||||
toast.error(error?.message || "Failed to update CloudZero integration");
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
return;
|
||||
}
|
||||
toast.error(error?.message || "Failed to update CloudZero integration");
|
||||
}
|
||||
const handleSubmit = (values: CloudZeroFormValues) => {
|
||||
updateMutation.mutate(buildCloudZeroPayload(values), {
|
||||
onSuccess: () => {
|
||||
toast.success("CloudZero integration updated successfully");
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
onOk();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update CloudZero integration");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
|
@ -69,7 +67,7 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }:
|
|||
<Modal
|
||||
title="Edit CloudZero Integration"
|
||||
open={open}
|
||||
onOk={handleSubmit}
|
||||
onOk={() => void form.handleSubmit(handleSubmit)()}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={updateMutation.isPending}
|
||||
okText={updateMutation.isPending ? "Updating..." : "Update"}
|
||||
|
|
@ -81,30 +79,31 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }:
|
|||
disabled: updateMutation.isPending,
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="CloudZero API Key"
|
||||
name="api_key"
|
||||
rules={[{ required: false, message: "Please enter your CloudZero API key" }]}
|
||||
tooltip="Leave empty to keep the existing API key"
|
||||
>
|
||||
<Input.Password placeholder="Leave empty to keep existing" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Connection ID"
|
||||
name="connection_id"
|
||||
rules={[{ required: true, message: "Please enter your CloudZero connection ID" }]}
|
||||
>
|
||||
<Input placeholder="Enter your CloudZero connection ID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
tooltip="Timezone for date handling (defaults to UTC if not provided)"
|
||||
>
|
||||
<Input placeholder="UTC" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
label={labelWithHint("CloudZero API Key", "Leave empty to keep the existing API key")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<CloudZeroApiKeyInput {...field} ref={ref} placeholder="Leave empty to keep existing" />
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="connection_id" label="Connection ID">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="Enter your CloudZero connection ID" />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
label={labelWithHint("Timezone", "Timezone for date handling (defaults to UTC if not provided)")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="UTC" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCloudZeroPayload } from "./cloudZeroPayload";
|
||||
|
||||
describe("buildCloudZeroPayload", () => {
|
||||
it("passes a filled form through verbatim", () => {
|
||||
expect(
|
||||
buildCloudZeroPayload({ api_key: "cz-key", connection_id: "conn-1", timezone: "America/New_York" }),
|
||||
).toStrictEqual({
|
||||
connection_id: "conn-1",
|
||||
timezone: "America/New_York",
|
||||
api_key: "cz-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the api_key key entirely when the field is blank, so a stored secret survives an untouched save", () => {
|
||||
const payload = buildCloudZeroPayload({ api_key: "", connection_id: "conn-1", timezone: "UTC" });
|
||||
|
||||
expect("api_key" in payload).toBe(false);
|
||||
expect(payload).toStrictEqual({ connection_id: "conn-1", timezone: "UTC" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["blank", ""],
|
||||
["absent", undefined],
|
||||
])("falls back to UTC when the timezone is %s", (_label, timezone) => {
|
||||
const payload = buildCloudZeroPayload({
|
||||
api_key: "cz-key",
|
||||
connection_id: "conn-1",
|
||||
timezone: timezone as string,
|
||||
});
|
||||
|
||||
expect(payload.timezone).toBe("UTC");
|
||||
});
|
||||
|
||||
it("never invents a timezone default over a real value", () => {
|
||||
expect(buildCloudZeroPayload({ api_key: "", connection_id: "conn-1", timezone: "UTC+2" }).timezone).toBe("UTC+2");
|
||||
});
|
||||
|
||||
it("keeps a whitespace-only api_key, matching the truthiness check the antd modals used", () => {
|
||||
expect(buildCloudZeroPayload({ api_key: " ", connection_id: "conn-1", timezone: "UTC" }).api_key).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
export interface CloudZeroFormValues {
|
||||
api_key: string;
|
||||
connection_id: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface CloudZeroPayload {
|
||||
connection_id: string;
|
||||
timezone: string;
|
||||
api_key?: string;
|
||||
}
|
||||
|
||||
export const EMPTY_CLOUDZERO_FORM_VALUES: CloudZeroFormValues = {
|
||||
api_key: "",
|
||||
connection_id: "",
|
||||
timezone: "",
|
||||
};
|
||||
|
||||
export const buildCloudZeroPayload = (values: CloudZeroFormValues): CloudZeroPayload => ({
|
||||
connection_id: values.connection_id,
|
||||
timezone: values.timezone || "UTC",
|
||||
...(values.api_key && { api_key: values.api_key }),
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue