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 `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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -166,6 +174,7 @@ Upload.defaultProps = {
max_size: -1,
min_size: 0,
multiple: false,
useFsAccessApi: false,
style: {},
style_active: {
borderStyle: 'solid',
Expand Down
103 changes: 103 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,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) {
Expand Down Expand Up @@ -55,6 +147,7 @@ export default class Upload extends Component {
max_size,
min_size,
multiple,
useFsAccessApi,
className,
className_active,
className_reject,
Expand All @@ -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 (
<LoadingElement id={id}>
<Dropzone
Expand All @@ -79,6 +180,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,167 @@
import os
from dash import Dash, Input, Output, dcc, html


def test_upfd001_folder_upload_prop_exists(dash_dcc):
"""
Test that useFsAccessApi prop is available on dcc.Upload component.

Note: Full end-to-end testing of folder upload functionality is limited
because the File System Access API requires user interaction and browser
permissions that cannot be fully automated with Selenium. This test verifies
that the prop is correctly passed to the component.
"""
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,
useFsAccessApi=True, # Enable folder upload
),
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(
[
html.Div(f"Number of files uploaded: {len(contents_list)}"),
]
)
return html.Div("No files uploaded yet")

dash_dcc.start_server(app)

# Wait for the component to render
dash_dcc.wait_for_element("#upload-folder")

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

# Verify initial state
dash_dcc.wait_for_text_to_equal("#output", "No files uploaded yet")

assert dash_dcc.get_logs() == []


def test_upfd002_folder_upload_with_multiple_files(dash_dcc):
"""
Test uploading multiple files with useFsAccessApi enabled.

This test simulates multiple file upload to verify the API remains
compatible when useFsAccessApi is enabled.
"""
# Create test files
test_dir = os.path.join(os.path.dirname(__file__), "upload-assets")
test_file1 = os.path.join(test_dir, "upft001.csv")
test_file2 = os.path.join(test_dir, "upft001.png")

app = Dash(__name__)

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

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

dash_dcc.start_server(app)

# Find the file input and upload multiple files
upload_input = dash_dcc.wait_for_element("#upload-multiple input[type=file]")

# Upload multiple files - Selenium requires absolute paths joined with newline
# Note: This simulates multiple file selection, not folder selection
files_to_upload = "\n".join(
[os.path.abspath(test_file1), os.path.abspath(test_file2)]
)
upload_input.send_keys(files_to_upload)

# Wait for the callback to complete
dash_dcc.wait_for_text_to_equal("#file-count", "Uploaded 2 file(s)", timeout=5)

assert dash_dcc.get_logs() == []


def test_upfd003_folder_upload_disabled_by_default(dash_dcc):
"""
Test that useFsAccessApi is disabled by default (False).
"""
app = Dash(__name__)

app.layout = html.Div(
[
html.Div("Default Behavior Test", id="title"),
dcc.Upload(
id="upload-default",
children=html.Div(["Drag and Drop or ", html.A("Select Files")]),
style={
"width": "100%",
"height": "60px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "5px",
"textAlign": "center",
},
# useFsAccessApi not specified, should default to False
),
html.Div(id="output", children="Upload ready"),
]
)

dash_dcc.start_server(app)

# Wait for the component to render
dash_dcc.wait_for_element("#upload-default")
dash_dcc.wait_for_text_to_equal("#output", "Upload ready")

assert dash_dcc.get_logs() == []