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.
This commit is contained in:
Anso
2026-05-24 23:54:35 -04:00
committed by GitHub
parent 4964320f50
commit ea002cd9a0
3 changed files with 106 additions and 24 deletions
+4 -2
View File
@@ -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
@@ -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<HTMLInputElement>(null);
const [isOver, setIsOver] = useState(false);
const [conflict, setConflict] = useState<File | null>(null);
if (!canEdit) return null;
@@ -55,6 +57,34 @@ export function FileUploadDropzone({
ev.target.value = '';
};
const handleDragOver = (ev: React.DragEvent<HTMLDivElement>) => {
if (!ev.dataTransfer.types.includes('Files')) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
if (!isOver) setIsOver(true);
};
const handleDragLeave = (ev: React.DragEvent<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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 (
<>
<input
@@ -67,7 +97,12 @@ export function FileUploadDropzone({
<div
role="button"
tabIndex={0}
className="border border-dashed border-border rounded-md p-2 text-xs text-muted-foreground flex items-center gap-2 cursor-pointer hover:border-brand/50 transition-colors"
className={cn(
'border border-dashed rounded-md p-2 text-xs flex items-center gap-2 cursor-pointer transition-colors',
isOver
? 'border-brand bg-brand/10 text-brand'
: 'border-border text-muted-foreground hover:border-brand/50',
)}
onClick={() => 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}
>
<UploadCloud className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
Upload file
{isOver ? 'Drop to upload' : 'Upload or drop file'}
</div>
<ConfirmModal
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Mock the API module FIRST so the real class export is replaced before
@@ -29,38 +29,80 @@ vi.mock('@/components/ui/toast-store', () => ({
import { FileUploadDropzone } from '../FileUploadDropzone';
import { uploadStackFile, UploadConflictError } from '@/lib/stackFilesApi';
import { toast } from '@/components/ui/toast-store';
const mockUpload = uploadStackFile as unknown as ReturnType<typeof vi.fn>;
const mockToastError = toast.error as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
mockUpload.mockReset();
mockToastError.mockReset();
});
describe('FileUploadDropzone', () => {
it('renders upload control for users with stack edit permission', () => {
render(
<FileUploadDropzone
stackName="app"
currentDir=""
canEdit
onUploaded={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
render(<FileUploadDropzone stackName="app" currentDir="" canEdit onUploaded={vi.fn()} />);
expect(screen.getByRole('button', { name: /upload or drop file/i })).toBeInTheDocument();
});
it('hides upload control when the user cannot edit the stack', () => {
render(
<FileUploadDropzone
stackName="app"
currentDir=""
canEdit={false}
onUploaded={vi.fn()}
/>,
);
render(<FileUploadDropzone stackName="app" currentDir="" canEdit={false} onUploaded={vi.fn()} />);
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(<FileUploadDropzone stackName="app" currentDir="config" canEdit onUploaded={onUploaded} />);
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(<FileUploadDropzone stackName="app" currentDir="" canEdit onUploaded={vi.fn()} />);
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(<FileUploadDropzone stackName="app" currentDir="" canEdit onUploaded={vi.fn()} />);
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 () => {