mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): add attachment download actions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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?");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user