feat(ui): add attachment download actions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-19 12:08:34 +00:00
1 parent e250a31982
commit aae9d279c6
2 files changed
+179 -24

No files matched your search

@@ -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<void>) {
let result: void | Promise<void> = 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(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={attachments}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={onDelete}
onImageClick={onImageClick}
/>
</QueryClientProvider>,
);
});
await flushReact();
const download = container.querySelector<HTMLAnchorElement>('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<HTMLButtonElement>('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<HTMLElement>('[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<HTMLButtonElement>("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?");
});
});
+64 -23
View File
@@ -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({
</a>
</Button>
{onDelete ? (
<Button
variant="ghost"
size="icon-sm"
title="Delete attachment"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(attachment.id)}
disabled={deletePending}
>
<Trash2 className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
title="Attachment actions"
aria-label={`More actions for ${filename}`}
disabled={deletePending}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(attachment.id)}>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
@@ -337,19 +352,45 @@ export function IssueAttachmentsSection({
</button>
</div>
</div>
) : onDelete ? (
<button
type="button"
className="absolute right-1.5 top-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 transition-opacity hover:bg-destructive group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
requestDelete(attachment.id);
}}
title="Delete attachment"
) : (
<div
className="absolute right-1.5 top-1.5 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
onClick={(event) => event.stopPropagation()}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
<a
href={attachmentDownloadPath(attachment)}
className="rounded-md bg-black/50 p-1 text-white hover:bg-black/70"
aria-label={`Download ${attachmentFilename(attachment)}`}
title="Download"
>
<Download className="h-3.5 w-3.5" />
</a>
{onDelete ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="rounded-md bg-black/50 p-1 text-white hover:bg-black/70"
aria-label={`More actions for ${attachmentFilename(attachment)}`}
title="Attachment actions"
disabled={deletePending}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onSelect={() => requestDelete(attachment.id)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
)}
</div>
))}
</div>