Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
144 changes: 144 additions & 0 deletions components/dash-core-components/src/fragments/Upload.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<LoadingElement id={id}>
<Dropzone
Expand All @@ -79,6 +221,8 @@ export default class Upload extends Component {
maxSize={max_size === -1 ? Infinity : max_size}
minSize={min_size}
multiple={multiple}
inputProps={inputProps}
getDataTransferItems={this.getDataTransferItems}
className={className}
activeClassName={className_active}
rejectClassName={className_reject}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from dash import Dash, Input, Output, dcc, html


def test_upfd001_folder_upload_with_multiple(dash_dcc):
"""
Test that folder upload is enabled when multiple=True.

Note: Full end-to-end testing of folder upload functionality is limited
by Selenium's capabilities. This test verifies the component renders
correctly with multiple=True which enables folder support.
"""
app = Dash(__name__)

app.layout = html.Div(
[
html.Div("Folder Upload Test", id="title"),
dcc.Upload(
id="upload-folder",
children=html.Div(
["Drag and Drop or ", html.A("Select Files or Folders")]
),
style={
"width": "100%",
"height": "60px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "5px",
"textAlign": "center",
},
multiple=True, # Enables folder upload
accept=".txt,.csv", # Test accept filtering
),
html.Div(id="output"),
]
)

@app.callback(
Output("output", "children"),
[Input("upload-folder", "contents")],
)
def update_output(contents_list):
if contents_list is not None:
return html.Div(f"Uploaded {len(contents_list)} file(s)", id="file-count")
return html.Div("No files uploaded")

dash_dcc.start_server(app)

# Verify the component renders
dash_dcc.wait_for_text_to_equal("#title", "Folder Upload Test")

# Verify the upload component and input are present
dash_dcc.wait_for_element("#upload-folder")

# Verify the input has folder selection attributes when multiple=True
upload_input = dash_dcc.wait_for_element("#upload-folder input[type=file]")
webkitdir_attr = upload_input.get_attribute("webkitdirectory")

assert webkitdir_attr == "true", (
f"webkitdirectory attribute should be 'true' when multiple=True, "
f"but got '{webkitdir_attr}'"
)

assert dash_dcc.get_logs() == [], "browser console should contain no error"


def test_upfd002_folder_upload_disabled_with_single(dash_dcc):
"""
Test that folder upload is NOT enabled when multiple=False.
"""
app = Dash(__name__)

app.layout = html.Div(
[
html.Div("Single File Test", id="title"),
dcc.Upload(
id="upload-single",
children=html.Div(["Drag and Drop or ", html.A("Select File")]),
style={
"width": "100%",
"height": "60px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "5px",
"textAlign": "center",
},
multiple=False, # Folder upload should be disabled
),
html.Div(id="output", children="Upload ready"),
]
)

dash_dcc.start_server(app)

# Wait for the component to render
dash_dcc.wait_for_text_to_equal("#title", "Single File Test")
dash_dcc.wait_for_text_to_equal("#output", "Upload ready")

# Verify the input does NOT have folder selection attributes when multiple=False
upload_input = dash_dcc.wait_for_element("#upload-single input[type=file]")
webkitdir_attr = upload_input.get_attribute("webkitdirectory")

# webkitdirectory should not be set when multiple=False
assert webkitdir_attr in [None, "", "false"], (
f"webkitdirectory attribute should not be 'true' when multiple=False, "
f"but got '{webkitdir_attr}'"
)

assert dash_dcc.get_logs() == [], "browser console should contain no error"