fix(ui): refresh untouched key settings while preserving edits

This commit is contained in:
Yuneng Jiang 2026-09-15 18:43:14 -07:00
parent 07f9e96fdb
commit 1205311df9
No known key found for this signature in database
3 changed files with 46 additions and 22 deletions

View file

@ -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<void>();

View file

@ -998,13 +998,31 @@ describe("KeyEditView", () => {
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 }} />);
view.rerender(
<KeyEditView
{...props}
keyData={{
...MOCK_KEY_DATA,
max_budget: 99,
rpm_limit: 75,
models: ["gpt-4"],
metadata: { note: "new" },
object_permission: { ...MOCK_KEY_DATA.object_permission, vector_stores: ["store-new"] },
}}
/>,
);
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)));

View file

@ -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<KeyEditFormValues, KeyEditFormValues>(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<string[]>([]);
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
@ -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<string, string[]> | undefined) || {}}
onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)}
onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms, { shouldDirty: true })}
/>
</div>
@ -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 })
}
/>
)}