diff --git a/CHANGELOG.md b/CHANGELOG.md index b9399acfdf..d3f4ef229c 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 `useFsAccessApi` prop to `dcc.Upload` component to enable folder upload functionality. When set to `True`, users can select and upload entire folders in addition to individual files, utilizing the File System Access API. This allows for recursive folder uploads when supported by the browser. 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..8616aeb7b5 100644 --- a/components/dash-core-components/src/components/Upload.react.js +++ b/components/dash-core-components/src/components/Upload.react.js @@ -154,6 +154,14 @@ Upload.propTypes = { */ style_disabled: PropTypes.object, + /** + * Set to true to use the File System Access API for folder selection. + * When enabled, users can select folders in addition to files. + * This allows for recursive folder uploads. Note: browser support varies. + * See: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API + */ + useFsAccessApi: PropTypes.bool, + /** * Dash-supplied function for updating props */ @@ -166,6 +174,7 @@ Upload.defaultProps = { max_size: -1, min_size: 0, multiple: false, + useFsAccessApi: false, style: {}, style_active: { borderStyle: 'solid', diff --git a/components/dash-core-components/src/fragments/Upload.react.js b/components/dash-core-components/src/fragments/Upload.react.js index 7bd910c190..c7db4a97e8 100644 --- a/components/dash-core-components/src/fragments/Upload.react.js +++ b/components/dash-core-components/src/fragments/Upload.react.js @@ -8,6 +8,98 @@ export default class Upload extends Component { constructor() { super(); this.onDrop = this.onDrop.bind(this); + this.getDataTransferItems = this.getDataTransferItems.bind(this); + } + + // Recursively traverse folder structure and extract all files + async traverseFileTree(item, path = '') { + const files = []; + if (item.isFile) { + return new Promise((resolve) => { + item.file((file) => { + // 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 {useFsAccessApi} = this.props; + + // If folder support is not enabled, use default behavior + if (!useFsAccessApi) { + 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 + 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) { @@ -55,6 +147,7 @@ export default class Upload extends Component { max_size, min_size, multiple, + useFsAccessApi, className, className_active, className_reject, @@ -69,6 +162,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 useFsAccessApi is enabled to support folder selection + const inputProps = useFsAccessApi ? { + webkitdirectory: 'true', + directory: 'true', + mozdirectory: 'true' + } : {}; + return (