Skip to content

Commit 7a25fa9

Browse files
committed
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
1 parent 74a30a9 commit 7a25fa9

File tree

4 files changed

+280
-0
lines changed

4 files changed

+280
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
55
## [UNRELEASED]
66

77
## Added
8+
- [#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.
89
- [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool
910
- [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes.
1011
- [#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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ Upload.propTypes = {
154154
*/
155155
style_disabled: PropTypes.object,
156156

157+
/**
158+
* Set to true to use the File System Access API for folder selection.
159+
* When enabled, users can select folders in addition to files.
160+
* This allows for recursive folder uploads. Note: browser support varies.
161+
* See: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
162+
*/
163+
useFsAccessApi: PropTypes.bool,
164+
157165
/**
158166
* Dash-supplied function for updating props
159167
*/
@@ -166,6 +174,7 @@ Upload.defaultProps = {
166174
max_size: -1,
167175
min_size: 0,
168176
multiple: false,
177+
useFsAccessApi: false,
169178
style: {},
170179
style_active: {
171180
borderStyle: 'solid',

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,98 @@ 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+
// Recursively traverse folder structure and extract all files
15+
async traverseFileTree(item, path = '') {
16+
const files = [];
17+
if (item.isFile) {
18+
return new Promise((resolve) => {
19+
item.file((file) => {
20+
// Preserve folder structure in file name
21+
const relativePath = path + file.name;
22+
Object.defineProperty(file, 'name', {
23+
writable: true,
24+
value: relativePath
25+
});
26+
resolve([file]);
27+
});
28+
});
29+
} else if (item.isDirectory) {
30+
const dirReader = item.createReader();
31+
return new Promise((resolve) => {
32+
const readEntries = () => {
33+
dirReader.readEntries(async (entries) => {
34+
if (entries.length === 0) {
35+
resolve(files);
36+
} else {
37+
for (const entry of entries) {
38+
const entryFiles = await this.traverseFileTree(
39+
entry,
40+
path + item.name + '/'
41+
);
42+
files.push(...entryFiles);
43+
}
44+
// Continue reading (directories may have more than 100 entries)
45+
readEntries();
46+
}
47+
});
48+
};
49+
readEntries();
50+
});
51+
}
52+
return files;
53+
}
54+
55+
// Custom data transfer handler that supports folders
56+
async getDataTransferItems(event) {
57+
const {useFsAccessApi} = this.props;
58+
59+
// If folder support is not enabled, use default behavior
60+
if (!useFsAccessApi) {
61+
if (event.dataTransfer) {
62+
return Array.from(event.dataTransfer.files);
63+
} else if (event.target && event.target.files) {
64+
return Array.from(event.target.files);
65+
}
66+
return [];
67+
}
68+
69+
// Handle drag-and-drop with folder support
70+
if (event.dataTransfer && event.dataTransfer.items) {
71+
const items = Array.from(event.dataTransfer.items);
72+
const files = [];
73+
74+
for (const item of items) {
75+
if (item.kind === 'file') {
76+
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
77+
if (entry) {
78+
const entryFiles = await this.traverseFileTree(entry);
79+
files.push(...entryFiles);
80+
} else {
81+
// Fallback for browsers without webkitGetAsEntry
82+
const file = item.getAsFile();
83+
if (file) {
84+
files.push(file);
85+
}
86+
}
87+
}
88+
}
89+
return files;
90+
}
91+
92+
// Handle file picker (already works with webkitdirectory attribute)
93+
if (event.target && event.target.files) {
94+
return Array.from(event.target.files);
95+
}
96+
97+
// Fallback
98+
if (event.dataTransfer && event.dataTransfer.files) {
99+
return Array.from(event.dataTransfer.files);
100+
}
101+
102+
return [];
11103
}
12104

13105
onDrop(files) {
@@ -55,6 +147,7 @@ export default class Upload extends Component {
55147
max_size,
56148
min_size,
57149
multiple,
150+
useFsAccessApi,
58151
className,
59152
className_active,
60153
className_reject,
@@ -69,6 +162,14 @@ export default class Upload extends Component {
69162
const disabledStyle = className_disabled ? undefined : style_disabled;
70163
const rejectStyle = className_reject ? undefined : style_reject;
71164

165+
// For react-dropzone v4.1.2, we need to add webkitdirectory attribute manually
166+
// when useFsAccessApi is enabled to support folder selection
167+
const inputProps = useFsAccessApi ? {
168+
webkitdirectory: 'true',
169+
directory: 'true',
170+
mozdirectory: 'true'
171+
} : {};
172+
72173
return (
73174
<LoadingElement id={id}>
74175
<Dropzone
@@ -79,6 +180,8 @@ export default class Upload extends Component {
79180
maxSize={max_size === -1 ? Infinity : max_size}
80181
minSize={min_size}
81182
multiple={multiple}
183+
inputProps={inputProps}
184+
getDataTransferItems={this.getDataTransferItems}
82185
className={className}
83186
activeClassName={className_active}
84187
rejectClassName={className_reject}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import os
2+
from dash import Dash, Input, Output, dcc, html
3+
4+
5+
def test_upfd001_folder_upload_prop_exists(dash_dcc):
6+
"""
7+
Test that useFsAccessApi prop is available on dcc.Upload component.
8+
9+
Note: Full end-to-end testing of folder upload functionality is limited
10+
because the File System Access API requires user interaction and browser
11+
permissions that cannot be fully automated with Selenium. This test verifies
12+
that the prop is correctly passed to the component.
13+
"""
14+
app = Dash(__name__)
15+
16+
app.layout = html.Div(
17+
[
18+
html.Div("Folder Upload Test", id="title"),
19+
dcc.Upload(
20+
id="upload-folder",
21+
children=html.Div(
22+
["Drag and Drop or ", html.A("Select Files or Folders")]
23+
),
24+
style={
25+
"width": "100%",
26+
"height": "60px",
27+
"lineHeight": "60px",
28+
"borderWidth": "1px",
29+
"borderStyle": "dashed",
30+
"borderRadius": "5px",
31+
"textAlign": "center",
32+
},
33+
multiple=True,
34+
useFsAccessApi=True, # Enable folder upload
35+
),
36+
html.Div(id="output"),
37+
]
38+
)
39+
40+
@app.callback(
41+
Output("output", "children"),
42+
[Input("upload-folder", "contents")],
43+
)
44+
def update_output(contents_list):
45+
if contents_list is not None:
46+
return html.Div(
47+
[
48+
html.Div(f"Number of files uploaded: {len(contents_list)}"),
49+
]
50+
)
51+
return html.Div("No files uploaded yet")
52+
53+
dash_dcc.start_server(app)
54+
55+
# Wait for the component to render
56+
dash_dcc.wait_for_element("#upload-folder")
57+
58+
# Verify the title renders correctly
59+
dash_dcc.wait_for_text_to_equal("#title", "Folder Upload Test")
60+
61+
# Verify initial state
62+
dash_dcc.wait_for_text_to_equal("#output", "No files uploaded yet")
63+
64+
assert dash_dcc.get_logs() == []
65+
66+
67+
def test_upfd002_folder_upload_with_multiple_files(dash_dcc):
68+
"""
69+
Test uploading multiple files with useFsAccessApi enabled.
70+
71+
This test simulates multiple file upload to verify the API remains
72+
compatible when useFsAccessApi is enabled.
73+
"""
74+
# Create test files
75+
test_dir = os.path.join(os.path.dirname(__file__), "upload-assets")
76+
test_file1 = os.path.join(test_dir, "upft001.csv")
77+
test_file2 = os.path.join(test_dir, "upft001.png")
78+
79+
app = Dash(__name__)
80+
81+
app.layout = html.Div(
82+
[
83+
html.Div("Multiple Files Test", id="title"),
84+
dcc.Upload(
85+
id="upload-multiple",
86+
children=html.Div(["Drag and Drop or ", html.A("Select Files")]),
87+
style={
88+
"width": "100%",
89+
"height": "60px",
90+
"lineHeight": "60px",
91+
"borderWidth": "1px",
92+
"borderStyle": "dashed",
93+
"borderRadius": "5px",
94+
"textAlign": "center",
95+
},
96+
multiple=True,
97+
useFsAccessApi=True,
98+
),
99+
html.Div(id="output"),
100+
]
101+
)
102+
103+
@app.callback(
104+
Output("output", "children"),
105+
[Input("upload-multiple", "contents")],
106+
)
107+
def update_output(contents_list):
108+
if contents_list is not None:
109+
return html.Div(
110+
[
111+
html.Div(f"Uploaded {len(contents_list)} file(s)", id="file-count"),
112+
]
113+
)
114+
return html.Div("No files uploaded")
115+
116+
dash_dcc.start_server(app)
117+
118+
# Find the file input and upload multiple files
119+
upload_input = dash_dcc.wait_for_element("#upload-multiple input[type=file]")
120+
121+
# Upload multiple files - Selenium requires absolute paths joined with newline
122+
# Note: This simulates multiple file selection, not folder selection
123+
files_to_upload = "\n".join(
124+
[os.path.abspath(test_file1), os.path.abspath(test_file2)]
125+
)
126+
upload_input.send_keys(files_to_upload)
127+
128+
# Wait for the callback to complete
129+
dash_dcc.wait_for_text_to_equal("#file-count", "Uploaded 2 file(s)", timeout=5)
130+
131+
assert dash_dcc.get_logs() == []
132+
133+
134+
def test_upfd003_folder_upload_disabled_by_default(dash_dcc):
135+
"""
136+
Test that useFsAccessApi is disabled by default (False).
137+
"""
138+
app = Dash(__name__)
139+
140+
app.layout = html.Div(
141+
[
142+
html.Div("Default Behavior Test", id="title"),
143+
dcc.Upload(
144+
id="upload-default",
145+
children=html.Div(["Drag and Drop or ", html.A("Select Files")]),
146+
style={
147+
"width": "100%",
148+
"height": "60px",
149+
"lineHeight": "60px",
150+
"borderWidth": "1px",
151+
"borderStyle": "dashed",
152+
"borderRadius": "5px",
153+
"textAlign": "center",
154+
},
155+
# useFsAccessApi not specified, should default to False
156+
),
157+
html.Div(id="output", children="Upload ready"),
158+
]
159+
)
160+
161+
dash_dcc.start_server(app)
162+
163+
# Wait for the component to render
164+
dash_dcc.wait_for_element("#upload-default")
165+
dash_dcc.wait_for_text_to_equal("#output", "Upload ready")
166+
167+
assert dash_dcc.get_logs() == []

0 commit comments

Comments
 (0)