refactor(ui): migrate the model settings and credential rotation modals to react-hook-form and shadcn (#37342)

* refactor(ui): migrate the model settings and credential rotation modals to react-hook-form

Both modals owned a self-contained antd FormInstance with no shared form
children, so each migrates on its own without touching the add_model graph.

ModelSettingsModal keeps its antd Modal shell, footer buttons and Skeleton
placeholder. The single store_model_in_db field becomes a shadcn Switch inside
a FormField, the antd Form.Item tooltip stays a hover tooltip rather than
becoming always-visible description text, and the remount-on-new-config
behaviour that the antd `key` provided is now RHF's `values` option.

UpdateModelCredentialsModal keeps the antd Modal and warning Alert. The
Input.Password becomes an InputGroup with an Eye/EyeOff reveal toggle so the
reveal affordance survives, and the required rule ports to the same message.
Both submit paths stay exactly as they were: Enter still submits here because
the antd Form had onFinish and a real submit button, while the settings modal
keeps submitting only from its footer button.

* refactor(ui): reuse the shared PasswordInput in the credential rotation modal
This commit is contained in:
yuneng-jiang 2026-08-18 14:28:00 -07:00 committed by GitHub
parent 5d1401342a
commit 76ff0d5351
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 156 additions and 38 deletions

View file

@ -4,8 +4,14 @@ import { ConfigType, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/
import { StoreModelInDBParams, useStoreModelInDB } from "@/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB";
import { toast } from "@/lib/toast";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { Button, Form, Modal, Skeleton, Space, Switch, Typography } from "antd";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Button, Modal, Skeleton, Space, Typography } from "antd";
import { CircleHelp } from "lucide-react";
import React, { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
interface ModelSettingsModalProps {
isVisible: boolean;
@ -13,8 +19,17 @@ interface ModelSettingsModalProps {
onSuccess?: () => void;
}
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 ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
const [form] = Form.useForm();
const { mutateAsync, isPending } = useStoreModelInDB();
const { data: proxyConfigData, isLoading: isLoadingConfig, refetch } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
@ -26,7 +41,7 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
}, [isVisible, refetch]);
// Compute initial values from fetched config data
const initialValues = useMemo(() => {
const initialValues = useMemo<StoreModelInDBParams>(() => {
if (!proxyConfigData) {
return {
store_model_in_db: false,
@ -40,6 +55,8 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
};
}, [proxyConfigData]);
const form = useForm<StoreModelInDBParams>({ defaultValues: initialValues, values: initialValues });
const handleFormSubmit = async (formValues: StoreModelInDBParams) => {
try {
await mutateAsync(formValues, {
@ -58,7 +75,7 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
};
const handleCancel = () => {
form.resetFields();
form.reset(initialValues);
onCancel();
};
@ -71,32 +88,47 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
<Button onClick={handleCancel} disabled={isPending || isLoadingConfig}>
Cancel
</Button>
<Button type="primary" loading={isPending} disabled={isLoadingConfig} onClick={() => form.submit()}>
<Button
type="primary"
loading={isPending}
disabled={isLoadingConfig}
onClick={() => void form.handleSubmit(handleFormSubmit)()}
>
{isPending ? "Saving..." : "Save Settings"}
</Button>
</Space>
}
onCancel={handleCancel}
>
<Form
key={proxyConfigData ? JSON.stringify(initialValues) : "loading"}
form={form}
layout="horizontal"
onFinish={handleFormSubmit}
initialValues={initialValues}
>
<Form.Item
label="Store Model in DB"
name="store_model_in_db"
tooltip={
proxyConfigData?.find((f) => f.field_name === "store_model_in_db")?.field_description ||
"If enabled, models and config are stored in and loaded from the database."
}
valuePropName="checked"
>
{isLoadingConfig ? <Skeleton.Input active block /> : <Switch />}
</Form.Item>
</Form>
<TooltipProvider>
<form onSubmit={(event) => event.preventDefault()}>
<FieldGroup>
<FormField
control={form.control}
name="store_model_in_db"
label={labelWithHint(
"Store Model in DB",
proxyConfigData?.find((f) => f.field_name === "store_model_in_db")?.field_description ||
"If enabled, models and config are stored in and loaded from the database.",
)}
>
{({ id, value, onChange, onBlur }) =>
isLoadingConfig ? (
<Skeleton.Input active block />
) : (
<Switch
id={id}
checked={Boolean(value)}
onCheckedChange={onChange}
onBlur={onBlur}
className="w-fit"
/>
)
}
</FormField>
</FieldGroup>
</form>
</TooltipProvider>
</Modal>
);
};

View file

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import UpdateModelCredentialsModal from "./update_model_credentials_modal";
import * as networking from "./networking";
import { toast } from "@/lib/toast";
vi.mock("./networking", async () => {
const actual = await vi.importActual("./networking");
@ -13,6 +14,7 @@ vi.mock("./networking", async () => {
});
const mockModelPatchUpdateCall = vi.mocked(networking.modelPatchUpdateCall);
const mockToast = vi.mocked(toast);
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
@ -76,4 +78,68 @@ describe("UpdateModelCredentialsModal", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockModelPatchUpdateCall).not.toHaveBeenCalled();
});
it("renders the required message when the field is left blank", async () => {
const user = userEvent.setup();
renderModal();
await user.click(screen.getByRole("button", { name: /update api key/i }));
expect(await screen.findByText("Enter a new API key")).toBeInTheDocument();
});
it("rejects a whitespace-only key without sending a PATCH", async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByLabelText(/new api key/i), " ");
await user.click(screen.getByRole("button", { name: /update api key/i }));
await waitFor(() => expect(mockToast.fromError).toHaveBeenCalledWith("Enter a new API key"));
expect(mockModelPatchUpdateCall).not.toHaveBeenCalled();
});
it("trims surrounding whitespace off the key it sends", async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByLabelText(/new api key/i), " sk-pad-77 ");
await user.click(screen.getByRole("button", { name: /update api key/i }));
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalledTimes(1));
expect(mockModelPatchUpdateCall.mock.calls[0][1]).toEqual({
litellm_params: { api_key: "sk-pad-77" },
model_info: { id: "model-123" },
});
});
it("submits on Enter from inside the key field", async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByLabelText(/new api key/i), "sk-enter-1{Enter}");
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalledTimes(1));
expect(mockModelPatchUpdateCall.mock.calls[0][1]).toEqual({
litellm_params: { api_key: "sk-enter-1" },
model_info: { id: "model-123" },
});
});
it("reveals and re-hides the key without touching the value", async () => {
const user = userEvent.setup();
renderModal();
const field = screen.getByLabelText(/new api key/i);
await user.type(field, "sk-peek-42");
expect(field).toHaveAttribute("type", "password");
await user.click(screen.getByRole("button", { name: /show password/i }));
expect(field).toHaveAttribute("type", "text");
expect(field).toHaveValue("sk-peek-42");
await user.click(screen.getByRole("button", { name: /hide password/i }));
expect(field).toHaveAttribute("type", "password");
expect(field).toHaveValue("sk-peek-42");
});
});

