diff --git a/solara/components/file_drop.py b/solara/components/file_drop.py index 4aef70e04..dad27d444 100644 --- a/solara/components/file_drop.py +++ b/solara/components/file_drop.py @@ -35,6 +35,7 @@ def _FileDrop( on_file: Optional[Callable[[Union[FileInfo, List[FileInfo]]], None]] = None, lazy: bool = True, multiple: bool = False, + accept: Optional[str] = None, ): """Generic implementation used by FileDrop and FileDropMultiple. @@ -44,7 +45,7 @@ def _FileDrop( file_info, set_file_info = solara.use_state(None) wired_files, set_wired_files = solara.use_state(cast(Optional[typing.List[FileInfo]], None)) - file_drop = FileDropZone.element(label=label, on_total_progress=on_total_progress, on_file_info=set_file_info, multiple=multiple) # type: ignore + file_drop = FileDropZone.element(label=label, on_total_progress=on_total_progress, on_file_info=set_file_info, multiple=multiple, accept=accept or "") # type: ignore def wire_files(): if not file_info: @@ -87,6 +88,7 @@ def FileDrop( on_total_progress: Optional[Callable[[float], None]] = None, on_file: Optional[Callable[[FileInfo], None]] = None, lazy: bool = True, + accept: Optional[str] = None, ): """Region a user can drop a file into for file uploading. @@ -110,6 +112,7 @@ class FileInfo(typing.TypedDict): * `on_file`: Will be called with a `FileInfo` object, which contains the file `.name`, `.length` and a `.file_obj` object. * `lazy`: Whether to load the file contents into memory or not. If `False`, the file contents will be loaded into memory via the `.data` attribute of file object(s). + * `accept`: Comma-separated file extensions or MIME types to accept, for example `.csv,image/*`. ## Load into Pandas To load the data into a Pandas DF, set `lazy=False` and use `file['file_obj']` (be careful of memory)
@@ -133,7 +136,7 @@ def load_file_df(file): """ - return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=False) + return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=False, accept=accept) @solara.component @@ -142,6 +145,7 @@ def FileDropMultiple( on_total_progress: Optional[Callable[[float], None]] = None, on_file: Optional[Callable[[List[FileInfo]], None]] = None, lazy: bool = True, + accept: Optional[str] = None, ): """Region a user can drop multiple files into for file uploading. @@ -153,7 +157,8 @@ def FileDropMultiple( * `on_file`: Will be called with a `List[FileInfo]`. Each `FileInfo` contains the file `.name`, `.length`, `.file_obj` object, and `.data` attributes. * `lazy`: Whether to load the file contents into memory or not. + * `accept`: Comma-separated file extensions or MIME types to accept, for example `.csv,image/*`. """ - return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=True) + return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=True, accept=accept) diff --git a/solara/components/file_drop.vue b/solara/components/file_drop.vue index 54d4cf0e2..3b5c1f790 100644 --- a/solara/components/file_drop.vue +++ b/solara/components/file_drop.vue @@ -30,7 +30,7 @@ module.exports = { const files = items.map(i => i.webkitGetAsEntry()) const fileHolders = files.filter(f => f.isFile) const nativeFilesPromises = fileHolders.map(fileHolder => new Promise((rs, rj) => fileHolder.file(rs, rj))) - const nativeFiles = await Promise.all(nativeFilesPromises) + const nativeFiles = (await Promise.all(nativeFilesPromises)).filter(this.fileIsAccepted) this.native_file_info = nativeFiles this.file_info = this.native_file_info.map( @@ -42,6 +42,23 @@ module.exports = { }); }, methods: { + fileIsAccepted(file) { + if (!this.accept) { + return true; + } + const name = file.name.toLowerCase(); + const type = file.type.toLowerCase(); + return this.accept.split(',').some(value => { + const token = value.trim().toLowerCase(); + if (token.startsWith('.')) { + return name.endsWith(token); + } + if (token.endsWith('/*')) { + return type.startsWith(token.slice(0, -1)); + } + return type === token; + }); + }, jupyter_clear() { this.native_file_info = []; this.file_info = []; diff --git a/tests/integration/file_drop_test.py b/tests/integration/file_drop_test.py new file mode 100644 index 000000000..e56093894 --- /dev/null +++ b/tests/integration/file_drop_test.py @@ -0,0 +1,53 @@ +import playwright.sync_api +import pytest + +import solara + + +@pytest.mark.parametrize( + ("accept", "multiple", "files", "expected"), + [ + (".csv", False, [("data.csv", "text/plain"), ("data.txt", "text/csv")], "data.csv"), + ("text/csv", False, [("data.txt", "text/csv"), ("data.csv", "text/plain")], "data.txt"), + ("image/*", False, [("photo.png", "image/png"), ("data.csv", "text/csv")], "photo.png"), + (None, False, [("data.bin", "application/octet-stream")], "data.bin"), + (".csv, image/*", True, [("data.csv", "text/plain"), ("notes.txt", "text/plain"), ("photo.png", "image/png")], "data.csv,photo.png"), + ], +) +def test_file_drop_accept( + solara_test, + page_session: playwright.sync_api.Page, + accept, + multiple: bool, + files, + expected: str, +): + @solara.component + def Page(): + received = solara.use_reactive("none") + + if multiple: + solara.FileDropMultiple(accept=accept, on_file=lambda value: received.set(",".join(file["name"] for file in value))) + else: + solara.FileDrop(accept=accept, on_file=lambda value: received.set(value["name"])) + solara.Text(received.value, classes=["received-files"]) + + solara.display(Page()) + page_session.locator(".solara-file-drop").evaluate( + """(element, files) => { + const event = new Event('drop', {bubbles: true}); + Object.defineProperty(event, 'dataTransfer', { + value: { + items: files.map(([name, type]) => ({ + webkitGetAsEntry: () => ({ + isFile: true, + file: resolve => resolve(new File(['content'], name, {type})), + }), + })), + }, + }); + element.dispatchEvent(event); + }""", + files, + ) + playwright.sync_api.expect(page_session.locator(".received-files")).to_have_text(expected)