From 0ba8adffed13f4fc0c97424c251327da068b88fb Mon Sep 17 00:00:00 2001 From: Lucas Song Date: Thu, 9 Apr 2026 01:19:54 -0700 Subject: [PATCH] fix(ui): use mutation for attachment delete Adopt a React Query mutation for policy attachment deletion and add a pending-state test on the policies index panel. This removes local delete-loading state and keeps modal loading tied to mutation status. Made-with: Cursor --- ...policies_panel.test.tsx => index.test.tsx} | 35 +++++++++++++++++++ .../src/components/policies/index.tsx | 31 ++++++++++------ 2 files changed, 55 insertions(+), 11 deletions(-) rename ui/litellm-dashboard/src/components/policies/{policies_panel.test.tsx => index.test.tsx} (80%) diff --git a/ui/litellm-dashboard/src/components/policies/policies_panel.test.tsx b/ui/litellm-dashboard/src/components/policies/index.test.tsx similarity index 80% rename from ui/litellm-dashboard/src/components/policies/policies_panel.test.tsx rename to ui/litellm-dashboard/src/components/policies/index.test.tsx index ea1fdd9aa61..4a33e8905a5 100644 --- a/ui/litellm-dashboard/src/components/policies/policies_panel.test.tsx +++ b/ui/litellm-dashboard/src/components/policies/index.test.tsx @@ -182,4 +182,39 @@ describe("PoliciesPanel attachment delete", () => { expect(networkingMocks.deletePolicyAttachmentCall).toHaveBeenCalledWith("test-token", EXPECTED_ATTACHMENT_ID); }); }); + + it("should show mutation pending state while attachment delete is in flight", async () => { + let resolveDelete: (() => void) | undefined; + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve; + }); + networkingMocks.deletePolicyAttachmentCall.mockImplementationOnce(() => deletePromise); + + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => { + expect(networkingMocks.getPolicyAttachmentsList).toHaveBeenCalled(); + }); + + await user.click(screen.getByRole("tab", { name: /^attachments$/i })); + await waitFor(() => { + expect(screen.getByText("test-policy")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /TrashIcon/i })); + const dialog = await screen.findByRole("dialog", {}, { timeout: 5000 }); + + const deleteButton = within(dialog).getByRole("button", { name: /^delete$/i }); + await user.click(deleteButton); + + await waitFor(() => { + expect(within(dialog).getByRole("button", { name: /deleting/i })).toBeDisabled(); + }); + + resolveDelete?.(); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/policies/index.tsx b/ui/litellm-dashboard/src/components/policies/index.tsx index 663c7bb3128..10a6848bd52 100644 --- a/ui/litellm-dashboard/src/components/policies/index.tsx +++ b/ui/litellm-dashboard/src/components/policies/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { Alert } from "antd"; +import { useMutation } from "@tanstack/react-query"; import MessageManager from "@/components/molecules/message_manager"; import { InfoCircleOutlined } from "@ant-design/icons"; import { isAdminRole } from "@/utils/roles"; @@ -57,7 +58,6 @@ const PoliciesPanel: React.FC = ({ const [isDeleting, setIsDeleting] = useState(false); const [policyToDelete, setPolicyToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isDeletingAttachment, setIsDeletingAttachment] = useState(false); const [attachmentToDelete, setAttachmentToDelete] = useState(null); const [isDeleteAttachmentModalOpen, setIsDeleteAttachmentModalOpen] = useState(false); const [isGuardrailSelectionModalOpen, setIsGuardrailSelectionModalOpen] = useState(false); @@ -169,6 +169,22 @@ const PoliciesPanel: React.FC = ({ setPolicyToDelete(null); }; + const deleteAttachmentMutation = useMutation({ + mutationFn: async (attachmentId: string) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return deletePolicyAttachmentCall(accessToken, attachmentId); + }, + onSuccess: async () => { + MessageManager.success("Attachment deleted successfully"); + await fetchAttachments(); + }, + onError: (error) => { + console.error("Error deleting attachment:", error); + MessageManager.error("Failed to delete attachment"); + }, + }); const handleDeleteAttachmentClick = (attachmentId: string) => { const attachment = attachmentsList.find((a) => a.attachment_id === attachmentId) || null; setAttachmentToDelete(attachment); @@ -181,17 +197,10 @@ const PoliciesPanel: React.FC = ({ }; const handleAttachmentDeleteConfirm = async () => { - if (!attachmentToDelete || !accessToken) return; - setIsDeletingAttachment(true); + if (!attachmentToDelete) return; try { - await deletePolicyAttachmentCall(accessToken, attachmentToDelete.attachment_id); - MessageManager.success("Attachment deleted successfully"); - await fetchAttachments(); - } catch (error) { - console.error("Error deleting attachment:", error); - MessageManager.error("Failed to delete attachment"); + await deleteAttachmentMutation.mutateAsync(attachmentToDelete.attachment_id); } finally { - setIsDeletingAttachment(false); setIsDeleteAttachmentModalOpen(false); setAttachmentToDelete(null); } @@ -621,7 +630,7 @@ const PoliciesPanel: React.FC = ({ ]} onCancel={handleAttachmentDeleteCancel} onOk={handleAttachmentDeleteConfirm} - confirmLoading={isDeletingAttachment} + confirmLoading={deleteAttachmentMutation.isPending} />