diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 866fb315f1..5432d672f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,7 +129,7 @@ issue or PR if you're still interested in working on it. ### Python Taipy's repositories follow the [PEP 8](https://www.python.org/dev/peps/pep-0008/) and -[PEP 484](https://www.python.org/dev/peps/pep-0484/) coding convention. +[PEP 484](https://www.python.org/dev/peps/pep-0484/) coding convention. Gui variables need to be `snake_case` to be correctly converted into the `camelCase`, used for typescript variables. ### TypeScript @@ -255,17 +255,26 @@ npm run build:dev This will preserve the debugging symbols, and you will be able to navigate in the TypeScript code from your debugger. -!!!note "Web application location" - When you are developing front-end code for the Taipy GUI package, it may be cumbersome to have - to install the package over and over when you know that all that has changed is the JavaScript - bundle that makes the Taipy web app. - - By default, the Taipy GUI application searches for the front-end code in the - `[taipy-gui-package-dir]/taipy/gui/webapp` directory. - You can, however, set the environment variable `TAIPY_GUI_WEBAPP_PATH` to the location of your - choice, and Taipy GUI will look for the web app in that directory. - If you set this variable to the location where you build the web app repeatedly, you will no - longer have to reinstall Taipy GUI before you try your code again. +#### 📝A note on "Web application location" +When you are developing front-end code for the Taipy GUI package, it may be cumbersome to have +to install the package over and over when you know that all that has changed is the JavaScript +bundle that makes the Taipy web app. + +By default, the Taipy GUI application searches for the front-end code in the +`[taipy-gui-package-dir]/taipy/gui/webapp` directory. +You can, however, set the environment variable `TAIPY_GUI_WEBAPP_PATH` to the location of your +choice, and Taipy GUI will look for the web app in that directory. +If you set this variable to the location where you build the web app repeatedly, you will no +longer have to reinstall Taipy GUI before you try your code again. +In python, you can handle this with: +```python +import os +os.environ["TAIPY_GUI_WEBAPP_PATH"] = os.path.normpath( "/path/to/your/taipy/taipy/gui/webapp" ) +``` +or in bash with: +```bash +export TAIPY_GUI_WEBAPP_PATH="/path/to/your/taipy/taipy/gui/webapp" +``` ### Running the tests diff --git a/frontend/taipy-gui/packaging/taipy-gui.d.ts b/frontend/taipy-gui/packaging/taipy-gui.d.ts index 0c7246fa61..dbd409324c 100644 --- a/frontend/taipy-gui/packaging/taipy-gui.d.ts +++ b/frontend/taipy-gui/packaging/taipy-gui.d.ts @@ -166,6 +166,7 @@ export interface FileSelectorProps extends TaipyActiveProps { defaultLabel?: string; label?: string; multiple?: boolean; + selectionType?: string; extensions?: string; dropMessage?: string; notify?: boolean; diff --git a/frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx b/frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx index d9176e2dde..c3c3fffbc3 100644 --- a/frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx +++ b/frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx @@ -21,6 +21,7 @@ import { TaipyContext } from "../../context/taipyContext"; import { TaipyState, INITIAL_STATE } from "../../context/taipyReducers"; import { uploadFile } from "../../workers/fileupload"; + jest.mock("../../workers/fileupload", () => ({ uploadFile: jest.fn().mockResolvedValue("mocked response"), // returns a Promise that resolves to 'mocked response' })); @@ -283,4 +284,62 @@ describe("FileSelector Component", () => { // Check if the dispatch function has not been called expect(mockDispatch).not.toHaveBeenCalled(); }); + + it("checks the appearance of folder upload options in input", async () => { + const mockDispatch = jest.fn(); + + // Render HTML Document + const { getByLabelText } = render( + + + + ); + + // Simulate folder upload + const folder = new Blob([""], { type: "" }); + const selectorElt = getByLabelText("FileSelector"); + fireEvent.change(selectorElt, { target: { files: [folder] } }); + + // Wait for the upload to complete + await waitFor(() => expect(mockDispatch).toHaveBeenCalled()); + + // Check for input element + const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input"); + expect(inputElt).toBeInTheDocument(); + + // Check attributes of + expect(inputElt?.getAttribute("directory")).toBe(""); + expect(inputElt?.getAttribute("webkitdirectory")).toBe(""); + expect(inputElt?.getAttribute("mozdirectory")).toBe(""); + expect(inputElt?.getAttribute("nwdirectory")).toBe(""); + }); + + it("checks the absence of folder upload options, when selection type is set accordingly", async () => { + const mockDispatch = jest.fn(); + + // Render HTML Document + const { getByLabelText } = render( + + + + ); + + // Simulate folder upload + const file = new Blob(["(o.O)"], { type: "" }); + const selectorElt = getByLabelText("FileSelector"); + fireEvent.change(selectorElt, { target: { files: [file] } }); + + // Wait for the upload to complete + await waitFor(() => expect(mockDispatch).toHaveBeenCalled()); + + // Check for input element + const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input"); + expect(inputElt).toBeInTheDocument(); + + // Check attributes of + expect(inputElt?.getAttributeNames()).not.toContain("directory"); + expect(inputElt?.getAttributeNames()).not.toContain("webkitdirectory"); + expect(inputElt?.getAttributeNames()).not.toContain("mozdirectory"); + expect(inputElt?.getAttributeNames()).not.toContain("nwdirectory"); + }); }); diff --git a/frontend/taipy-gui/src/components/Taipy/FileSelector.tsx b/frontend/taipy-gui/src/components/Taipy/FileSelector.tsx index 5df0bcccf5..be286df97c 100644 --- a/frontend/taipy-gui/src/components/Taipy/FileSelector.tsx +++ b/frontend/taipy-gui/src/components/Taipy/FileSelector.tsx @@ -35,11 +35,14 @@ import { uploadFile } from "../../workers/fileupload"; import { SxProps } from "@mui/material"; import { getComponentClassName } from "./TaipyStyle"; + + interface FileSelectorProps extends TaipyActiveProps { onAction?: string; defaultLabel?: string; label?: string; multiple?: boolean; + selectionType?: string; extensions?: string; dropMessage?: string; notify?: boolean; @@ -65,12 +68,16 @@ const FileSelector = (props: FileSelectorProps) => { defaultLabel = "", updateVarName = "", multiple = false, + selectionType = "file", extensions = ".csv,.xlsx", dropMessage = "Drop here to Upload", label, notify = true, withBorder = true, } = props; + const directoryProps = ["d", "dir", "directory", "folder"].includes(selectionType?.toLowerCase()) ? + {webkitdirectory: "", directory: "", mozdirectory: "", nwdirectory: ""} : + undefined; const [dropLabel, setDropLabel] = useState(""); const [dropSx, setDropSx] = useState(defaultSx); const [upload, setUpload] = useState(false); @@ -194,6 +201,7 @@ const FileSelector = (props: FileSelectorProps) => { type="file" accept={extensions} multiple={multiple} + {...directoryProps} onChange={handleChange} disabled={!active || upload} /> diff --git a/frontend/taipy-gui/src/workers/fileupload.worker.ts b/frontend/taipy-gui/src/workers/fileupload.worker.ts index d1011a19b8..bf65f0dacf 100644 --- a/frontend/taipy-gui/src/workers/fileupload.worker.ts +++ b/frontend/taipy-gui/src/workers/fileupload.worker.ts @@ -23,6 +23,7 @@ const uploadFile = ( part: number, total: number, fileName: string, + filePath: string, multiple: boolean, id: string, progressCb: (uploaded: number) => void @@ -40,6 +41,7 @@ const uploadFile = ( onAction && fdata.append("on_action", onAction); uploadData && fdata.append("upload_data", uploadData); fdata.append("multiple", multiple ? "True" : "False"); + fdata.append("path", filePath) xhr.send(fdata); }; @@ -90,6 +92,7 @@ const process = ( Math.floor(start / BYTES_PER_CHUNK), tot, blob.name, + blob.webkitRelativePath, i == 0 ? false : files.length > 0, id, progressCallback diff --git a/taipy/gui/_renderers/factory.py b/taipy/gui/_renderers/factory.py index e7bb2e4a38..5c14efd476 100644 --- a/taipy/gui/_renderers/factory.py +++ b/taipy/gui/_renderers/factory.py @@ -262,6 +262,7 @@ class _Factory: ("on_action", PropertyType.function), ("active", PropertyType.dynamic_boolean, True), ("multiple", PropertyType.boolean, False), + ("selection_type", PropertyType.string), ("extensions",), ("drop_message",), ("hover_text", PropertyType.dynamic_string), diff --git a/taipy/gui/gui.py b/taipy/gui/gui.py index c9488ddebd..89ee496b43 100644 --- a/taipy/gui/gui.py +++ b/taipy/gui/gui.py @@ -989,6 +989,8 @@ def __upload_files(self): context = request.form.get("context", None) upload_data = request.form.get("upload_data", None) multiple = "multiple" in request.form and request.form["multiple"] == "True" + + # File parsing and checks file = request.files.get("blob", None) if not file: _warn("upload files: No file part") @@ -998,58 +1000,72 @@ def __upload_files(self): if file.filename == "": _warn("upload files: No selected file") return ("upload files: No selected file", 400) + + # Path parsing and checks + path = request.form.get("path", "") suffix = "" complete = True part = 0 + if "total" in request.form: total = int(request.form["total"]) if total > 1 and "part" in request.form: part = int(request.form["part"]) suffix = f".part.{part}" complete = part == total - 1 - if file: # and allowed_file(file.filename) - upload_path = Path(self._get_config("upload_folder", tempfile.gettempdir())).resolve() + + # Extract upload path (when single file is selected, path="" does not change the path) + upload_root = os.path.abspath( self._get_config( "upload_folder", tempfile.gettempdir() ) ) + upload_path = os.path.abspath( os.path.join( upload_root, os.path.dirname(path) ) ) + if upload_path.startswith( upload_root ): + upload_path = Path( upload_path ).resolve() + os.makedirs( upload_path, exist_ok=True ) + # Save file into upload_path directory file_path = _get_non_existent_file_path(upload_path, secure_filename(file.filename)) - file.save(str(upload_path / (file_path.name + suffix))) - if complete: - if part > 0: - try: - with open(file_path, "wb") as grouped_file: - for nb in range(part + 1): - part_file_path = upload_path / f"{file_path.name}.part.{nb}" - with open(part_file_path, "rb") as part_file: - grouped_file.write(part_file.read()) - # remove file_path after it is merged - part_file_path.unlink() - except EnvironmentError as ee: # pragma: no cover - _warn(f"Cannot group file after chunk upload for {file.filename}", ee) - return (f"Cannot group file after chunk upload for {file.filename}", 500) - # notify the file is uploaded - newvalue = str(file_path) - if multiple and var_name: - value = _getscopeattr(self, var_name) - if not isinstance(value, t.List): - value = [] if value is None else [value] - value.append(newvalue) - newvalue = value - with self._set_locals_context(context): - if on_upload_action: - data = {} - if upload_data: - try: - data = json.loads(upload_data) - except Exception: - pass - data["path"] = file_path - file_fn = self._get_user_function(on_upload_action) - if not _is_function(file_fn): - file_fn = _getscopeattr(self, on_upload_action) - if _is_function(file_fn): - self._call_function_with_state( - t.cast(t.Callable, file_fn), ["file_upload", {"args": [data]}] - ) - else: - setattr(self._bindings(), var_name, newvalue) + file.save( os.path.join( upload_path, (file_path.name + suffix) ) ) + else: + _warn(f"upload files: Path {path} points outside of upload root.") + return("upload files: Path part points outside of upload root.", 400) + + if complete: + if part > 0: + try: + with open(file_path, "wb") as grouped_file: + for nb in range(part + 1): + part_file_path = upload_path / f"{file_path.name}.part.{nb}" + with open(part_file_path, "rb") as part_file: + grouped_file.write(part_file.read()) + # remove file_path after it is merged + part_file_path.unlink() + except EnvironmentError as ee: # pragma: no cover + _warn(f"Cannot group file after chunk upload for {file.filename}", ee) + return (f"Cannot group file after chunk upload for {file.filename}", 500) + # notify the file is uploaded + newvalue = str(file_path) + if multiple and var_name: + value = _getscopeattr(self, var_name) + if not isinstance(value, t.List): + value = [] if value is None else [value] + value.append(newvalue) + newvalue = value + with self._set_locals_context(context): + if on_upload_action: + data = {} + if upload_data: + try: + data = json.loads(upload_data) + except Exception: + pass + data["path"] = file_path + file_fn = self._get_user_function(on_upload_action) + if not _is_function(file_fn): + file_fn = _getscopeattr(self, on_upload_action) + if _is_function(file_fn): + self._call_function_with_state( + t.cast(t.Callable, file_fn), ["file_upload", {"args": [data]}] + ) + else: + setattr(self._bindings(), var_name, newvalue) return ("", 200) def __send_var_list_update( # noqa C901 diff --git a/taipy/gui/viselements.json b/taipy/gui/viselements.json index ddc13e8d21..a6cc9fa1b4 100644 --- a/taipy/gui/viselements.json +++ b/taipy/gui/viselements.json @@ -1227,6 +1227,12 @@ "default_value": "False", "doc": "If set to True, multiple files can be uploaded." }, + { + "name": "selection_type", + "type": "str", + "default_value": "\"file\"", + "doc": "Can be set to \"file\" (with \"f\", \"\" aliases) or \"directory\" (with \"d\", \"dir\", \"folder\" aliases) to upload the respective element with preserved inner structure." + }, { "name": "extensions", "type": "str", diff --git a/tests/gui/control/test_file_selector.py b/tests/gui/control/test_file_selector.py index f2fa92b9cc..531ee192d1 100644 --- a/tests/gui/control/test_file_selector.py +++ b/tests/gui/control/test_file_selector.py @@ -47,3 +47,15 @@ def test_file_selector_html(gui: Gui, test_client, helpers): 'onAction="action"', ] helpers.test_control_html(gui, html_string, expected_list) + + +# Testing folder support +def test_file_selector_folder_md(gui: Gui, test_client, helpers): + gui._bind_var_val("content", None) + md_string = '<|{content}|file_selector|selection_type=d|>' + expected_list = [ + "