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
15 changes: 14 additions & 1 deletion components/dash-core-components/src/components/Upload.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,22 @@ Upload.propTypes = {
min_size: PropTypes.number,

/**
* Allow dropping multiple files
* Allow dropping multiple files.
* When true, enables folder drag-and-drop support.
* The folder hierarchy is preserved in filenames (e.g., 'folder/subfolder/file.txt').
* Note: Folder drag-and-drop is supported in Chrome, Edge, and Opera.
*/
multiple: PropTypes.bool,

/**
* Enable folder selection in the file picker dialog.
* When true with multiple=True, the file picker allows selecting folders instead of files.
* Note: When folder selection is enabled, individual files cannot be selected via the button.
* Use separate Upload components if you need both file and folder selection options.
* Folder selection is supported in Chrome, Edge, and Opera.
*/
enable_folder_selection: PropTypes.bool,

/**
* HTML class name of the component
*/
Expand Down Expand Up @@ -166,6 +178,7 @@ Upload.defaultProps = {
max_size: -1,
min_size: 0,
multiple: false,
enable_folder_selection: false,
style: {},
style_active: {
borderStyle: 'solid',
Expand Down
150 changes: 150 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,142 @@ 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 @@ -55,6 +191,7 @@ export default class Upload extends Component {
max_size,
min_size,
multiple,
enable_folder_selection,
className,
className_active,
className_reject,
Expand All @@ -69,6 +206,17 @@ export default class Upload extends Component {
const disabledStyle = className_disabled ? undefined : style_disabled;
const rejectStyle = className_reject ? undefined : style_reject;

// Enable folder selection in file picker when explicitly requested
// Note: This makes individual files unselectable in the file picker
const inputProps =
multiple && enable_folder_selection
? {
webkitdirectory: 'true',
directory: 'true',
mozdirectory: 'true',
}
: {};

return (
<LoadingElement id={id}>
<Dropzone
Expand All @@ -79,6 +227,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"