diff --git a/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts b/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts index 5e76ab21315..399d0947dae 100644 --- a/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts @@ -37,6 +37,9 @@ test.describe("Proxy Admin - Key budget window", () => { await navigateToPage(page, Page.ApiKeys); await dismissFeedbackPopup(page); + // The initial, unfiltered list must already contain this newly created key. + // useKeys keeps that list visible while the filtered search is pending. + await expect(page.getByRole("row").filter({ hasText: alias })).toBeVisible({ timeout: 15_000 }); // Deliver the real search result after editing starts: replacing the list's // key object must not reset the open form and erase its unsaved budget. const searchReceived = Promise.withResolvers(); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index 10d718d368f..d87f64be012 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -998,13 +998,31 @@ describe("KeyEditView", () => { fireEvent.change(await screen.findByRole("spinbutton", { name: "Max Budget (USD)" }), { target: { value: "12.5" }, }); - view.rerender(); + view.rerender( + , + ); await chooseSelectOption(userEvent, screen.getByLabelText("Reset Budget"), "monthly"); await userEvent.click(screen.getByRole("button", { name: /save changes/i })); - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ max_budget: "12.5", budget_duration: "30d" })), - ); + const expected = { + max_budget: "12.5", + budget_duration: "30d", + rpm_limit: 75, + models: ["gpt-4"], + metadata: JSON.stringify({ note: "new" }, null, 2), + vector_stores: ["store-new"], + }; + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining(expected))); }); it("should start a fresh budget draft when switching to another key", async () => { @@ -1604,6 +1622,8 @@ describe("KeyEditView", () => { view.rerender(<>); view.rerender(renderEditor({ ...key })); await userEvent.click(await screen.findByRole("button", { name: "Detach from project" })); + view.rerender(renderEditor({ ...key, spend: 1 })); + expect(screen.getByRole("button", { name: "Keep project" })).toBeVisible(); await userEvent.click(screen.getByRole("button", { name: /save changes/i })); const expectedDetach = { project_id: null, organization_id: "org-1", team_id: "group-maple", models: key.models }; await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining(expectedDetach))); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index f856ddc36df..0ebbfe8dd28 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -87,7 +87,7 @@ export function KeyEditView(props: KeyEditViewProps) { } function KeyEditSession({ - keyData: initialKeyData, + keyData, onCancel, onSubmit, teams, @@ -96,16 +96,15 @@ function KeyEditSession({ userRole, premiumUser = false, }: KeyEditViewProps) { - // Background key-list refreshes must not overwrite an in-progress edit. - // Switching keys or reopening the editor starts a new session. - const [keyData] = useState(initialKeyData); const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); const canViewPolicies = hasCapability(userRole, "viewPolicies"); const canViewPrompts = hasCapability(userRole, "viewPrompts"); const canEditEstimates = userRole != null && isProxyAdminRole(userRole); const estimateTooltip = estimateTooltips(canEditEstimates); const form = useZodForm(keyEditFormSchema, { - defaultValues: toKeyEditFormValues(keyData), + // Refresh untouched fields without erasing the user's in-progress edits. + values: toKeyEditFormValues(keyData), + resetOptions: { keepDirtyValues: true }, }); const [promptsList, setPromptsList] = useState([]); const [tagsList, setTagsList] = useState>({}); @@ -185,17 +184,17 @@ function KeyEditSession({ // Sync disabled callbacks with form when component mounts useEffect(() => { - form.setValue("disabled_callbacks", disabledCallbacks); + form.setValue("disabled_callbacks", disabledCallbacks, { shouldDirty: true }); }, [form, disabledCallbacks]); // Sync auto-rotation state with form values useEffect(() => { - form.setValue("auto_rotate", autoRotationEnabled); + form.setValue("auto_rotate", autoRotationEnabled, { shouldDirty: true }); }, [autoRotationEnabled, form]); useEffect(() => { if (rotationInterval) { - form.setValue("rotation_interval", rotationInterval); + form.setValue("rotation_interval", rotationInterval, { shouldDirty: true }); } }, [rotationInterval, form]); @@ -315,7 +314,7 @@ function KeyEditSession({ const handleOrganizationChange = (setField: (value: string | null) => void, orgId: string | null) => { setField(orgId); setSelectedOrganizationId(orgId); - form.setValue("team_id", null); + form.setValue("team_id", null, { shouldDirty: true }); }; const handleTeamChange = (setField: (value: string | null) => void, teamId: string | null) => { @@ -323,16 +322,16 @@ function KeyEditSession({ const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; if (selectedTeam?.organization_id) { setSelectedOrganizationId(selectedTeam.organization_id); - form.setValue("organization_id", selectedTeam.organization_id); + form.setValue("organization_id", selectedTeam.organization_id, { shouldDirty: true }); } else if (!teamId) { setSelectedOrganizationId(null); - form.setValue("organization_id", null); + form.setValue("organization_id", null, { shouldDirty: true }); } }; const handleDisabledCallbacksChange = (internalValues: string[]) => { setDisabledCallbacks(mapInternalToDisplayNames(internalValues)); - form.setValue("disabled_callbacks", internalValues); + form.setValue("disabled_callbacks", internalValues, { shouldDirty: true }); }; const modelOptions = [ @@ -397,14 +396,14 @@ function KeyEditSession({ onChange={(value) => { switch (value) { case "default": - form.setValue("allowed_routes", ""); + form.setValue("allowed_routes", "", { shouldDirty: true }); break; case "llm_api": - form.setValue("allowed_routes", "llm_api_routes"); + form.setValue("allowed_routes", "llm_api_routes", { shouldDirty: true }); break; case "management": - form.setValue("allowed_routes", "management_routes"); - form.setValue("models", []); + form.setValue("allowed_routes", "management_routes", { shouldDirty: true }); + form.setValue("models", [], { shouldDirty: true }); break; } }} @@ -737,7 +736,7 @@ function KeyEditSession({ selectedAccessGroups={mcpSelection?.accessGroups || []} selectedToolsets={mcpSelection?.toolsets || []} toolPermissions={(mcpToolPermissions as Record | undefined) || {}} - onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)} + onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms, { shouldDirty: true })} /> @@ -799,7 +798,9 @@ function KeyEditSession({ canDetach={canDetachProject} pending={detachProject} disabled={isKeySaving} - onToggle={() => form.setValue("project_id", detachProject ? keyData.project_id : null)} + onToggle={() => + form.setValue("project_id", detachProject ? keyData.project_id : null, { shouldDirty: true }) + } /> )}