View file

@ -1,10 +1,25 @@
import { Alert, Button, Form, Input, Modal, Typography } from "antd";
import { Alert, Modal, Typography } from "antd";
import { useState } from "react";
import { z } from "zod/v4";
import { modelPatchUpdateCall } from "./networking";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
const { Text } = Typography;
const updateCredentialsSchema = z.object({
api_key: z.string().min(1, "Enter a new API key"),
});
type UpdateCredentialsValues = z.infer<typeof updateCredentialsSchema>;
const EMPTY_VALUES: UpdateCredentialsValues = { api_key: "" };
interface UpdateModelCredentialsModalProps {
open: boolean;
onCancel: () => void;
@ -20,15 +35,15 @@ export default function UpdateModelCredentialsModal({
modelId,
onUpdated,
}: UpdateModelCredentialsModalProps) {
const [form] = Form.useForm();
const form = useZodForm(updateCredentialsSchema, { defaultValues: EMPTY_VALUES });
const [isSaving, setIsSaving] = useState(false);
const close = () => {
form.resetFields();
form.reset(EMPTY_VALUES);
onCancel();
};
const handleSubmit = async (values: { api_key?: string }) => {
const handleSubmit = async (values: UpdateCredentialsValues) => {
const apiKey = values.api_key?.trim();
if (!apiKey) {
toast.fromError("Enter a new API key");
@ -42,7 +57,7 @@ export default function UpdateModelCredentialsModal({
modelId,
);
toast.success("API key updated");
form.resetFields();
form.reset(EMPTY_VALUES);
onUpdated();
onCancel();
} catch (error) {
@ -55,7 +70,7 @@ export default function UpdateModelCredentialsModal({
return (
<Modal title="Update API Key" open={open} onCancel={close} footer={null} width={520} destroyOnHidden={true}>
<Text className="block mb-4 text-gray-500">
<Text className="block mb-4 text-muted-foreground">
Update this model&apos;s API key. Only the new key is sent; the rest of the deployment configuration is left
untouched.
</Text>
@ -65,19 +80,24 @@ export default function UpdateModelCredentialsModal({
className="mb-4"
message="Only the API key is rotated here. Models that authenticate with an Azure AD token, AWS credentials, or a Vertex service-account JSON aren't supported yet; update those from the model's LiteLLM Params for now."
/>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item label="New API Key" name="api_key" rules={[{ required: true, message: "Enter a new API key" }]}>
<Input.Password placeholder="Enter the new API key" autoComplete="new-password" />
</Form.Item>
<div className="flex justify-end items-center mt-4">
<Button onClick={close} style={{ marginRight: 10 }}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<FormField control={form.control} name="api_key" label="New API Key">
{({ ref, ...field }) => (
<PasswordInput {...field} ref={ref} placeholder="Enter the new API key" autoComplete="new-password" />
)}
</FormField>
</FieldGroup>
<div className="flex justify-end items-center mt-4 gap-2.5">
<Button type="button" variant="outline" onClick={close}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={isSaving}>
<Button type="submit" disabled={isSaving}>
{isSaving && <UiLoadingSpinner className="size-4" />}
Update API Key
</Button>
</div>
</Form>
</form>
</Modal>
);
}