mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
fix(ui): preserve key settings drafts during background refreshes
This commit is contained in:
parent
a9a654cb60
commit
07f9e96fdb
3 changed files with 101 additions and 16 deletions
|
|
@ -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<void>();
|
||||
const releaseSearch = Promise.withResolvers<void>();
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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(<KeyEditView {...props} />);
|
||||
|
||||
fireEvent.change(await screen.findByRole("spinbutton", { name: "Max Budget (USD)" }), {
|
||||
target: { value: "12.5" },
|
||||
});
|
||||
view.rerender(<KeyEditView {...props} keyData={{ ...MOCK_KEY_DATA, max_budget: 99 }} />);
|
||||
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(<KeyEditView {...props} />);
|
||||
fireEvent.change(await screen.findByRole("spinbutton", { name: "Max Budget (USD)" }), {
|
||||
target: { value: "12.5" },
|
||||
});
|
||||
view.rerender(
|
||||
<KeyEditView
|
||||
{...props}
|
||||
keyData={{ ...MOCK_KEY_DATA, token: "different-key", token_id: "different-key", max_budget: 25 }}
|
||||
/>,
|
||||
);
|
||||
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"));
|
||||
|
|
|
|||
|
|
@ -82,8 +82,12 @@ interface KeyEditViewProps {
|
|||
premiumUser?: boolean;
|
||||
}
|
||||
|
||||
export function KeyEditView({
|
||||
keyData,
|
||||
export function KeyEditView(props: KeyEditViewProps) {
|
||||
return <KeyEditSession key={props.keyData.token} {...props} />;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue