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 = [
+ "