diff --git a/CHANGELOG.md b/CHANGELOG.md index 67dee58345..dc8d72a1fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] ## Added +- [#3464](https://github.com/plotly/dash/issues/3464) Add folder upload functionality to `dcc.Upload` component. When `multiple=True`, users can now select and upload entire folders in addition to individual files. The folder hierarchy is preserved in filenames (e.g., `folder/subfolder/file.txt`). Files within folders are filtered according to the `accept` prop. Folder support is available in Chrome, Edge, and Opera; other browsers gracefully fall back to file-only mode. The uploaded files use the same output API as multiple file uploads. - [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool - [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes. - [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph. diff --git a/components/dash-core-components/src/components/Upload.react.js b/components/dash-core-components/src/components/Upload.react.js index 916181ef3c..ad524327bb 100644 --- a/components/dash-core-components/src/components/Upload.react.js +++ b/components/dash-core-components/src/components/Upload.react.js @@ -110,7 +110,12 @@ Upload.propTypes = { min_size: PropTypes.number, /** - * Allow dropping multiple files + * Allow dropping multiple files. + * When true, also enables folder selection and drag-and-drop, + * allowing users to upload entire folders. The folder hierarchy + * is preserved in the filenames (e.g., 'folder/subfolder/file.txt'). + * Note: Folder support is available in Chrome, Edge, and Opera. + * Other browsers will fall back to file-only mode. */ multiple: PropTypes.bool, diff --git a/components/dash-core-components/src/fragments/Upload.react.js b/components/dash-core-components/src/fragments/Upload.react.js index 7bd910c190..815db9cc6f 100644 --- a/components/dash-core-components/src/fragments/Upload.react.js +++ b/components/dash-core-components/src/fragments/Upload.react.js @@ -8,6 +8,140 @@ export default class Upload extends Component { constructor() { super(); this.onDrop = this.onDrop.bind(this); + this.getDataTransferItems = this.getDataTransferItems.bind(this); + } + + // Check if file matches the accept criteria + fileMatchesAccept(file, accept) { + if (!accept) { + return true; + } + + const acceptList = Array.isArray(accept) ? accept : accept.split(','); + const fileName = file.name.toLowerCase(); + const fileType = file.type.toLowerCase(); + + return acceptList.some(acceptItem => { + const item = acceptItem.trim().toLowerCase(); + + // Exact MIME type match + if (item === fileType) { + return true; + } + + // Wildcard MIME type (e.g., image/*) + if (item.endsWith('/*')) { + const wildcardSuffixLength = 2; + const baseType = item.slice(0, -wildcardSuffixLength); + return fileType.startsWith(baseType + '/'); + } + + // File extension match (e.g., .jpg) + if (item.startsWith('.')) { + return fileName.endsWith(item); + } + + return false; + }); + } + + // Recursively traverse folder structure and extract all files + async traverseFileTree(item, path = '') { + const {accept} = this.props; + const files = []; + + if (item.isFile) { + return new Promise((resolve) => { + item.file((file) => { + // Check if file matches accept criteria + if (!this.fileMatchesAccept(file, accept)) { + resolve([]); + return; + } + + // Preserve folder structure in file name + const relativePath = path + file.name; + Object.defineProperty(file, 'name', { + writable: true, + value: relativePath + }); + resolve([file]); + }); + }); + } else if (item.isDirectory) { + const dirReader = item.createReader(); + return new Promise((resolve) => { + const readEntries = () => { + dirReader.readEntries(async (entries) => { + if (entries.length === 0) { + resolve(files); + } else { + for (const entry of entries) { + const entryFiles = await this.traverseFileTree( + entry, + path + item.name + '/' + ); + files.push(...entryFiles); + } + // Continue reading (directories may have more than 100 entries) + readEntries(); + } + }); + }; + readEntries(); + }); + } + return files; + } + + // Custom data transfer handler that supports folders + async getDataTransferItems(event) { + const {multiple} = this.props; + + // If multiple is not enabled, use default behavior (files only) + if (!multiple) { + if (event.dataTransfer) { + return Array.from(event.dataTransfer.files); + } else if (event.target && event.target.files) { + return Array.from(event.target.files); + } + return []; + } + + // Handle drag-and-drop with folder support when multiple=true + if (event.dataTransfer && event.dataTransfer.items) { + const items = Array.from(event.dataTransfer.items); + const files = []; + + for (const item of items) { + if (item.kind === 'file') { + const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; + if (entry) { + const entryFiles = await this.traverseFileTree(entry); + files.push(...entryFiles); + } else { + // Fallback for browsers without webkitGetAsEntry + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + } + return files; + } + + // Handle file picker (already works with webkitdirectory attribute) + if (event.target && event.target.files) { + return Array.from(event.target.files); + } + + // Fallback + if (event.dataTransfer && event.dataTransfer.files) { + return Array.from(event.dataTransfer.files); + } + + return []; } onDrop(files) { @@ -69,6 +203,14 @@ export default class Upload extends Component { const disabledStyle = className_disabled ? undefined : style_disabled; const rejectStyle = className_reject ? undefined : style_reject; + // For react-dropzone v4.1.2, we need to add webkitdirectory attribute manually + // when multiple is enabled to support folder selection + const inputProps = multiple ? { + webkitdirectory: 'true', + directory: 'true', + mozdirectory: 'true' + } : {}; + return (