diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts new file mode 100644 index 00000000000..b619ec5748a --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import type { UseFormGetValues } from "react-hook-form"; + +import { + projectMountedValues, + type MountedFieldName, + type MountedFormValues, + type MountRegistry, +} from "./MountedFormField"; + +const registryOf = (names: readonly MountedFieldName[]): MountRegistry => ({ + register: () => () => undefined, + mountedNames: () => names, +}); + +const getValuesOf = (store: Readonly>): UseFormGetValues => + ((names: readonly string[]) => names.map((name) => store[name])) as unknown as UseFormGetValues; + +const project = (store: Readonly>) => + projectMountedValues(registryOf(Object.keys(store)), getValuesOf(store)); + +const projectPaths = (entries: readonly (readonly [MountedFieldName, unknown])[]) => { + const store = Object.fromEntries( + entries.map(([name, value]) => [Array.isArray(name) ? name.join(".") : (name as string), value]), + ); + return projectMountedValues(registryOf(entries.map(([name]) => name)), getValuesOf(store)); +}; + +describe("projectMountedValues", () => { + it("keeps a flat name flat", () => { + expect(project({ server_name: "s1", transport: "http" })).toStrictEqual({ server_name: "s1", transport: "http" }); + }); + + it("nests an ARRAY name into a credentials object", () => { + expect( + projectPaths([ + [["credentials", "aws_region_name"], "us-east-1"], + [["credentials", "aws_access_key_id"], "AKIA"], + ]), + ).toStrictEqual({ credentials: { aws_region_name: "us-east-1", aws_access_key_id: "AKIA" } }); + }); + + it("keeps a literal dotted STRING name flat, matching antd getNamePath toArray", () => { + expect(projectPaths([["a.b", 1]])).toStrictEqual({ "a.b": 1 }); + expect(projectPaths([["schema.property.with.dots", "v"]])).toStrictEqual({ "schema.property.with.dots": "v" }); + }); + + it("rebuilds Form.List rows as an array, not an object keyed by digits", () => { + const projected = projectPaths([ + [["env_vars", "0", "name"], "API_KEY"], + [["env_vars", "0", "description"], "the key"], + [["env_vars", "1", "name"], "REGION"], + ]); + expect(projected).toStrictEqual({ + env_vars: [{ name: "API_KEY", description: "the key" }, { name: "REGION" }], + }); + expect(Array.isArray(projected.env_vars)).toBe(true); + }); + + it("rebuilds static_headers rows, the second Form.List site", () => { + expect( + projectPaths([ + [["static_headers", "0", "key"], "X-Tenant"], + [["static_headers", "0", "value"], "acme"], + ]), + ).toStrictEqual({ + static_headers: [{ key: "X-Tenant", value: "acme" }], + }); + }); + + it("emits a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => { + const projected = project({ alias: undefined }); + expect(Object.keys(projected)).toStrictEqual(["alias"]); + expect(projected.alias).toBeUndefined(); + }); + + it("leaves a sparse row index as a hole rather than shifting later rows down", () => { + const projected = projectPaths([[["env_vars", "2", "name"], "THIRD"]]) as { env_vars: readonly unknown[] }; + expect(projected.env_vars).toHaveLength(3); + expect(projected.env_vars[2]).toStrictEqual({ name: "THIRD" }); + }); + + it("mixes flat, nested and list names in one projection", () => { + expect( + projectPaths([ + ["transport", "http"], + [["credentials", "client_id"], "cid"], + [["env_vars", "0", "name"], "K"], + ]), + ).toStrictEqual({ + transport: "http", + credentials: { client_id: "cid" }, + env_vars: [{ name: "K" }], + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx index 8a92873495c..0ae58e102e7 100644 --- a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx @@ -13,9 +13,14 @@ import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/sh export type MountedFormValues = Record; +const fieldKey = (name: string | readonly string[]): string => + Array.isArray(name) ? name.join(".") : (name as string); + +export type MountedFieldName = string | readonly string[]; + export interface MountRegistry { - readonly register: (name: string) => () => void; - readonly mountedNames: () => readonly string[]; + readonly register: (name: MountedFieldName) => () => void; + readonly mountedNames: () => readonly MountedFieldName[]; } export interface MountedFormContextValue { @@ -40,33 +45,53 @@ const MountedFormContext = React.createContext({ export const MountedFormProvider = MountedFormContext.Provider; export const useMountRegistry = (): MountRegistry => { - const counts = React.useRef>(new Map()); + const counts = React.useRef>(new Map()); return React.useMemo( () => ({ - register: (name: string) => { - counts.current.set(name, (counts.current.get(name) ?? 0) + 1); + register: (name: MountedFieldName) => { + const key = fieldKey(name); + counts.current.set(key, { name, count: (counts.current.get(key)?.count ?? 0) + 1 }); return () => { - const remaining = (counts.current.get(name) ?? 0) - 1; + const remaining = (counts.current.get(key)?.count ?? 0) - 1; if (remaining > 0) { - counts.current.set(name, remaining); + counts.current.set(key, { name, count: remaining }); } else { - counts.current.delete(name); + counts.current.delete(key); } }; }, - mountedNames: () => Array.from(counts.current.keys()), + mountedNames: () => Array.from(counts.current.values(), (entry) => entry.name), }), [], ); }; +const withIndex = (base: readonly unknown[], index: number, next: unknown): readonly unknown[] => + Array.from({ length: Math.max(base.length, index + 1) }, (_, i) => (i === index ? next : base[i])); + +const setPath = (target: unknown, segments: readonly string[], value: unknown): unknown => { + const [head, ...rest] = segments; + if (/^\d+$/.test(head)) { + const base: readonly unknown[] = Array.isArray(target) ? target : []; + const index = Number(head); + return withIndex(base, index, rest.length === 0 ? value : setPath(base[index], rest, value)); + } + const base: Record = + target !== null && typeof target === "object" && !Array.isArray(target) ? (target as Record) : {}; + return { ...base, [head]: rest.length === 0 ? value : setPath(base[head], rest, value) }; +}; + export const projectMountedValues = ( registry: MountRegistry, getValues: UseFormGetValues, ): MountedFormValues => { const names = [...registry.mountedNames()]; - const values = getValues(names); - return Object.fromEntries(names.map((name, index) => [name, values[index]])); + const values = getValues(names.map(fieldKey)); + return names.reduce( + (projected, name, index) => + setPath(projected, Array.isArray(name) ? name : [name as string], values[index]) as MountedFormValues, + {}, + ); }; export type MountedFieldControlProps = { @@ -81,7 +106,7 @@ export type MountedFieldControlProps = { }; export interface MountedFormFieldProps { - readonly name: string; + readonly name: MountedFieldName; readonly label?: React.ReactNode; readonly help?: React.ReactNode; readonly required?: boolean; @@ -107,15 +132,16 @@ export const MountedFormField: React.FC = ({ children, }) => { const { control, registry } = React.useContext(MountedFormContext); + const path = fieldKey(name); React.useEffect(() => registry.register(name), [registry, name]); - const helpId = `${name}_help`; + const helpId = `${path}_help`; const hasHelp = help !== undefined && help !== null; const renderField: ControllerProps["render"] = ({ field, fieldState }) => { const invalid = fieldState.error !== undefined; const controlProps: MountedFieldControlProps = { - id: name, + id: path, name: field.name, value: field.value, onChange: field.onChange, @@ -131,7 +157,7 @@ export const MountedFormField: React.FC = ({ return ( - {label !== undefined && {label}} + {label !== undefined && {label}} {children(controlProps)} {hasHelp ? ( {help} @@ -142,5 +168,5 @@ export const MountedFormField: React.FC = ({ ); }; - return ; + return ; };