diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 5ff95d2af0c..b692960a678 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -902,16 +902,16 @@ describe("Teams - schema-declared metadata fields in team create", () => { }); }; - it("should prepopulate the declared key as an ordinary pair row and submit its value", async () => { + it("should show the declared key as a fixed label and submit its value under the declared key", async () => { await openCreateModal(); fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } }); fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } }); - await waitFor(() => { - expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center"); - }); - fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-1001" } }); + expect(await screen.findByTestId("metadata-schema-label")).toHaveTextContent("Cost Center"); + expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Remove key-value pair")).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Cost Center"), { target: { value: "CC-1001" } }); const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i }); fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]); @@ -932,10 +932,7 @@ describe("Teams - schema-declared metadata fields in team create", () => { fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } }); fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } }); - await waitFor(() => { - expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center"); - }); - fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-9999" } }); + fireEvent.change(await screen.findByLabelText("Cost Center"), { target: { value: "CC-9999" } }); const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i }); fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]); @@ -955,16 +952,12 @@ describe("Teams - schema-declared metadata fields in team create", () => { expect(screen.queryByRole("button", { name: /add key-value pair/i })).not.toBeInTheDocument(); }); - it("should re-seed declared keys when the create modal is closed and reopened", async () => { + it("should re-seed the declared key and drop free-form rows when the create modal is closed and reopened", async () => { await openCreateModal(); - await waitFor(() => { - expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center"); - }); - fireEvent.click(screen.getByLabelText("Remove key-value pair")); - await waitFor(() => { - expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument(); - }); + fireEvent.change(await screen.findByLabelText("Cost Center"), { target: { value: "CC-1001" } }); + fireEvent.click(screen.getByRole("button", { name: /add key-value pair/i })); + fireEvent.change(await screen.findByPlaceholderText("Key"), { target: { value: "region" } }); fireEvent.click(screen.getByRole("button", { name: /^close$/i })); await waitFor(() => { @@ -976,9 +969,8 @@ describe("Teams - schema-declared metadata fields in team create", () => { fireEvent.click(createButton); }); - await waitFor(() => { - expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center"); - }); + expect(await screen.findByLabelText("Cost Center")).toHaveValue(""); + expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.test.tsx b/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.test.tsx index 721653c6427..0ccb2346450 100644 --- a/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.test.tsx @@ -212,17 +212,41 @@ describe("MetadataKeyValueFields with a declared schema", () => { { key: "app_name", label: "Application Name" }, ]; - it("should prepopulate one ordinary editable pair row per declared key", async () => { + it("should render each declared key as a fixed label with only the value editable", async () => { render(); await waitFor(() => { - expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([ - "cost_center", - "app_name", + expect(screen.getAllByTestId("metadata-schema-label").map((label) => label.textContent)).toEqual([ + "Cost Center", + "Application Name", ]); }); - screen.getAllByPlaceholderText("Key").forEach((input) => expect(input).toBeEnabled()); - expect(screen.getAllByLabelText("Remove key-value pair")).toHaveLength(2); + expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Remove key-value pair")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Cost Center")).toHaveAttribute("placeholder", "Value"); + }); + + it("should fall back to the key when a declared field has no label", async () => { + render(); + + expect(await screen.findByTestId("metadata-schema-label")).toHaveTextContent("cost_center"); + }); + + it("should treat an existing pair that matches a declared key as fixed too", async () => { + render( + , + ); + + expect(await screen.findByLabelText("Cost Center")).toHaveValue("CC-1001"); + expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual(["region"]); + expect(screen.getAllByLabelText("Remove key-value pair")).toHaveLength(1); }); it("should submit a prepopulated key with its typed value", async () => { @@ -244,9 +268,9 @@ describe("MetadataKeyValueFields with a declared schema", () => { ); await waitFor(() => { - expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([ - "cost_center", - "app_name", + expect(screen.getAllByTestId("metadata-schema-label").map((label) => label.textContent)).toEqual([ + "Cost Center", + "Application Name", ]); }); expect(screen.getAllByPlaceholderText("Value").map((input) => (input as HTMLInputElement).value)).toEqual([ @@ -255,18 +279,54 @@ describe("MetadataKeyValueFields with a declared schema", () => { ]); }); - it("should let the user remove a prepopulated row", async () => { + it("should still let the user add and remove free-form pairs below the declared keys", async () => { const user = userEvent.setup(); - render(); + const onFinish = vi.fn(); + render(); - await screen.findAllByPlaceholderText("Key"); - await user.click(screen.getAllByLabelText("Remove key-value pair")[0]); + await screen.findByTestId("metadata-schema-label"); + await user.click(screen.getByRole("button", { name: /add key-value pair/i })); + fireEvent.change(screen.getByPlaceholderText("Key"), { target: { value: "region" } }); + fireEvent.change(screen.getAllByPlaceholderText("Value")[1], { target: { value: "us" } }); + fireEvent.change(screen.getByLabelText("Cost Center"), { target: { value: "CC-1001" } }); + await user.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { - expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([ - "app_name", - ]); + expect(onFinish).toHaveBeenCalledWith({ + metadata: [ + { key: "cost_center", value: "CC-1001" }, + { key: "region", value: "us" }, + ], + }); }); + + await user.click(screen.getByLabelText("Remove key-value pair")); + await waitFor(() => { + expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument(); + }); + expect(screen.getByTestId("metadata-schema-label")).toBeInTheDocument(); + }); + + it("should keep duplicate keys in a free-form row", async () => { + const user = userEvent.setup(); + const onFinish = vi.fn(); + render(); + + expect(await screen.findByTestId("metadata-schema-label")).toHaveTextContent("Cost Center"); + await user.click(screen.getByRole("button", { name: /add key-value pair/i })); + fireEvent.change(screen.getByPlaceholderText("Key"), { target: { value: "cost_center" } }); + + expect(screen.getAllByPlaceholderText("Key")).toHaveLength(1); + expect(screen.getByPlaceholderText("Key")).toHaveValue("cost_center"); + expect(screen.getAllByLabelText("Remove key-value pair")).toHaveLength(1); + expect(screen.getAllByTestId("metadata-schema-label")).toHaveLength(1); + + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(screen.getByText("Duplicate key")).toBeInTheDocument(); + }); + expect(onFinish).not.toHaveBeenCalled(); }); it("should show a skeleton instead of the editor while the schema is loading", () => { @@ -283,9 +343,9 @@ describe("MetadataKeyValueFields with a declared schema", () => { rerender(); await waitFor(() => { - expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([ - "cost_center", - "app_name", + expect(screen.getAllByTestId("metadata-schema-label").map((label) => label.textContent)).toEqual([ + "Cost Center", + "Application Name", ]); }); }); diff --git a/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx b/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx index 1c67d48128f..3014fdd697c 100644 --- a/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx @@ -2,6 +2,7 @@ import { CircleMinus, Plus } from "lucide-react"; import React, { useEffect, useRef } from "react"; import { useFieldArray, + useWatch, type Control, type FieldArrayPath, type FieldPath, @@ -14,6 +15,7 @@ import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadata import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; export interface MetadataPair { @@ -86,7 +88,12 @@ const MetadataKeyValueFields = ({ schemaLoading = false, }: MetadataKeyValueFieldsProps) => { const { fields, append, remove } = useFieldArray({ control, name }); + const watchedPairs = (useWatch({ control, name: name as unknown as FieldPath }) ?? []) as readonly ( + | Partial + | undefined + )[]; const seededRef = useRef(false); + const schemaLabelsByKey = new Map(schemaFields.map((field) => [field.key, field.label || field.key])); useEffect(() => { if (seededRef.current || schemaLoading || schemaFields.length === 0) return; @@ -112,31 +119,64 @@ const MetadataKeyValueFields = ({ ); } + const getSchemaLabel = (index: number): string | undefined => { + const key = watchedPairs[index]?.key; + if ( + key === undefined || + !schemaLabelsByKey.has(key) || + watchedPairs.findIndex((pair) => pair?.key === key) !== index + ) { + return undefined; + } + return schemaLabelsByKey.get(key); + }; + return ( <> - {fields.map((field, index) => ( -
- }> - {({ ref, value, ...rest }) => ( - + {fields.map((field, index) => { + const schemaLabel = getSchemaLabel(index); + return ( +
+ {schemaLabel === undefined ? ( + }> + {({ ref, value, ...rest }) => ( + + )} + + ) : ( + )} - - }> - {({ ref, value, ...rest }) => ( - + }> + {({ ref, value, id, ...rest }) => ( + + )} + + {schemaLabel === undefined && ( + )} - - -
- ))} +
+ ); + })}