Skip to content

Commit 9e5d7c5

Browse files
authored
Merge pull request #3481 from lfelipediniz/feat/add-folder-upload-support
feat: Add folder upload support to dcc.Upload component
2 parents 670cd51 + ca5515e commit 9e5d7c5

File tree

4 files changed

+333
-1
lines changed

4 files changed

+333
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
4747
## [3.3.0] - 2025-11-12
4848

4949
## Added
50+
- [#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.
5051
- [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool
5152
- [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes.
5253
- [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph.

components/dash-core-components/src/components/Upload.react.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,22 @@ Upload.propTypes = {
110110
min_size: PropTypes.number,
111111

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

120+
/**
121+
* Enable folder selection in the file picker dialog.
122+
* When true with multiple=True, the file picker allows selecting folders instead of files.
123+
* Note: When folder selection is enabled, individual files cannot be selected via the button.
124+
* Use separate Upload components if you need both file and folder selection options.
125+
* Folder selection is supported in Chrome, Edge, and Opera.
126+
*/
127+
enable_folder_selection: PropTypes.bool,
128+
117129
/**
118130
* HTML class name of the component
119131
*/
@@ -166,6 +178,7 @@ Upload.defaultProps = {
166178
max_size: -1,
167179
min_size: 0,
168180
multiple: false,
181+
enable_folder_selection: false,
169182
style: {},
170183
style_active: {
171184
borderStyle: 'solid',

components/dash-core-components/src/fragments/Upload.react.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,142 @@ export default class Upload extends Component {
88
constructor() {
99
super();
1010
this.onDrop = this.onDrop.bind(this);
11+
this.getDataTransferItems = this.getDataTransferItems.bind(this);
12+
}
13+
14+
// Check if file matches the accept criteria
15+
fileMatchesAccept(file, accept) {
16+
if (!accept) {
17+
return true;
18+
}
19+
20+
const acceptList = Array.isArray(accept) ? accept : accept.split(',');
21+
const fileName = file.name.toLowerCase();
22+
const fileType = file.type.toLowerCase();
23+
24+
return acceptList.some(acceptItem => {
25+
const item = acceptItem.trim().toLowerCase();
26+
27+
// Exact MIME type match
28+
if (item === fileType) {
29+
return true;
30+
}
31+
32+
// Wildcard MIME type (e.g., image/*)
33+
if (item.endsWith('/*')) {
34+
const wildcardSuffixLength = 2;
35+
const baseType = item.slice(0, -wildcardSuffixLength);
36+
return fileType.startsWith(baseType + '/');
37+
}
38+
39+
// File extension match (e.g., .jpg)
40+
if (item.startsWith('.')) {
41+
return fileName.endsWith(item);
42+
}
43+
44+
return false;
45+
});
46+
}
47+
48+
// Recursively traverse folder structure and extract all files
49+
async traverseFileTree(item, path = '') {
50+
const {accept} = this.props;
51+
const files = [];
52+
53+
if (item.isFile) {
54+
return new Promise(resolve => {
55+
item.file(file => {
56+
// Check if file matches accept criteria
57+
if (!this.fileMatchesAccept(file, accept)) {
58+
resolve([]);
59+
return;
60+
}
61+
62+
// Preserve folder structure in file name
63+
const relativePath = path + file.name;
64+
Object.defineProperty(file, 'name', {
65+
writable: true,
66+
value: relativePath,
67+
});
68+
resolve([file]);
69+
});
70+
});
71+
} else if (item.isDirectory) {
72+
const dirReader = item.createReader();
73+
return new Promise(resolve => {
74+
const readEntries = () => {
75+
dirReader.readEntries(async entries => {
76+
if (entries.length === 0) {
77+
resolve(files);
78+
} else {
79+
for (const entry of entries) {
80+
const entryFiles = await this.traverseFileTree(
81+
entry,
82+
path + item.name + '/'
83+
);
84+
files.push(...entryFiles);
85+
}
86+
// Continue reading (directories may have more than 100 entries)
87+
readEntries();
88+
}
89+
});
90+
};
91+
readEntries();
92+
});
93+
}
94+
return files;
95+
}
96+
97+
// Custom data transfer handler that supports folders
98+
async getDataTransferItems(event) {
99+
const {multiple} = this.props;
100+
101+
// If multiple is not enabled, use default behavior (files only)
102+
if (!multiple) {
103+
if (event.dataTransfer) {
104+
return Array.from(event.dataTransfer.files);
105+
} else if (event.target && event.target.files) {
106+
return Array.from(event.target.files);
107+
}
108+
return [];
109+
}
110+
111+
// Handle drag-and-drop with folder support when multiple=true
112+
if (event.dataTransfer && event.dataTransfer.items) {
113+
const items = Array.from(event.dataTransfer.items);
114+
const files = [];
115+
116+
for (const item of items) {
117+
if (item.kind === 'file') {
118+
const entry = item.webkitGetAsEntry
119+
? item.webkitGetAsEntry()
120+
: null;
121+
if (entry) {
122+
const entryFiles = await this.traverseFileTree(entry);
123+
files.push(...entryFiles);
124+
} else {
125+
// Fallback for browsers without webkitGetAsEntry
126+
const file = item.getAsFile();
127+
if (file) {
128+
files.push(file);
129+
}
130+
}
131+
}
132+
}
133+
return files;
134+
}
135+
136+
// Handle file picker (already works with webkitdirectory attribute)
137+
if (event.target && event.target.files) {
138+
return Array.from(event.target.files);
139+
}
140+
141+
// Fallback
142+
if (event.dataTransfer && event.dataTransfer.files) {
143+
return Array.from(event.dataTransfer.files);
144+
}
145+
146+
return [];
11147
}
12148

13149
onDrop(files) {
@@ -55,6 +191,7 @@ export default class Upload extends Component {
55191
max_size,
56192
min_size,
57193
multiple,
194+
enable_folder_selection,
58195
className,
59196
className_active,
60197
className_reject,
@@ -69,6 +206,17 @@ export default class Upload extends Component {
69206
const disabledStyle = className_disabled ? undefined : style_disabled;
70207
const rejectStyle = className_reject ? undefined : style_reject;
71208

209+
// Enable folder selection in file picker when explicitly requested
210+
// Note: This makes individual files unselectable in the file picker
211+
const inputProps =
212+
multiple && enable_folder_selection
213+
? {
214+
webkitdirectory: 'true',
215+
directory: 'true',
216+
mozdirectory: 'true',
217+
}
218+
: {};
219+
72220
return (
73221
<LoadingElement id={id}>
74222
<Dropzone
@@ -79,6 +227,8 @@ export default class Upload extends Component {
79227
maxSize={max_size === -1 ? Infinity : max_size}
80228
minSize={min_size}
81229
multiple={multiple}
230+
inputProps={inputProps}
231+
getDataTransferItems={this.getDataTransferItems}
82232
className={className}
83233
activeClassName={className_active}
84234
rejectClassName={className_reject}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from dash import Dash, Input, Output, dcc, html
2+
3+
4+
def test_upfd001_folder_upload_with_enable_folder_selection(dash_dcc):
5+
"""
6+
Test that folder upload is enabled when enable_folder_selection=True.
7+
8+
Note: Full end-to-end testing of folder upload functionality is limited
9+
by Selenium's capabilities. This test verifies the component renders
10+
correctly with enable_folder_selection=True which enables folder support.
11+
"""
12+
app = Dash(__name__)
13+
14+
app.layout = html.Div(
15+
[
16+
html.Div("Folder Upload Test", id="title"),
17+
dcc.Upload(
18+
id="upload-folder",
19+
children=html.Div(
20+
["Drag and Drop or ", html.A("Select Folder")]
21+
),
22+
style={
23+
"width": "100%",
24+
"height": "60px",
25+
"lineHeight": "60px",
26+
"borderWidth": "1px",
27+
"borderStyle": "dashed",
28+
"borderRadius": "5px",
29+
"textAlign": "center",
30+
},
31+
multiple=True,
32+
enable_folder_selection=True, # Enables folder selection
33+
accept=".txt,.csv", # Test accept filtering
34+
),
35+
html.Div(id="output"),
36+
]
37+
)
38+
39+
@app.callback(
40+
Output("output", "children"),
41+
[Input("upload-folder", "contents")],
42+
)
43+
def update_output(contents_list):
44+
if contents_list is not None:
45+
return html.Div(f"Uploaded {len(contents_list)} file(s)", id="file-count")
46+
return html.Div("No files uploaded")
47+
48+
dash_dcc.start_server(app)
49+
50+
# Verify the component renders
51+
dash_dcc.wait_for_text_to_equal("#title", "Folder Upload Test")
52+
53+
# Verify the upload component and input are present
54+
dash_dcc.wait_for_element("#upload-folder")
55+
56+
# Verify the input has folder selection attributes when enable_folder_selection=True
57+
upload_input = dash_dcc.wait_for_element("#upload-folder input[type=file]")
58+
webkitdir_attr = upload_input.get_attribute("webkitdirectory")
59+
60+
assert webkitdir_attr == "true", (
61+
f"webkitdirectory attribute should be 'true' when enable_folder_selection=True, "
62+
f"but got '{webkitdir_attr}'"
63+
)
64+
65+
assert dash_dcc.get_logs() == [], "browser console should contain no error"
66+
67+
68+
def test_upfd002_multiple_files_without_folder_selection(dash_dcc):
69+
"""
70+
Test that multiple file upload does NOT enable folder selection
71+
when enable_folder_selection=False (default).
72+
"""
73+
app = Dash(__name__)
74+
75+
app.layout = html.Div(
76+
[
77+
html.Div("Multiple Files Test", id="title"),
78+
dcc.Upload(
79+
id="upload-multiple",
80+
children=html.Div(["Drag and Drop or ", html.A("Select Multiple Files")]),
81+
style={
82+
"width": "100%",
83+
"height": "60px",
84+
"lineHeight": "60px",
85+
"borderWidth": "1px",
86+
"borderStyle": "dashed",
87+
"borderRadius": "5px",
88+
"textAlign": "center",
89+
},
90+
multiple=True, # Allows multiple files
91+
enable_folder_selection=False, # But NOT folder selection
92+
accept=".txt,.csv", # Accept should work with file picker
93+
),
94+
html.Div(id="output", children="Upload ready"),
95+
]
96+
)
97+
98+
dash_dcc.start_server(app)
99+
100+
# Wait for the component to render
101+
dash_dcc.wait_for_text_to_equal("#title", "Multiple Files Test")
102+
dash_dcc.wait_for_text_to_equal("#output", "Upload ready")
103+
104+
# Verify the input does NOT have folder selection attributes
105+
upload_input = dash_dcc.wait_for_element("#upload-multiple input[type=file]")
106+
webkitdir_attr = upload_input.get_attribute("webkitdirectory")
107+
108+
# webkitdirectory should not be set when enable_folder_selection=False
109+
assert webkitdir_attr in [None, "", "false"], (
110+
f"webkitdirectory attribute should not be 'true' when enable_folder_selection=False, "
111+
f"but got '{webkitdir_attr}'"
112+
)
113+
114+
# Verify multiple attribute is set
115+
multiple_attr = upload_input.get_attribute("multiple")
116+
assert multiple_attr == "true", (
117+
f"multiple attribute should be 'true' when multiple=True, "
118+
f"but got '{multiple_attr}'"
119+
)
120+
121+
assert dash_dcc.get_logs() == [], "browser console should contain no error"
122+
123+
124+
def test_upfd003_single_file_upload(dash_dcc):
125+
"""
126+
Test that single file upload does NOT enable folder selection.
127+
"""
128+
app = Dash(__name__)
129+
130+
app.layout = html.Div(
131+
[
132+
html.Div("Single File Test", id="title"),
133+
dcc.Upload(
134+
id="upload-single",
135+
children=html.Div(["Drag and Drop or ", html.A("Select File")]),
136+
style={
137+
"width": "100%",
138+
"height": "60px",
139+
"lineHeight": "60px",
140+
"borderWidth": "1px",
141+
"borderStyle": "dashed",
142+
"borderRadius": "5px",
143+
"textAlign": "center",
144+
},
145+
multiple=False, # Single file only
146+
accept="application/pdf",
147+
),
148+
html.Div(id="output", children="Upload ready"),
149+
]
150+
)
151+
152+
dash_dcc.start_server(app)
153+
154+
# Wait for the component to render
155+
dash_dcc.wait_for_text_to_equal("#title", "Single File Test")
156+
dash_dcc.wait_for_text_to_equal("#output", "Upload ready")
157+
158+
# Verify the input does NOT have folder selection attributes when multiple=False
159+
upload_input = dash_dcc.wait_for_element("#upload-single input[type=file]")
160+
webkitdir_attr = upload_input.get_attribute("webkitdirectory")
161+
162+
# webkitdirectory should not be set when multiple=False
163+
assert webkitdir_attr in [None, "", "false"], (
164+
f"webkitdirectory attribute should not be 'true' when multiple=False, "
165+
f"but got '{webkitdir_attr}'"
166+
)
167+
168+
assert dash_dcc.get_logs() == [], "browser console should contain no error"

0 commit comments

Comments
 (0)