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]);
|
}, [task.id, onRetryTask, onClose, addToast]);
|
||||||
|
|
||||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadFile = useCallback(async (file: File) => {
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const attachment = await uploadAttachment(task.id, file);
|
const attachment = await uploadAttachment(task.id, file);
|
||||||
@@ -127,10 +125,52 @@ export function TaskDetailModal({
|
|||||||
addToast(err.message, "error");
|
addToast(err.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
||||||
}
|
}
|
||||||
}, [task.id, addToast]);
|
}, [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) => {
|
const handleDeleteAttachment = useCallback(async (filename: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteAttachment(task.id, filename);
|
await deleteAttachment(task.id, filename);
|
||||||
@@ -145,7 +185,7 @@ export function TaskDetailModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
<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="modal-header">
|
||||||
<div className="detail-title-row">
|
<div className="detail-title-row">
|
||||||
<span className="detail-id">{task.id}</span>
|
<span className="detail-id">{task.id}</span>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
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 { TaskDetailModal } from "../TaskDetailModal";
|
||||||
import type { TaskDetail, Column, MergeResult, Task } from "@hai/core";
|
import type { TaskDetail, Column, MergeResult, Task } from "@hai/core";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
uploadAttachment: vi.fn(),
|
||||||
|
deleteAttachment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||||
return {
|
return {
|
||||||
id: "HAI-099",
|
id: "HAI-099",
|
||||||
@@ -215,6 +220,180 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("HAI-099");
|
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", () => {
|
it("activity list does not have nested scroll constraints", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
|
|||||||
Reference in New Issue
Block a user