From 07f9e96fdbf65ed438b5c6c82e4c7739b072d6d2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 18:30:17 -0700 Subject: [PATCH] fix(ui): preserve key settings drafts during background refreshes --- .../tests/proxy-admin/keyBudgetWindow.spec.ts | 47 +++++++++++++--- .../key_edit_view.integration.test.tsx | 55 ++++++++++++++++++- .../components/templates/key_edit_view.tsx | 15 +++-- 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts b/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts index 4e4d0a395c3..5e76ab21315 100644 --- a/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keyBudgetWindow.spec.ts @@ -18,8 +18,11 @@ const test = base.extend<{ scopedKey: ScopedKey }>({ team_id: E2E_TEAM_CRUD_ID, models: [CHAT_MODEL_A], }); - await use({ alias, token: created.token }); - await deleteVirtualKey(page.request, created.token); + try { + await use({ alias, token: created.token }); + } finally { + await deleteVirtualKey(page.request, created.token); + } }, }); @@ -34,15 +37,41 @@ test.describe("Proxy Admin - Key budget window", () => { await navigateToPage(page, Page.ApiKeys); await dismissFeedbackPopup(page); - await openKeyDetail(page, alias); - - await page.getByRole("tab", { name: "Settings" }).click(); - await page.getByRole("button", { name: "Edit Settings" }).click(); - - await page.getByRole("spinbutton", { name: "Max Budget (USD)" }).fill("12.5"); + // 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(); + const releaseSearch = Promise.withResolvers(); + const isAliasSearch = (url: string) => { + const parsed = new URL(url); + return parsed.pathname.endsWith("/key/list") && parsed.searchParams.get("search") === alias; + }; + await page.route("**/key/list?**", async (route) => { + if (!isAliasSearch(route.request().url())) return route.continue(); + const response = await route.fetch(); + searchReceived.resolve(); + await releaseSearch.promise; + await route.fulfill({ response }); + }); + try { + await openKeyDetail(page, alias); + await page.getByRole("tab", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Edit Settings" }).click(); + await page.getByRole("spinbutton", { name: "Max Budget (USD)" }).fill("12.5"); + await searchReceived.promise; + const refreshed = page.waitForResponse((response) => isAliasSearch(response.url())); + releaseSearch.resolve(); + await refreshed; + } finally { + releaseSearch.resolve(); + await page.unrouteAll({ behavior: "wait" }); + } await page.getByLabel("Reset Budget", { exact: true }).click(); await page.getByRole("option", { name: "monthly", exact: true }).click(); - await page.getByRole("button", { name: "Save Changes" }).click(); + const saved = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/update" }, async () => { + await page.getByRole("button", { name: "Save Changes" }).click(); + }); + expect(saved.max_budget, "a delayed search response must not erase the budget draft").toBe("12.5"); + expect(saved.budget_duration).toBe("30d"); await expect .poll(async () => (await readKeyInfo(page.request, token)).max_budget, { 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 9efcff04832..10d718d368f 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 @@ -983,6 +983,55 @@ describe("KeyEditView", () => { }); }); + it("should preserve a budget draft when the same key refreshes before saving", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const props = { + keyData: MOCK_KEY_DATA, + onCancel: vi.fn(), + onSubmit, + accessToken: "test-token", + userID: "test-user", + userRole: "admin", + }; + const view = renderWithProviders(); + + fireEvent.change(await screen.findByRole("spinbutton", { name: "Max Budget (USD)" }), { + target: { value: "12.5" }, + }); + 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" })), + ); + }); + + it("should start a fresh budget draft when switching to another key", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const props = { + keyData: MOCK_KEY_DATA, + onCancel: vi.fn(), + onSubmit, + accessToken: "test-token", + userID: "test-user", + userRole: "admin", + }; + const view = renderWithProviders(); + fireEvent.change(await screen.findByRole("spinbutton", { name: "Max Budget (USD)" }), { + target: { value: "12.5" }, + }); + view.rerender( + , + ); + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ max_budget: 25 }))); + }); + it("should persist a canonical budget_duration value, not a word-form the backend cannot parse", async () => { const onSubmitMock = vi.fn().mockResolvedValue(undefined); renderWithProviders( @@ -1521,7 +1570,7 @@ describe("KeyEditView", () => { }); }); - it("should save an explicit project detach while keeping parents locked until the saved key changes", async () => { + it("should save an explicit project detach while keeping parents locked until reopening the editor", async () => { vi.mocked(getUiSettings).mockResolvedValue({ values: { enable_projects_ui: true } }); const onSubmit = vi.fn().mockResolvedValue(undefined); const onCancel = vi.fn(); @@ -1551,15 +1600,19 @@ describe("KeyEditView", () => { await userEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(onCancel).toHaveBeenCalledOnce(); expect(onSubmit).not.toHaveBeenCalled(); + // KeyInfoView unmounts the editor on cancel and after a successful save. + view.rerender(<>); view.rerender(renderEditor({ ...key })); await userEvent.click(await screen.findByRole("button", { name: "Detach from project" })); 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))); expect(screen.getByRole("combobox", { name: "Team ID" })).toBeDisabled(); + view.rerender(<>); view.rerender(renderEditor({ ...key, project_id: null })); expect(screen.getByRole("combobox", { name: "Team ID" })).toBeEnabled(); expect(screen.queryByRole("button", { name: "Detach from project" })).not.toBeInTheDocument(); + view.rerender(<>); view.rerender(renderEditor(key, "Internal User")); expect(screen.queryByRole("button", { name: "Detach from project" })).not.toBeInTheDocument(); view.rerender(renderEditor(key, "Org Admin")); 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 6a94cbcf2a0..f856ddc36df 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -82,8 +82,12 @@ interface KeyEditViewProps { premiumUser?: boolean; } -export function KeyEditView({ - keyData, +export function KeyEditView(props: KeyEditViewProps) { + return ; +} + +function KeyEditSession({ + keyData: initialKeyData, onCancel, onSubmit, teams, @@ -92,6 +96,9 @@ export function KeyEditView({ 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"); @@ -181,10 +188,6 @@ export function KeyEditView({ form.setValue("disabled_callbacks", disabledCallbacks); }, [form, disabledCallbacks]); - useEffect(() => { - form.reset(toKeyEditFormValues(keyData)); - }, [keyData, form]); - // Sync auto-rotation state with form values useEffect(() => { form.setValue("auto_rotate", autoRotationEnabled);