Skip to content

Commit

Permalink
feat(dropzone): add dropzone support for files within dropped directo…
Browse files Browse the repository at this point in the history
…ries (#6062)

* feat(dropzone): allow dropzone to handle directory contents and multiple files

* chore: update usage of onDrop to reflect it is now async

* chore: make onDrop synchronous, move traversal into util, add util tests

* chore: rearrange onDrop logic for better readability

* chore: clean up unnecessary typing

* chore: bumping size limit for components which use dropzone

* chore(tests): add testing for directories and mixed files/directories

* chore: rename util, add destructuring, simplify logic

* chore: simplify logic in onDrop

* chore: remove unnecessary return

* chore: destructure data transfer item

* chore: converting ternary to if/else
  • Loading branch information
jordanvn authored Nov 14, 2024
1 parent 41e88d8 commit 270995a
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 9 deletions.
25 changes: 18 additions & 7 deletions packages/react-core/src/hooks/useDropZone.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { isFunction } from '@aws-amplify/ui';
import { filterAllowedFiles } from '../utils/filterAllowedFiles';
import { processDroppedItems } from '../utils/processDroppedItems';

interface DragEvents {
onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;
Expand Down Expand Up @@ -85,17 +86,27 @@ export default function useDropZone({
event.preventDefault();
event.stopPropagation();
setDragState('inactive');
const files = Array.from(event.dataTransfer.files);
const { acceptedFiles, rejectedFiles } = filterAllowedFiles<File>(
files,
acceptedFileTypes
);

const { files, items } = event.dataTransfer;

if (isFunction(_onDrop)) {
_onDrop(event);
}
if (isFunction(onDropComplete)) {
onDropComplete({ acceptedFiles, rejectedFiles });

const completeDrop = (files: File[]) => {
const { acceptedFiles, rejectedFiles } = filterAllowedFiles<File>(
files,
acceptedFileTypes
);
if (isFunction(onDropComplete)) {
onDropComplete({ acceptedFiles, rejectedFiles });
}
};

if (!items) {
completeDrop(Array.from(files));
} else {
processDroppedItems(Array.from(items)).then(completeDrop);
}
};

Expand Down
141 changes: 141 additions & 0 deletions packages/react-core/src/utils/__tests__/processDroppedEntries.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { processDroppedItems } from '../processDroppedItems';

describe('processDroppedItems', () => {
const mockFileData = new Blob(['test content'], { type: 'text/plain' });
const mockFile = new File([mockFileData], 'test.txt', { type: 'text/plain' });

const createMockFileEntry = (file: File): FileSystemFileEntry => ({
isFile: true,
isDirectory: false,
name: file.name,
fullPath: `/${file.name}`,
filesystem: {
name: 'temporary',
root: {} as FileSystemDirectoryEntry,
},
file: (callback: (file: File) => void) => callback(file),
getParent: jest.fn(),
});

const createMockDirectoryEntry = (
files: File[]
): FileSystemDirectoryEntry => {
const entries = files.map((file) => createMockFileEntry(file));
let firstCall = true;
return {
getDirectory: jest.fn(),
getFile: jest.fn(),
isFile: false,
isDirectory: true,
name: 'test-directory',
fullPath: '/test-directory',
filesystem: {
name: 'temporary',
root: {} as FileSystemDirectoryEntry,
},
createReader: () => ({
readEntries: (resolve) => {
if (firstCall) {
firstCall = false;
resolve(entries);
} else {
resolve([]);
}
},
}),
getParent: jest.fn(),
};
};

const createMockDataTransferItem = (
entry: FileSystemEntry
): DataTransferItem => ({
getAsFile: jest.fn(),
getAsString: jest.fn(),
kind: 'file',
type: 'text/plain',
webkitGetAsEntry: () => entry,
});

it('should process a single file', async () => {
const fileEntry = createMockFileEntry(mockFile);
const items = [createMockDataTransferItem(fileEntry)];

const result = await processDroppedItems(items);

expect(result).toHaveLength(1);
expect(result[0].name).toBe('test.txt');
expect(result[0].type).toBe('text/plain');
});

it('should process multiple files', async () => {
const file1 = new File([mockFileData], 'test1.txt', { type: 'text/plain' });
const file2 = new File([mockFileData], 'test2.txt', { type: 'text/plain' });
const items = [
createMockDataTransferItem(createMockFileEntry(file1)),
createMockDataTransferItem(createMockFileEntry(file2)),
];

const result = await processDroppedItems(items);

expect(result).toHaveLength(2);
expect(result[0].name).toBe('test1.txt');
expect(result[1].name).toBe('test2.txt');
});

it('should process files in a directory', async () => {
const filesInDir = [
new File([mockFileData], 'dir-file1.txt', { type: 'text/plain' }),
new File([mockFileData], 'dir-file2.txt', { type: 'text/plain' }),
];
const dirEntry = createMockDirectoryEntry(filesInDir);
const items = [createMockDataTransferItem(dirEntry)];

const result = await processDroppedItems(items);

expect(result).toHaveLength(2);
expect(result[0].name).toBe('dir-file1.txt');
expect(result[1].name).toBe('dir-file2.txt');
});

it('should process mixed files and directories', async () => {
const singleFile = new File([mockFileData], 'single.txt', {
type: 'text/plain',
});
const filesInDir = [
new File([mockFileData], 'dir-file1.txt', { type: 'text/plain' }),
new File([mockFileData], 'dir-file2.txt', { type: 'text/plain' }),
];
const items = [
createMockDataTransferItem(createMockFileEntry(singleFile)),
createMockDataTransferItem(createMockDirectoryEntry(filesInDir)),
];

const result = await processDroppedItems(items);

expect(result).toHaveLength(3);
expect(result.map((f) => f.name)).toContain('single.txt');
expect(result.map((f) => f.name)).toContain('dir-file1.txt');
expect(result.map((f) => f.name)).toContain('dir-file2.txt');
});

it('should handle empty items array', async () => {
const result = await processDroppedItems([]);

expect(result).toHaveLength(0);
});

it('should handle non-file items', async () => {
const items = [
{
kind: 'string',
type: 'text/plain',
webkitGetAsEntry: () => null,
},
] as DataTransferItem[];

const result = await processDroppedItems(items);

expect(result).toHaveLength(0);
});
});
62 changes: 62 additions & 0 deletions packages/react-core/src/utils/processDroppedItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Helper function to convert FileSystemFileEntry to File
const getFileFromEntry = (fileEntry: FileSystemFileEntry): Promise<File> => {
return new Promise((resolve) => {
fileEntry.file(resolve);
});
};

// Helper function to read all entries in a directory
const readAllDirectoryEntries = async (
dirReader: FileSystemDirectoryReader
): Promise<FileSystemEntry[]> => {
const entries: FileSystemEntry[] = [];

let readBatch: FileSystemEntry[] = [];
do {
readBatch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
try {
dirReader.readEntries(resolve, reject);
} catch (error) {
reject(error);
}
});
entries.push(...readBatch);
} while (readBatch.length > 0);

return entries;
};

// Helper function to process files and folder contents
export async function processDroppedItems(
dataTransferItems: DataTransferItem[]
): Promise<File[]> {
const files: File[] = [];

const processFileSystemEntry = async (
entry: FileSystemEntry
): Promise<void> => {
if (entry.isFile) {
const file = await getFileFromEntry(entry as FileSystemFileEntry);
files.push(file);
} else if (entry.isDirectory) {
const dirReader = (entry as FileSystemDirectoryEntry).createReader();
const dirEntries = await readAllDirectoryEntries(dirReader);
await Promise.all(dirEntries.map(processFileSystemEntry));
}
};

// Filter out and process files from the data transfer items
await Promise.all(
dataTransferItems
.reduce<FileSystemEntry[]>(
(acc, { kind, webkitGetAsEntry }) =>
kind === 'file' && webkitGetAsEntry()
? [...acc, webkitGetAsEntry()!]
: acc,
[]
)
.map(processFileSystemEntry)
);

return files;
}
4 changes: 2 additions & 2 deletions packages/react-storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"name": "FileUploader",
"path": "dist/esm/index.mjs",
"import": "{ FileUploader }",
"limit": "21.5 kB"
"limit": "21.6 kB"
},
{
"name": "StorageImage",
Expand All @@ -69,7 +69,7 @@
"name": "StorageManager",
"path": "dist/esm/index.mjs",
"import": "{ StorageManager }",
"limit": "21.5 kB"
"limit": "21.6 kB"
}
]
}

0 comments on commit 270995a

Please sign in to comment.