diff --git a/ui/src/components/IssueAttachmentsSection.test.tsx b/ui/src/components/IssueAttachmentsSection.test.tsx index d7c67f7bf7c..7539db3ad2c 100644 --- a/ui/src/components/IssueAttachmentsSection.test.tsx +++ b/ui/src/components/IssueAttachmentsSection.test.tsx @@ -33,6 +33,10 @@ vi.mock("@/components/ui/button", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} async function act(callback: () => void | Promise) { let result: void | Promise = undefined; @@ -142,6 +146,116 @@ describe("IssueAttachmentsSection", () => { expect(container.querySelector('button[aria-label="Upload attachment"]')).toBeTruthy(); }); + it("offers individual downloads for images, markdown, videos, and generic files", async () => { + const attachments = [ + makeAttachment({ + id: "image-attachment", + originalFilename: "diagram.png", + contentType: "image/png", + contentPath: "/api/attachments/image-attachment/content", + }), + makeAttachment({ + id: "markdown-attachment", + originalFilename: "notes.md", + contentType: "text/markdown", + contentPath: "/api/attachments/markdown-attachment/content", + }), + makeAttachment({ + id: "video-attachment", + originalFilename: "demo.mp4", + contentType: "video/mp4", + contentPath: "/api/attachments/video-attachment/content", + }), + makeAttachment({ + id: "generic-attachment", + originalFilename: "report.pdf", + contentType: "application/pdf", + contentPath: "/api/attachments/generic-attachment/content", + }), + ]; + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + for (const attachment of attachments) { + const filename = attachment.originalFilename!; + expect(container.querySelector(`a[aria-label="Download ${filename}"]`)?.getAttribute("href")).toBe( + `${attachment.contentPath}?download=1`, + ); + } + }); + + it("downloads images from the hover action and keeps confirmed deletion in the kebab menu", async () => { + const attachment = makeAttachment({ + id: "image-attachment", + originalFilename: "diagram.png", + contentType: "image/png", + contentPath: "/api/attachments/image-attachment/content", + }); + const onDelete = vi.fn(); + const onImageClick = vi.fn(); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const download = container.querySelector('a[aria-label="Download diagram.png"]'); + expect(download?.getAttribute("href")).toBe("/api/attachments/image-attachment/content?download=1"); + + await act(async () => { + download?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onImageClick).not.toHaveBeenCalled(); + + const menuTrigger = container.querySelector('button[aria-label="More actions for diagram.png"]'); + expect(menuTrigger).toBeTruthy(); + expect(container.querySelector('button[title="Delete attachment"]')).toBeNull(); + + await act(async () => { + menuTrigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); + }); + await flushReact(); + + const deleteItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((item) => item.textContent?.trim() === "Delete"); + expect(deleteItem).toBeTruthy(); + + await act(async () => { + deleteItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain("Delete?"); + expect(onDelete).not.toHaveBeenCalled(); + + const confirmButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Yes"); + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onDelete).toHaveBeenCalledWith("image-attachment"); + }); + it("renders markdown attachments with the document markdown presentation", async () => { const attachment = makeAttachment({ id: "markdown-attachment", @@ -374,7 +488,7 @@ describe("IssueAttachmentsSection", () => { expect(container.textContent).toContain("stored-report.pdf"); expect(container.querySelector('a[aria-label="Open stored-report.pdf"]')).toBeTruthy(); expect(container.querySelector('a[aria-label="Download stored-report.pdf"]')).toBeTruthy(); - expect(container.querySelector('button[title="Delete attachment"]')).toBeNull(); + expect(container.querySelector('button[aria-label="More actions for stored-report.pdf"]')).toBeNull(); expect(container.textContent).not.toContain("Delete this attachment?"); }); }); diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx index 0ee7bf9b15f..b376ca2e239 100644 --- a/ui/src/components/IssueAttachmentsSection.tsx +++ b/ui/src/components/IssueAttachmentsSection.tsx @@ -1,8 +1,14 @@ import { useMemo, useState, type DragEvent, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import type { IssueAttachment } from "@paperclipai/shared"; -import { Download, ExternalLink, FileText, Maximize2, Paperclip, Trash2 } from "lucide-react"; +import { Download, ExternalLink, FileText, Maximize2, MoreHorizontal, Paperclip, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { FoldCurtain } from "./FoldCurtain"; import { MarkdownBody } from "./MarkdownBody"; import { OutputFileTile } from "./issue-output/OutputFileTile"; @@ -81,16 +87,25 @@ function AttachmentActions({ {onDelete ? ( - + + + + + + onDelete(attachment.id)}> + + Delete + + + ) : null} ); @@ -337,19 +352,45 @@ export function IssueAttachmentsSection({ - ) : onDelete ? ( - - ) : null} + + + + {onDelete ? ( + + + + + + requestDelete(attachment.id)} + > + + Delete + + + + ) : null} + + )} ))}