feat(HAI-068): add paste and drag-drop image upload to TaskDetailModal
- Extract uploadFile helper from file input handler for reuse - Add clipboard paste listener to upload pasted images automatically - Add drag-over and drop handlers on modal for drag-and-drop image upload - Add comprehensive tests for paste upload, non-image paste skip, uploading state, and drop upload
This commit is contained in:
@@ -115,9 +115,7 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, onRetryTask, onClose, addToast]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const attachment = await uploadAttachment(task.id, file);
|
||||
@@ -127,10 +125,52 @@ export function TaskDetailModal({
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}, [task.id, addToast]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadFile(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [uploadFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
e.preventDefault();
|
||||
uploadFile(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [uploadFile]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith("image/")) {
|
||||
uploadFile(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [uploadFile]);
|
||||
|
||||
const handleDeleteAttachment = useCallback(async (filename: string) => {
|
||||
try {
|
||||
await deleteAttachment(task.id, filename);
|
||||
@@ -145,7 +185,7 @@ export function TaskDetailModal({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal modal-lg">
|
||||
<div className="modal modal-lg" onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<span className="detail-id">{task.id}</span>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
import type { TaskDetail, Column, MergeResult, Task } from "@hai/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
return {
|
||||
id: "HAI-099",
|
||||
@@ -215,6 +220,180 @@ describe("TaskDetailModal", () => {
|
||||
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("HAI-099");
|
||||
});
|
||||
|
||||
describe("paste image upload", () => {
|
||||
it("uploads an image when pasting clipboard image data", async () => {
|
||||
const { uploadAttachment } = await import("../../api");
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
const mockAttachment = {
|
||||
filename: "abc123.png",
|
||||
originalName: "image.png",
|
||||
size: 1024,
|
||||
mimeType: "image/png",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
mockUpload.mockResolvedValueOnce(mockAttachment);
|
||||
const addToast = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
const imageFile = new File(["fake-image"], "image.png", { type: "image/png" });
|
||||
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
||||
pasteEvent.clipboardData = {
|
||||
items: [
|
||||
{
|
||||
type: "image/png",
|
||||
getAsFile: () => imageFile,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(pasteEvent);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("HAI-099", imageFile);
|
||||
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not intercept paste events without image data", async () => {
|
||||
const { uploadAttachment } = await import("../../api");
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
mockUpload.mockClear();
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
||||
pasteEvent.clipboardData = {
|
||||
items: [
|
||||
{
|
||||
type: "text/plain",
|
||||
getAsFile: () => null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(pasteEvent);
|
||||
});
|
||||
|
||||
expect(mockUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows uploading state during paste upload", async () => {
|
||||
const { uploadAttachment } = await import("../../api");
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
let resolveUpload!: (value: any) => void;
|
||||
mockUpload.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const imageFile = new File(["fake"], "shot.png", { type: "image/png" });
|
||||
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
||||
pasteEvent.clipboardData = {
|
||||
items: [{ type: "image/png", getAsFile: () => imageFile }],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(pasteEvent);
|
||||
});
|
||||
|
||||
// While uploading, button should show "Uploading…"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Uploading…")).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveUpload({
|
||||
filename: "x.png",
|
||||
originalName: "shot.png",
|
||||
size: 100,
|
||||
mimeType: "image/png",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Attach Screenshot")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("drag and drop image upload", () => {
|
||||
it("uploads an image when dropped onto the modal", async () => {
|
||||
const { uploadAttachment } = await import("../../api");
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
const mockAttachment = {
|
||||
filename: "drop123.png",
|
||||
originalName: "dropped.png",
|
||||
size: 2048,
|
||||
mimeType: "image/png",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
mockUpload.mockResolvedValueOnce(mockAttachment);
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modal = container.querySelector(".modal.modal-lg")!;
|
||||
const imageFile = new File(["fake-image"], "dropped.png", { type: "image/png" });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.drop(modal, {
|
||||
dataTransfer: {
|
||||
files: [imageFile],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("HAI-099", imageFile);
|
||||
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("activity list does not have nested scroll constraints", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
Reference in New Issue
Block a user