From 270995abeb6833264a12caa62ccfa9b3fd6a5929 Mon Sep 17 00:00:00 2001 From: Jordan Van Ness Date: Thu, 14 Nov 2024 10:42:17 -0800 Subject: [PATCH] feat(dropzone): add dropzone support for files within dropped directories (#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 --- packages/react-core/src/hooks/useDropZone.ts | 25 +++- .../__tests__/processDroppedEntries.spec.tsx | 141 ++++++++++++++++++ .../src/utils/processDroppedItems.ts | 62 ++++++++ packages/react-storage/package.json | 4 +- 4 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 packages/react-core/src/utils/__tests__/processDroppedEntries.spec.tsx create mode 100644 packages/react-core/src/utils/processDroppedItems.ts diff --git a/packages/react-core/src/hooks/useDropZone.ts b/packages/react-core/src/hooks/useDropZone.ts index 06754c991f7..e9dccada07c 100644 --- a/packages/react-core/src/hooks/useDropZone.ts +++ b/packages/react-core/src/hooks/useDropZone.ts @@ -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) => void; @@ -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( - 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( + files, + acceptedFileTypes + ); + if (isFunction(onDropComplete)) { + onDropComplete({ acceptedFiles, rejectedFiles }); + } + }; + + if (!items) { + completeDrop(Array.from(files)); + } else { + processDroppedItems(Array.from(items)).then(completeDrop); } }; diff --git a/packages/react-core/src/utils/__tests__/processDroppedEntries.spec.tsx b/packages/react-core/src/utils/__tests__/processDroppedEntries.spec.tsx new file mode 100644 index 00000000000..cfe74039cf1 --- /dev/null +++ b/packages/react-core/src/utils/__tests__/processDroppedEntries.spec.tsx @@ -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); + }); +}); diff --git a/packages/react-core/src/utils/processDroppedItems.ts b/packages/react-core/src/utils/processDroppedItems.ts new file mode 100644 index 00000000000..911e7dda47d --- /dev/null +++ b/packages/react-core/src/utils/processDroppedItems.ts @@ -0,0 +1,62 @@ +// Helper function to convert FileSystemFileEntry to File +const getFileFromEntry = (fileEntry: FileSystemFileEntry): Promise => { + return new Promise((resolve) => { + fileEntry.file(resolve); + }); +}; + +// Helper function to read all entries in a directory +const readAllDirectoryEntries = async ( + dirReader: FileSystemDirectoryReader +): Promise => { + const entries: FileSystemEntry[] = []; + + let readBatch: FileSystemEntry[] = []; + do { + readBatch = await new Promise((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 { + const files: File[] = []; + + const processFileSystemEntry = async ( + entry: FileSystemEntry + ): Promise => { + 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( + (acc, { kind, webkitGetAsEntry }) => + kind === 'file' && webkitGetAsEntry() + ? [...acc, webkitGetAsEntry()!] + : acc, + [] + ) + .map(processFileSystemEntry) + ); + + return files; +} diff --git a/packages/react-storage/package.json b/packages/react-storage/package.json index 079b72ca9fd..4ee132590cd 100644 --- a/packages/react-storage/package.json +++ b/packages/react-storage/package.json @@ -57,7 +57,7 @@ "name": "FileUploader", "path": "dist/esm/index.mjs", "import": "{ FileUploader }", - "limit": "21.5 kB" + "limit": "21.6 kB" }, { "name": "StorageImage", @@ -69,7 +69,7 @@ "name": "StorageManager", "path": "dist/esm/index.mjs", "import": "{ StorageManager }", - "limit": "21.5 kB" + "limit": "21.6 kB" } ] }