litellm/ui/litellm-dashboard/src/components/query_param_input.test.tsx
yuneng-jiang 2672b36dc3
fix(ui): clear pass-through header rows when the create modal is reopened (#37549)
KeyValueInput and QueryParamInput each seeded a private copy of their rows
from the value prop with a one-time useState initializer. The antd form they
were written for hid that: rc-field-form bumps an internal resetCount key on
resetFields, which remounts a Field's children, so the private copy was thrown
away on every reset. react-hook-form's reset does not remount, and the modal is
hidden rather than unmounted, so after Cancel the rows stayed on screen holding
the old values while the form value went back to empty.

The visible cost was a blocked create flow. A leaked header row made the modal
look configured, but the form value behind it was gone, so submitting a fresh
path and target was refused with "Please configure the headers" and no request
was sent. Typing one character into the leaked row put a value back and the
submit went through, which is not something a user can guess.

Both inputs are now controlled off the value prop, which is an array of pairs
rather than a record. A record cannot represent a row whose name is still empty,
which is the reason the private copy existed: two blank rows collapse into one
and a half-typed row disappears as it is typed. With pairs the field value is
the editable shape, the second source of truth is gone, and a form reset clears
the rows like every other field. add_pass_through converts to a record at submit,
so the request payload is unchanged.

Headers now require at least one row with a non-empty name. Previously that was
enforced by accident, because adding a row did not notify the form at all.
2026-08-19 18:44:01 -07:00

60 lines
2.4 KiB
TypeScript

import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import QueryParamInput from "./query_param_input";
import type { KeyValuePair } from "./key_value_input";
const ControlledQueryParamInput = ({ onChange }: { onChange?: (value: readonly KeyValuePair[]) => void }) => {
const [value, setValue] = useState<readonly KeyValuePair[]>([]);
return (
<QueryParamInput
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
/>
);
};
describe("QueryParamInput", () => {
it("renders existing query parameter pairs", () => {
render(<QueryParamInput value={[["version", "v1"]]} />);
expect(screen.getByPlaceholderText("Parameter Name (e.g., version)")).toHaveValue("version");
expect(screen.getByPlaceholderText("Parameter Value (e.g., v1)")).toHaveValue("v1");
});
it("adds a query parameter row and emits edits", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ControlledQueryParamInput onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /Add Query Parameter$/ }));
fireEvent.change(screen.getByPlaceholderText("Parameter Name (e.g., version)"), { target: { value: "region" } });
fireEvent.change(screen.getByPlaceholderText("Parameter Value (e.g., v1)"), { target: { value: "us-west" } });
expect(onChange).toHaveBeenLastCalledWith([["region", "us-west"]]);
});
it("renders no rows once the value prop goes back to empty", () => {
const { rerender } = render(<QueryParamInput value={[["version", "v1"]]} />);
expect(screen.getByPlaceholderText("Parameter Name (e.g., version)")).toHaveValue("version");
rerender(<QueryParamInput value={[]} />);
expect(screen.queryByPlaceholderText("Parameter Name (e.g., version)")).not.toBeInTheDocument();
});
it("keeps a half-typed row editable while its name is still empty", async () => {
const user = userEvent.setup();
render(<ControlledQueryParamInput />);
await user.click(screen.getByRole("button", { name: /Add Query Parameter$/ }));
await user.click(screen.getByRole("button", { name: /Add Query Parameter$/ }));
expect(screen.getAllByPlaceholderText("Parameter Name (e.g., version)")).toHaveLength(2);
});
});