From ea002cd9a0a9950225326660944efbeb3583a455 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 24 May 2026 23:54:35 -0400 Subject: [PATCH] feat(stack-files): drag-and-drop upload zone (#1207) * feat(stack-files): drag-and-drop upload zone The Files-tab dropzone was a click-only button. Drag-and-drop is the standard file-manager affordance and the audit doc named its absence as a known gap. The same dropzone now accepts file drops, gates the affordance on canEdit, and reuses the existing handleFile pipeline so all the existing rules apply: 25 MB cap, server-side path validation, multi-file drops rejected up front with a clear toast (the upload route only handles one file per request). dragenter+dragover light the zone in the brand color and swap the label to "Drop to upload"; dragleave honours bubbling from child nodes so the highlight does not flicker when the cursor crosses an inner icon. Drag events without a Files payload (text selection, image drag from another page) are explicitly ignored so the zone does not light up for non-file content. * test(stack-files): widen upload-zone E2E selector to match new label The drag-drop work renamed the dropzone label from 'Upload file' to 'Upload or drop file' (and 'Drop to upload' during hover). The E2E test in stack-files.spec.ts filtered on the literal /upload file/i, which no longer matches the new label and the test timed out at the visibility assertion. The hidden file input's aria-label='Upload file' was deliberately preserved, so every other locator in the spec (input[aria-label], getByLabel) keeps working. Only the role='button' filter needed the update. Widen the regex to /upload/i so future copy edits do not re-break this assertion. --- e2e/stack-files.spec.ts | 6 +- .../components/files/FileUploadDropzone.tsx | 42 +++++++++- .../__tests__/FileUploadDropzone.test.tsx | 82 ++++++++++++++----- 3 files changed, 106 insertions(+), 24 deletions(-) diff --git a/e2e/stack-files.spec.ts b/e2e/stack-files.spec.ts index 5fed6980..1d561c72 100644 --- a/e2e/stack-files.spec.ts +++ b/e2e/stack-files.spec.ts @@ -239,9 +239,11 @@ test.describe('File explorer - admin (full CRUD)', () => { }); test('upload a text file and verify it appears in the tree', async ({ page }) => { - // Admins with stack:edit see the upload dropzone on every tier. + // Admins with stack:edit see the upload dropzone on every tier. The + // dropzone label includes "upload" (e.g. "Upload or drop file"); match + // on the word alone so future copy edits don't break this assertion. await expect( - page.locator('[role="button"]').filter({ hasText: /upload file/i }).first() + page.locator('[role="button"]').filter({ hasText: /upload/i }).first() ).toBeVisible({ timeout: 5_000 }); // Set a file on the hidden input diff --git a/frontend/src/components/files/FileUploadDropzone.tsx b/frontend/src/components/files/FileUploadDropzone.tsx index c2d47e3b..2463500b 100644 --- a/frontend/src/components/files/FileUploadDropzone.tsx +++ b/frontend/src/components/files/FileUploadDropzone.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import { UploadCloud } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { uploadStackFile, UploadConflictError } from '@/lib/stackFilesApi'; @@ -20,6 +21,7 @@ export function FileUploadDropzone({ onUploaded, }: FileUploadDropzoneProps) { const inputRef = useRef(null); + const [isOver, setIsOver] = useState(false); const [conflict, setConflict] = useState(null); if (!canEdit) return null; @@ -55,6 +57,34 @@ export function FileUploadDropzone({ ev.target.value = ''; }; + const handleDragOver = (ev: React.DragEvent) => { + if (!ev.dataTransfer.types.includes('Files')) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + if (!isOver) setIsOver(true); + }; + + const handleDragLeave = (ev: React.DragEvent) => { + // Ignore leave events that bubble from child elements still within the + // dropzone (relatedTarget is contained by currentTarget); only react when + // the cursor truly leaves the zone. + const next = ev.relatedTarget; + if (next instanceof Node && ev.currentTarget.contains(next)) return; + setIsOver(false); + }; + + const handleDrop = (ev: React.DragEvent) => { + ev.preventDefault(); + setIsOver(false); + const files = ev.dataTransfer.files; + if (!files || files.length === 0) return; + if (files.length > 1) { + toast.error('Drop one file at a time.'); + return; + } + void handleFile(files[0]); + }; + return ( <> inputRef.current?.click()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -75,9 +110,12 @@ export function FileUploadDropzone({ inputRef.current?.click(); } }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} > - Upload file + {isOver ? 'Drop to upload' : 'Upload or drop file'} ({ import { FileUploadDropzone } from '../FileUploadDropzone'; import { uploadStackFile, UploadConflictError } from '@/lib/stackFilesApi'; +import { toast } from '@/components/ui/toast-store'; const mockUpload = uploadStackFile as unknown as ReturnType; +const mockToastError = toast.error as unknown as ReturnType; beforeEach(() => { mockUpload.mockReset(); + mockToastError.mockReset(); }); describe('FileUploadDropzone', () => { it('renders upload control for users with stack edit permission', () => { - render( - , - ); - - expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument(); + render(); + expect(screen.getByRole('button', { name: /upload or drop file/i })).toBeInTheDocument(); }); it('hides upload control when the user cannot edit the stack', () => { - render( - , - ); + render(); + expect(screen.queryByRole('button', { name: /upload or drop file/i })).not.toBeInTheDocument(); + }); - expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument(); + it('uploads via the same pipeline when a file is dropped onto the zone', async () => { + mockUpload.mockResolvedValueOnce(undefined); + const onUploaded = vi.fn(); + render(); + const zone = screen.getByRole('button', { name: /upload or drop file/i }); + + const file = new File(['hello'], 'drop.txt', { type: 'text/plain' }); + const dataTransfer = { + files: [file], + types: ['Files'], + dropEffect: 'copy', + } as unknown as DataTransfer; + + fireEvent.dragOver(zone, { dataTransfer }); + expect(screen.getByText(/drop to upload/i)).toBeInTheDocument(); + + fireEvent.drop(zone, { dataTransfer }); + await waitFor(() => expect(mockUpload).toHaveBeenCalledTimes(1)); + // Drop path reuses runUpload with overwrite=false (matching the click-pick path). + expect(mockUpload).toHaveBeenCalledWith('app', 'config', file, { overwrite: false }); + await waitFor(() => expect(onUploaded).toHaveBeenCalledTimes(1)); + }); + + it('rejects a multi-file drop with a clear toast and does not upload', async () => { + render(); + const zone = screen.getByRole('button', { name: /upload or drop file/i }); + + const a = new File(['a'], 'a.txt'); + const b = new File(['b'], 'b.txt'); + const dataTransfer = { + files: [a, b], + types: ['Files'], + dropEffect: 'copy', + } as unknown as DataTransfer; + + fireEvent.drop(zone, { dataTransfer }); + expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/one file at a time/i)); + expect(mockUpload).not.toHaveBeenCalled(); + }); + + it('ignores drag events that do not carry files (e.g. text selection)', () => { + render(); + const zone = screen.getByRole('button', { name: /upload or drop file/i }); + + const dataTransfer = { + files: [], + types: ['text/plain'], + dropEffect: 'none', + } as unknown as DataTransfer; + + fireEvent.dragOver(zone, { dataTransfer }); + // Zone never enters the active "Drop to upload" state. + expect(screen.queryByText(/drop to upload/i)).not.toBeInTheDocument(); }); it('opens the replace dialog on FILE_EXISTS and retries with overwrite on confirm', async () => {