From 7a25fa90b836615ccabf3457ce361690f0a934d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Let=C3=ADcia=20Barbosa=20Neves?= Date: Tue, 14 Oct 2025 08:54:24 -0300 Subject: [PATCH] feat: Add folder upload support to dcc.Upload component - Add useFsAccessApi prop to enable folder selection - Support both click-to-select and drag-and-drop folder uploads - Recursively traverse folder structures using FileSystem API - Preserve folder hierarchy in uploaded filenames - Maintain backward compatibility (default: False) - Add integration tests for folder upload functionality Closes #3464 --- CHANGELOG.md | 1 + .../src/components/Upload.react.js | 9 + .../src/fragments/Upload.react.js | 103 +++++++++++ .../integration/upload/test_folder_upload.py | 167 ++++++++++++++++++ 4 files changed, 280 insertions(+) create mode 100644 components/dash-core-components/tests/integration/upload/test_folder_upload.py 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 (