Ver código fonte

Merge branch 'feature/#398-expand-exposed-type-parameter' of https://github.com/sohamkumar05/taipy into feature/#398-expand-exposed-type-parameter

Sohamkumar Chauhan 5 meses atrás
pai
commit
c1f364b1cf
48 arquivos alterados com 1965 adições e 531 exclusões
  1. 5 6
      doc/gui/examples/controls/chat_calculator.py
  2. 4 2
      doc/gui/examples/controls/chat_discuss.py
  3. 47 0
      doc/gui/examples/controls/chat_images.py
  4. 8 9
      doc/gui/examples/controls/chat_messages.py
  5. 1 1
      frontend/taipy-gui/src/components/Router.tsx
  6. 3 7
      frontend/taipy-gui/src/components/Taipy/Chart.tsx
  7. 94 63
      frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx
  8. 142 94
      frontend/taipy-gui/src/components/Taipy/Chat.tsx
  9. 4 4
      frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx
  10. 31 13
      frontend/taipy-gui/src/components/Taipy/FileSelector.tsx
  11. 16 14
      frontend/taipy-gui/src/components/Taipy/Input.spec.tsx
  12. 20 17
      frontend/taipy-gui/src/components/Taipy/Input.tsx
  13. 21 21
      frontend/taipy-gui/src/components/Taipy/Notification.spec.tsx
  14. 21 19
      frontend/taipy-gui/src/components/Taipy/Notification.tsx
  15. 7 7
      frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
  16. 6 1
      frontend/taipy-gui/src/components/Taipy/Selector.tsx
  17. 0 2
      frontend/taipy-gui/src/components/Taipy/lovUtils.tsx
  18. 95 54
      frontend/taipy-gui/src/context/taipyReducers.spec.ts
  19. 28 28
      frontend/taipy-gui/src/context/taipyReducers.ts
  20. 12 0
      frontend/taipy-gui/src/themes/darkThemeTemplate.ts
  21. 30 0
      frontend/taipy-gui/src/utils/image.ts
  22. 2 2
      taipy/common/config/config.pyi
  23. 1 1
      taipy/core/_entity/_entity.py
  24. 3 0
      taipy/core/_entity/_properties.py
  25. 5 0
      taipy/core/common/_utils.py
  26. 3 0
      taipy/core/config/data_node_config.py
  27. 63 3
      taipy/core/config/scenario_config.py
  28. 59 27
      taipy/core/data/_file_datanode_mixin.py
  29. 22 5
      taipy/core/data/data_node.py
  30. 3 2
      taipy/gui/_renderers/factory.py
  31. 1 1
      taipy/gui/builder/_api_generator.py
  32. 4 1
      taipy/gui/utils/viselements.py
  33. 22 5
      taipy/gui/viselements.json
  34. 43 33
      taipy/gui_core/_context.py
  35. 20 0
      taipy/gui_core/_utils.py
  36. 5 0
      tests/core/config/test_data_node_config.py
  37. 79 0
      tests/core/config/test_scenario_config.py
  38. 28 9
      tests/core/data/test_csv_data_node.py
  39. 124 0
      tests/core/data/test_data_node.py
  40. 9 7
      tests/core/data/test_excel_data_node.py
  41. 8 6
      tests/core/data/test_json_data_node.py
  42. 9 7
      tests/core/data/test_parquet_data_node.py
  43. 7 5
      tests/core/data/test_pickle_data_node.py
  44. 4 10
      tests/gui_core/test_context_is_editable.py
  45. 38 45
      tests/gui_core/test_context_is_readable.py
  46. 209 0
      tests/gui_core/test_context_on_file_action.py
  47. 388 0
      tests/gui_core/test_context_tabular_data_edit.py
  48. 211 0
      tests/gui_core/test_context_update_data.py

+ 5 - 6
doc/gui/examples/controls/chat_calculator.py

@@ -16,20 +16,19 @@
 # Human-computer dialog UI based on the chat control.
 # Human-computer dialog UI based on the chat control.
 # -----------------------------------------------------------------------------------------
 # -----------------------------------------------------------------------------------------
 from math import cos, pi, sin, sqrt, tan  # noqa: F401
 from math import cos, pi, sin, sqrt, tan  # noqa: F401
-from typing import Optional
 
 
 from taipy.gui import Gui
 from taipy.gui import Gui
 
 
 # The user interacts with the Python interpreter
 # The user interacts with the Python interpreter
 users = ["human", "Result"]
 users = ["human", "Result"]
-messages: list[tuple[str, str, str, Optional[str]]] = []
+messages: list[tuple[str, str, str]] = []
 
 
 
 
 def evaluate(state, var_name: str, payload: dict):
 def evaluate(state, var_name: str, payload: dict):
     # Retrieve the callback parameters
     # Retrieve the callback parameters
-    (_, _, expression, sender_id, image_url) = payload.get("args", [])
+    (_, _, expression, sender_id, _) = payload.get("args", [])
     # Add the input content as a sent message
     # Add the input content as a sent message
-    messages.append((f"{len(messages)}", expression, sender_id, image_url))
+    messages.append((f"{len(messages)}", expression, sender_id))
     # Default message used if evaluation fails
     # Default message used if evaluation fails
     result = "Invalid expression"
     result = "Invalid expression"
     try:
     try:
@@ -38,12 +37,12 @@ def evaluate(state, var_name: str, payload: dict):
     except Exception:
     except Exception:
         pass
         pass
     # Add the result as an incoming message
     # Add the result as an incoming message
-    messages.append((f"{len(messages)}", result, users[1], None))
+    messages.append((f"{len(messages)}", result, users[1]))
     state.messages = messages
     state.messages = messages
 
 
 
 
 page = """
 page = """
-<|{messages}|chat|users={users}|sender_id={users[0]}|on_action=evaluate|>
+<|{messages}|chat|users={users}|sender_id={users[0]}|on_action=evaluate|don't allow_send_images|>
 """
 """
 
 
 Gui(page).run(title="Chat - Calculator")
 Gui(page).run(title="Chat - Calculator")

+ 4 - 2
doc/gui/examples/controls/chat_discuss.py

@@ -25,7 +25,7 @@ from taipy.gui import Gui, Icon
 from taipy.gui.gui_actions import navigate, notify
 from taipy.gui.gui_actions import navigate, notify
 
 
 username = ""
 username = ""
-users: list[Union[str, Icon]] = []
+users: list[tuple[str, Union[str, Icon]]] = []
 messages: list[tuple[str, str, str, Optional[str]]] = []
 messages: list[tuple[str, str, str, Optional[str]]] = []
 
 
 Gui.add_shared_variables("messages", "users")
 Gui.add_shared_variables("messages", "users")
@@ -82,4 +82,6 @@ discuss_page = """
 """
 """
 
 
 pages = {"register": register_page, "discuss": discuss_page}
 pages = {"register": register_page, "discuss": discuss_page}
-gui = Gui(pages=pages).run(title="Chat - Discuss")
+
+if __name__ == "__main__":
+    gui = Gui(pages=pages).run(title="Chat - Discuss")

+ 47 - 0
doc/gui/examples/controls/chat_images.py

@@ -0,0 +1,47 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+# A chatting application based on the chat control.
+# In order to see the users' avatars, the image files must be stored next to this script.
+# If you want to test this application locally, you need to use several browsers and/or
+# incognito windows so a given user's context is not reused.
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui, Icon
+
+msgs = [
+    ["1", "msg 1", "Alice", None],
+    ["2", "msg From Another unknown User", "Charles", None],
+    ["3", "This from the sender User", "taipy", "./beatrix-avatar.png"],
+    ["4", "And from another known one", "Alice", None],
+]
+users = [
+    ["Alice", Icon("./alice-avatar.png", "Alice avatar")],
+    ["Charles", Icon("./charles-avatar.png", "Charles avatar")],
+    ["taipy", Icon("./beatrix-avatar.png", "Beatrix avatar")],
+]
+
+
+def on_action(state, id: str, payload: dict):
+    (reason, varName, text, senderId, imageData) = payload.get("args", [])
+    msgs.append([f"{len(msgs) +1 }", text, senderId, imageData])
+    state.msgs = msgs
+
+
+page = """
+<|{msgs}|chat|users={users}|allow_send_images|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Chat - Images")

+ 8 - 9
doc/gui/examples/controls/chat_messages.py

@@ -14,7 +14,7 @@ from taipy.gui import Gui, Icon
 msgs = [
 msgs = [
     ["1", "msg 1", "Alice", None],
     ["1", "msg 1", "Alice", None],
     ["2", "msg From Another unknown User", "Charles", None],
     ["2", "msg From Another unknown User", "Charles", None],
-    ["3", "This from the sender User", "taipy", "./sample.jpeg"],
+    ["3", "This from the sender User", "taipy", None],
     ["4", "And from another known one", "Alice", None],
     ["4", "And from another known one", "Alice", None],
 ]
 ]
 users = [
 users = [
@@ -25,15 +25,12 @@ users = [
 
 
 
 
 def on_action(state, var_name: str, payload: dict):
 def on_action(state, var_name: str, payload: dict):
-    args = payload.get("args", [])
-    msgs.append([f"{len(msgs) +1 }", args[2], args[3], args[4]])
+    (reason, varName, text, senderId, imageData) = payload.get("args", [])
+    msgs.append([f"{len(msgs) +1 }", text, senderId, imageData])
     state.msgs = msgs
     state.msgs = msgs
 
 
 
 
-Gui(
-    """
-<|toggle|theme|>
-# Test Chat
+page="""
 <|1 1 1|layout|
 <|1 1 1|layout|
 <|{msgs}|chat|users={users}|show_sender={True}|>
 <|{msgs}|chat|users={users}|show_sender={True}|>
 
 
@@ -42,5 +39,7 @@ Gui(
 <|{msgs}|chat|users={users}|show_sender={True}|not with_input|>
 <|{msgs}|chat|users={users}|show_sender={True}|not with_input|>
 |>
 |>
 
 
-""",
-).run()
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Chat - Simple")

+ 1 - 1
frontend/taipy-gui/src/components/Router.tsx

@@ -152,7 +152,7 @@ const Router = () => {
                                         ) : null}
                                         ) : null}
                                     </Box>
                                     </Box>
                                     <ErrorBoundary FallbackComponent={ErrorFallback}>
                                     <ErrorBoundary FallbackComponent={ErrorFallback}>
-                                        <TaipyNotification alerts={state.alerts} />
+                                        <TaipyNotification notifications={state.notifications} />
                                         <UIBlocker block={state.block} />
                                         <UIBlocker block={state.block} />
                                         <Navigate
                                         <Navigate
                                             to={state.navigateTo}
                                             to={state.navigateTo}

+ 3 - 7
frontend/taipy-gui/src/components/Taipy/Chart.tsx

@@ -17,6 +17,7 @@ import { useTheme } from "@mui/material";
 import Box from "@mui/material/Box";
 import Box from "@mui/material/Box";
 import Skeleton from "@mui/material/Skeleton";
 import Skeleton from "@mui/material/Skeleton";
 import Tooltip from "@mui/material/Tooltip";
 import Tooltip from "@mui/material/Tooltip";
+import merge from "lodash/merge";
 import { nanoid } from "nanoid";
 import { nanoid } from "nanoid";
 import {
 import {
     Config,
     Config,
@@ -300,7 +301,7 @@ const Chart = (props: ChartProp) => {
     const theme = useTheme();
     const theme = useTheme();
     const module = useModule();
     const module = useModule();
 
 
-    const refresh = useMemo(() => data?.__taipy_refresh !== undefined ? nanoid() : false, [data]);
+    const refresh = useMemo(() => (data?.__taipy_refresh !== undefined ? nanoid() : false), [data]);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const render = useDynamicProperty(props.render, props.defaultRender, true);
     const render = useDynamicProperty(props.render, props.defaultRender, true);
@@ -394,12 +395,7 @@ const Chart = (props: ChartProp) => {
             layout.template = template;
             layout.template = template;
         }
         }
         if (props.figure) {
         if (props.figure) {
-            return {
-                ...(props.figure[0].layout as Partial<Layout>),
-                ...layout,
-                title: title || layout.title || (props.figure[0].layout as Partial<Layout>).title,
-                clickmode: "event+select",
-            } as Layout;
+            return merge({},props.figure[0].layout as Partial<Layout>, layout, {title: title || layout.title || (props.figure[0].layout as Partial<Layout>).title, clickmode: "event+select"});
         }
         }
         return {
         return {
             ...layout,
             ...layout,

+ 94 - 63
frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx

@@ -22,15 +22,24 @@ import { TaipyContext } from "../../context/taipyContext";
 import { stringIcon } from "../../utils/icon";
 import { stringIcon } from "../../utils/icon";
 import { TableValueType } from "./tableUtils";
 import { TableValueType } from "./tableUtils";
 
 
+import { toDataUrl } from "../../utils/image";
+jest.mock('../../utils/image', () => ({
+    toDataUrl: (url: string) => new Promise((resolve) => resolve(url)),
+  }));
+
 const valueKey = "Infinite-Entity--asc";
 const valueKey = "Infinite-Entity--asc";
 const messages: TableValueType = {
 const messages: TableValueType = {
     [valueKey]: {
     [valueKey]: {
         data: [
         data: [
-    ["1", "msg 1", "Fred"],
-    ["2", "msg From Another unknown User", "Fredo"],
-    ["3", "This from the sender User", "taipy"],
-    ["4", "And from another known one", "Fredi"],
-], rowcount: 4, start: 0}};
+            ["1", "msg 1", "Fred"],
+            ["2", "msg From Another unknown User", "Fredo"],
+            ["3", "This from the sender User", "taipy"],
+            ["4", "And from another known one", "Fredi"],
+        ],
+        rowcount: 4,
+        start: 0,
+    },
+};
 const user1: [string, stringIcon] = ["Fred", { path: "/images/favicon.png", text: "Fred.png" }];
 const user1: [string, stringIcon] = ["Fred", { path: "/images/favicon.png", text: "Fred.png" }];
 const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const users = [user1, user2];
 const users = [user1, user2];
@@ -46,32 +55,48 @@ describe("Chat Component", () => {
         expect(input.tagName).toBe("INPUT");
         expect(input.tagName).toBe("INPUT");
     });
     });
     it("uses the class", async () => {
     it("uses the class", async () => {
-        const { getByText } = render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
+        const { getByText } = render(
+            <Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />
+        );
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
-        expect(elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("taipy-chat");
+        expect(
+            elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
+        ).toHaveClass("taipy-chat");
     });
     });
     it("can display an avatar", async () => {
     it("can display an avatar", async () => {
-        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} mode="raw"/>);
+        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} mode="raw" />);
         const elt = getByAltText("Fred.png");
         const elt = getByAltText("Fred.png");
         expect(elt.tagName).toBe("IMG");
         expect(elt.tagName).toBe("IMG");
     });
     });
     it("is disabled", async () => {
     it("is disabled", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).toHaveClass("Mui-disabled");
+        expect(getByRole("button", { name: /send message/i })).toHaveClass("Mui-disabled");
     });
     });
     it("is enabled by default", async () => {
     it("is enabled by default", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        const sendButton = getByRole("button", { name: /send message/i });
+        expect(sendButton).toHaveClass("Mui-disabled");
+        await userEvent.click(elt);
+        await userEvent.keyboard("new message");
+        expect(sendButton).not.toHaveClass("Mui-disabled");
     });
     });
     it("is enabled by active", async () => {
     it("is enabled by active", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        const sendButton = getByRole("button", { name: /send message/i });
+        expect(sendButton).toHaveClass("Mui-disabled");
+        await userEvent.click(elt);
+        await userEvent.keyboard("new message");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        expect(sendButton).not.toHaveClass("Mui-disabled");
     });
     });
     it("can hide input", async () => {
     it("can hide input", async () => {
-        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} mode="raw"/>);
+        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
         const elt = document.querySelector(".taipy-chat input");
         const elt = document.querySelector(".taipy-chat input");
         expect(elt).toBeNull();
         expect(elt).toBeNull();
     });
     });
@@ -81,13 +106,17 @@ describe("Chat Component", () => {
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
     });
     });
     it("can render pre", async () => {
     it("can render pre", async () => {
-        const { getByText } = render(<Chat messages={messages} defaultKey={valueKey} className="taipy-chat"  mode="pre" />);
+        const { getByText } = render(
+            <Chat messages={messages} defaultKey={valueKey} className="taipy-chat" mode="pre" />
+        );
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
         expect(elt.tagName).toBe("PRE");
         expect(elt.tagName).toBe("PRE");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
     });
     });
     it("can render raw", async () => {
     it("can render raw", async () => {
-        const { getByText } = render(<Chat messages={messages} defaultKey={valueKey} className="taipy-chat"  mode="raw" />);
+        const { getByText } = render(
+            <Chat messages={messages} defaultKey={valueKey} className="taipy-chat" mode="raw" />
+        );
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
         expect(elt).toHaveClass("taipy-chat-raw");
         expect(elt).toHaveClass("taipy-chat-raw");
     });
     });
@@ -96,7 +125,7 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText } = render(
         const { getByLabelText } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
@@ -108,7 +137,7 @@ describe("Chat Component", () => {
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 action: undefined,
-                args: ["Enter", "varName", "new message", "taipy",null],
+                args: ["Enter", "varName", "new message", "taipy", null],
             },
             },
         });
         });
     });
     });
@@ -117,89 +146,91 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText, getByRole } = render(
         const { getByLabelText, getByRole } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
         await userEvent.click(elt);
         await userEvent.click(elt);
         await userEvent.keyboard("new message");
         await userEvent.keyboard("new message");
-        await userEvent.click(getByRole("button",{ name: /send message/i }))
+        await userEvent.click(getByRole("button", { name: /send message/i }));
         expect(dispatch).toHaveBeenCalledWith({
         expect(dispatch).toHaveBeenCalledWith({
             type: "SEND_ACTION_ACTION",
             type: "SEND_ACTION_ACTION",
             name: "",
             name: "",
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 action: undefined,
-                args: ["click", "varName", "new message", "taipy",null],
+                args: ["click", "varName", "new message", "taipy", null],
             },
             },
         });
         });
     });
     });
-    it("handle image upload",async()=>{
+    it("handle image upload", async () => {
         const dispatch = jest.fn();
         const dispatch = jest.fn();
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
-        const { getByLabelText,getByText,getByAltText,queryByText,getByRole } = render(
+        const { getByLabelText, getByText, getByAltText, queryByText, getByRole } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
-        const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
-        URL.createObjectURL = jest.fn(() => 'mocked-url');
+        const file = new File(["(⌐□_□)"], "test.png", { type: "image/png" });
+        URL.createObjectURL = jest.fn(() => "mocked-url");
         URL.revokeObjectURL = jest.fn();
         URL.revokeObjectURL = jest.fn();
 
 
-        const attachButton = getByLabelText('upload image');
+        const attachButton = getByLabelText("upload image");
         expect(attachButton).toBeInTheDocument();
         expect(attachButton).toBeInTheDocument();
 
 
-
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         expect(fileInput).toBeInTheDocument();
         expect(fileInput).toBeInTheDocument();
         fireEvent.change(fileInput, { target: { files: [file] } });
         fireEvent.change(fileInput, { target: { files: [file] } });
 
 
         await waitFor(() => {
         await waitFor(() => {
-            const chipWithImage = getByText('test.png');
+            const chipWithImage = getByText("test.png");
             expect(chipWithImage).toBeInTheDocument();
             expect(chipWithImage).toBeInTheDocument();
-            const previewImg = getByAltText('Image preview');
+            const previewImg = getByAltText("Image preview");
             expect(previewImg).toBeInTheDocument();
             expect(previewImg).toBeInTheDocument();
-            expect(previewImg).toHaveAttribute('src', 'mocked-url');
-          });
+            expect(previewImg).toHaveAttribute("src", "mocked-url");
+        });
 
 
-          const elt = getByLabelText("message (taipy)");
-          await userEvent.click(elt);
-          await userEvent.keyboard("Test message with image");
-          await userEvent.click(getByRole("button",{ name: /send message/i }))
+        const elt = getByLabelText("message (taipy)");
+        await userEvent.click(elt);
+        await userEvent.keyboard("Test message with image");
+        await userEvent.click(getByRole("button", { name: /send message/i }));
 
 
-          expect(dispatch).toHaveBeenNthCalledWith(2,
-            expect.objectContaining({
-              type: 'SEND_ACTION_ACTION',
-              payload: expect.objectContaining({
-                args: ['click', 'varName', 'Test message with image', 'taipy', 'mocked-url']
-              })
-            })
-          );
-          await waitFor(() => {
-            const chipWithImage = queryByText('test.png');
+        // needed mocked toDataUrl
+        await waitFor(() => {
+            expect(dispatch).toHaveBeenCalledWith(
+                expect.objectContaining({
+                    type: "SEND_ACTION_ACTION",
+                    payload: expect.objectContaining({
+                        args: ["click", "varName", "Test message with image", "taipy", "mocked-url"],
+                    }),
+                })
+            );
+        });
+        await waitFor(() => {
+            const chipWithImage = queryByText("test.png");
             expect(chipWithImage).not.toBeInTheDocument();
             expect(chipWithImage).not.toBeInTheDocument();
-          });
-          jest.restoreAllMocks()
-    })
-    it("Not upload image over a file size limit",async()=>{
+        });
+        jest.restoreAllMocks();
+    });
+    it("Not upload image over a file size limit", async () => {
         const dispatch = jest.fn();
         const dispatch = jest.fn();
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
-        const { getByText,getByAltText } = render(
+        const { getByText, getByAltText } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" maxFileSize={0} defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" maxFileSize={0} defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
-        const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
-        URL.createObjectURL = jest.fn(() => 'mocked-url');
+        const file = new File(["(⌐□_□)"], "test.png", { type: "image/png" });
+        URL.createObjectURL = jest.fn(() => "mocked-url");
 
 
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         expect(fileInput).toBeInTheDocument();
         expect(fileInput).toBeInTheDocument();
         fireEvent.change(fileInput, { target: { files: [file] } });
         fireEvent.change(fileInput, { target: { files: [file] } });
 
 
         await waitFor(() => {
         await waitFor(() => {
-            expect(() =>getByText('test.png')).toThrow()
-            expect(()=>getByAltText('Image preview')).toThrow();
-          });
-          jest.restoreAllMocks()
-    })
+            expect(() => getByText("test.png")).toThrow();
+            expect(() => getByAltText("Image preview")).toThrow();
+        });
+        jest.restoreAllMocks();
+    });
 });
 });

+ 142 - 94
frontend/taipy-gui/src/components/Taipy/Chat.tsx

@@ -21,6 +21,7 @@ import React, {
     useEffect,
     useEffect,
     ReactNode,
     ReactNode,
     lazy,
     lazy,
+    ChangeEvent,
 } from "react";
 } from "react";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 import Avatar from "@mui/material/Avatar";
 import Avatar from "@mui/material/Avatar";
@@ -39,7 +40,11 @@ import AttachFile from "@mui/icons-material/AttachFile";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 
 
-import { createRequestInfiniteTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
+import {
+    createNotificationAction,
+    createRequestInfiniteTableUpdateAction,
+    createSendActionNameAction,
+} from "../../context/taipyReducers";
 import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { LoVElt, useLovListMemo } from "./lovUtils";
 import { LoVElt, useLovListMemo } from "./lovUtils";
@@ -49,6 +54,7 @@ import { RowType, TableValueType } from "./tableUtils";
 import { Stack } from "@mui/material";
 import { Stack } from "@mui/material";
 import { getComponentClassName } from "./TaipyStyle";
 import { getComponentClassName } from "./TaipyStyle";
 import { noDisplayStyle } from "./utils";
 import { noDisplayStyle } from "./utils";
+import { toDataUrl } from "../../utils/image";
 
 
 const Markdown = lazy(() => import("react-markdown"));
 const Markdown = lazy(() => import("react-markdown"));
 
 
@@ -65,6 +71,7 @@ interface ChatProps extends TaipyActiveProps {
     pageSize?: number;
     pageSize?: number;
     showSender?: boolean;
     showSender?: boolean;
     mode?: string;
     mode?: string;
+    allowSendImages?: boolean;
 }
 }
 
 
 const ENTER_KEY = "Enter";
 const ENTER_KEY = "Enter";
@@ -135,7 +142,7 @@ const defaultBoxSx = {
 } as SxProps<Theme>;
 } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
-const imageSx = {width:3/5, height:"auto"}
+const imageSx = { width: 3 / 5, height: "auto" };
 interface key2Rows {
 interface key2Rows {
     key: string;
     key: string;
 }
 }
@@ -166,16 +173,11 @@ const ChatRow = (props: ChatRowProps) => {
             justifyContent={sender ? "flex-end" : undefined}
             justifyContent={sender ? "flex-end" : undefined}
         >
         >
             <Grid sx={sender ? senderMsgSx : undefined}>
             <Grid sx={sender ? senderMsgSx : undefined}>
-            {image?(
-                <Grid container justifyContent={sender ? "flex-end" : undefined}>
-                <Box
-                                component="img"
-                                sx={imageSx}
-                                alt="Uploaded image"
-                                src={image}
-                            />
-                </Grid>
-                            ):null}
+                {image ? (
+                    <Grid container justifyContent={sender ? "flex-end" : undefined}>
+                        <Box component="img" sx={imageSx} alt="Uploaded image" src={image} />
+                    </Grid>
+                ) : null}
                 {(!sender || showSender) && avatar ? (
                 {(!sender || showSender) && avatar ? (
                     <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
                     <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
@@ -227,9 +229,10 @@ const Chat = (props: ChatProps) => {
         onAction,
         onAction,
         withInput = true,
         withInput = true,
         defaultKey = "",
         defaultKey = "",
-        maxFileSize= 1 * 1024 * 1024, // 1 MB
+        maxFileSize = .8 * 1024 * 1024, // 0.8 MB
         pageSize = 50,
         pageSize = 50,
         showSender = false,
         showSender = false,
+        allowSendImages = true,
     } = props;
     } = props;
     const dispatch = useDispatch();
     const dispatch = useDispatch();
     const module = useModule();
     const module = useModule();
@@ -240,6 +243,7 @@ const Chat = (props: ChatProps) => {
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
+    const [enableSend, setEnableSend] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
     const [selectedFile, setSelectedFile] = useState<File | null>(null);
     const [selectedFile, setSelectedFile] = useState<File | null>(null);
@@ -269,61 +273,94 @@ const Chat = (props: ChatProps) => {
         [props.height]
         [props.height]
     );
     );
 
 
+    const onChangeHandler = useCallback((evt: ChangeEvent<HTMLInputElement>) => setEnableSend(!!evt.target.value), []);
+
+    const sendAction = useCallback(
+        (elt: HTMLInputElement | null | undefined, reason: string) => {
+            if (elt && (elt?.value || imagePreview)) {
+                toDataUrl(imagePreview)
+                    .then((dataUrl) => {
+                        dispatch(
+                            createSendActionNameAction(
+                                id,
+                                module,
+                                onAction,
+                                reason,
+                                updateVarName,
+                                elt?.value,
+                                senderId,
+                                dataUrl
+                            )
+                        );
+                        elt.value = "";
+                        setSelectedFile(null);
+                        setImagePreview((url) => {
+                            url && URL.revokeObjectURL(url);
+                            return null;
+                        });
+                        fileInputRef.current && (fileInputRef.current.value = "");
+                    })
+                    .catch(console.log);
+            }
+        },
+        [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
+    );
+
     const handleAction = useCallback(
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
         (evt: KeyboardEvent<HTMLDivElement>) => {
             if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
             if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
-                const elt = evt.currentTarget.querySelector("input");
-                if (elt?.value) {
-                    dispatch(
-                        createSendActionNameAction(id, module, onAction, evt.key, updateVarName, elt?.value,senderId, imagePreview)
-                    );
-                    elt.value = "";
-                    setSelectedFile(null);
-                    setImagePreview(null);
-                }
+                sendAction(evt.currentTarget.querySelector("input"), evt.key);
                 evt.preventDefault();
                 evt.preventDefault();
             }
             }
         },
         },
-        [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
     );
     );
 
 
     const handleClick = useCallback(
     const handleClick = useCallback(
         (evt: MouseEvent<HTMLButtonElement>) => {
         (evt: MouseEvent<HTMLButtonElement>) => {
-            const elt = evt.currentTarget.parentElement?.parentElement?.querySelector("input");
-            if (elt?.value) {
-                dispatch(
-                    createSendActionNameAction(id, module, onAction, "click", updateVarName, elt?.value,senderId,imagePreview)
-                );
-                elt.value = "";
-                setSelectedFile(null);
-                setImagePreview(null);
-            }
+            sendAction(evt.currentTarget.parentElement?.parentElement?.querySelector("input"), "click");
             evt.preventDefault();
             evt.preventDefault();
         },
         },
-        [imagePreview,updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
     );
     );
 
 
-    const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
-            const file = event.target.files ? event.target.files[0] : null;
-            if (file) {
-                if (file.type.startsWith("image/") && file.size <= maxFileSize) {
-                    setSelectedFile(file);
-                    const newImagePreview = URL.createObjectURL(file);
-                    setImagePreview(newImagePreview);
-                    setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
-                } else {
-                    setSelectedFile(null);
-                    setImagePreview(null);
-                }
+    const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
+        const file = event.target.files ? event.target.files[0] : null;
+        if (file) {
+            if (file.type.startsWith("image/") && file.size <= maxFileSize) {
+                setSelectedFile(file);
+                const newImagePreview = URL.createObjectURL(file);
+                setImagePreview(newImagePreview);
+                setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
+            } else {
+                dispatch(
+                    createNotificationAction({
+                        atype: "info",
+                        message:
+                            file.size > maxFileSize
+                                ? `Image size is limited to ${maxFileSize / 1024} KB`
+                                : "Only image file are authorized",
+                        system: false,
+                        duration: 3000,
+                    })
+                );
+                setSelectedFile(null);
+                setImagePreview(null);
+                fileInputRef.current && (fileInputRef.current.value = "");
             }
             }
-        };
-
-    const handleAttachClick = useCallback(() => {
-        if (fileInputRef.current) {
-            fileInputRef.current.click();
         }
         }
-    }, [fileInputRef]);
+    }, [maxFileSize, dispatch]);
 
 
+    const handleAttachClick = useCallback(() => fileInputRef.current && fileInputRef.current.click(), [fileInputRef]);
+
+    const handleImageDelete = useCallback(() => {
+        setSelectedFile(null);
+        setImagePreview((url) => {
+            url && URL.revokeObjectURL(url);
+            return null;
+        });
+        fileInputRef.current && (fileInputRef.current.value = "");
+    }, []);
 
 
     const avatars = useMemo(() => {
     const avatars = useMemo(() => {
         return users.reduce((pv, elt) => {
         return users.reduce((pv, elt) => {
@@ -477,7 +514,11 @@ const Chat = (props: ChatProps) => {
                                 senderId={senderId}
                                 senderId={senderId}
                                 message={`${row[columns[1]]}`}
                                 message={`${row[columns[1]]}`}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
-                                image={columns[3] && columns[3] != "_tp_index" && row[columns[3]] ? `${row[columns[3]]}` : undefined}
+                                image={
+                                    columns[3] && columns[3] != "_tp_index" && row[columns[3]]
+                                        ? `${row[columns[3]]}`
+                                        : undefined
+                                }
                                 className={className}
                                 className={className}
                                 getAvatar={getAvatar}
                                 getAvatar={getAvatar}
                                 index={idx}
                                 index={idx}
@@ -498,60 +539,67 @@ const Chat = (props: ChatProps) => {
                 </Popper>
                 </Popper>
                 {withInput ? (
                 {withInput ? (
                     <>
                     <>
-                    {imagePreview && selectedFile && (
+                        {imagePreview && (
                             <Box mb={1}>
                             <Box mb={1}>
                                 <Chip
                                 <Chip
-                                    label={selectedFile.name}
-                                    avatar={<Avatar alt="Image preview" src={imagePreview}/>}
-                                    onDelete={() => setSelectedFile(null)}
+                                    label={selectedFile?.name}
+                                    avatar={<Avatar alt="Image preview" src={imagePreview} />}
+                                    onDelete={handleImageDelete}
                                     variant="outlined"
                                     variant="outlined"
                                 />
                                 />
                             </Box>
                             </Box>
                         )}
                         )}
-                    <input
+                        <input
                             type="file"
                             type="file"
                             ref={fileInputRef}
                             ref={fileInputRef}
                             style={noDisplayStyle}
                             style={noDisplayStyle}
-                            onChange={(e) => handleFileSelect(e)}
+                            onChange={handleFileSelect}
                             accept="image/*"
                             accept="image/*"
                         />
                         />
 
 
-                    <TextField
-                        margin="dense"
-                        fullWidth
-                        className={getSuffixedClassNames(className, "-input")}
-                        label={`message (${senderId})`}
-                        disabled={!active}
-                        onKeyDown={handleAction}
-                        slotProps={{
-                            input: {
-                                startAdornment: (<InputAdornment position="start">
-                                    <IconButton
-                                            aria-label="upload image"
-                                            onClick={handleAttachClick}
-                                            edge="start"
-                                            disabled={!active}
-                                        >
-                                       <AttachFile color={disableColor("primary", !active)} />
-                                    </IconButton>
-
-                                </InputAdornment>),
-                                endAdornment: (
-                                    <InputAdornment position="end">
-                                        <IconButton
-                                            aria-label="send message"
-                                            onClick={handleClick}
-                                            edge="end"
-                                            disabled={!active}
-                                        >
-                                            <Send color={disableColor("primary", !active)} />
-                                        </IconButton>
-                                    </InputAdornment>
-                                ),
-                            },
-                        }}
-                        sx={inputSx}
-                    />
+                        <TextField
+                            margin="dense"
+                            fullWidth
+                            onChange={onChangeHandler}
+                            className={getSuffixedClassNames(className, "-input")}
+                            label={`message (${senderId})`}
+                            disabled={!active}
+                            onKeyDown={handleAction}
+                            slotProps={{
+                                input: {
+                                    startAdornment: allowSendImages ? (
+                                        <InputAdornment position="start">
+                                            <IconButton
+                                                aria-label="upload image"
+                                                onClick={handleAttachClick}
+                                                edge="start"
+                                                disabled={!active}
+                                            >
+                                                <AttachFile color={disableColor("primary", !active)} />
+                                            </IconButton>
+                                        </InputAdornment>
+                                    ) : undefined,
+                                    endAdornment: (
+                                        <InputAdornment position="end">
+                                            <IconButton
+                                                aria-label="send message"
+                                                onClick={handleClick}
+                                                edge="end"
+                                                disabled={!active || !(enableSend || imagePreview)}
+                                            >
+                                                <Send
+                                                    color={disableColor(
+                                                        "primary",
+                                                        !active || !(enableSend || imagePreview)
+                                                    )}
+                                                />
+                                            </IconButton>
+                                        </InputAdornment>
+                                    ),
+                                },
+                            }}
+                            sx={inputSx}
+                        />
                     </>
                     </>
                 ) : null}
                 ) : null}
                 {props.children}
                 {props.children}

+ 4 - 4
frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx

@@ -189,7 +189,7 @@ describe("FileSelector Component", () => {
         // Check if the alert action has been dispatched
         // Check if the alert action has been dispatched
         expect(mockDispatch).toHaveBeenCalledWith(
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
             expect.objectContaining({
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "success",
                 atype: "success",
                 duration: 3000,
                 duration: 3000,
                 message: "mocked response",
                 message: "mocked response",
@@ -225,7 +225,7 @@ describe("FileSelector Component", () => {
         // Check if the alert action has been dispatched
         // Check if the alert action has been dispatched
         expect(mockDispatch).toHaveBeenCalledWith(
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
             expect.objectContaining({
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "error",
                 atype: "error",
                 duration: 3000,
                 duration: 3000,
                 message: "Upload failed",
                 message: "Upload failed",
@@ -302,7 +302,7 @@ describe("FileSelector Component", () => {
 
 
         // Wait for the upload to complete
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
-        
+
         // Check for input element
         // Check for input element
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         expect(inputElt).toBeInTheDocument();
         expect(inputElt).toBeInTheDocument();
@@ -331,7 +331,7 @@ describe("FileSelector Component", () => {
 
 
         // Wait for the upload to complete
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
-        
+
         // Check for input element
         // Check for input element
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         expect(inputElt).toBeInTheDocument();
         expect(inputElt).toBeInTheDocument();

+ 31 - 13
frontend/taipy-gui/src/components/Taipy/FileSelector.tsx

@@ -22,20 +22,19 @@ import React, {
     useRef,
     useRef,
     useState,
     useState,
 } from "react";
 } from "react";
+import UploadFile from "@mui/icons-material/UploadFile";
 import Button from "@mui/material/Button";
 import Button from "@mui/material/Button";
 import LinearProgress from "@mui/material/LinearProgress";
 import LinearProgress from "@mui/material/LinearProgress";
 import Tooltip from "@mui/material/Tooltip";
 import Tooltip from "@mui/material/Tooltip";
-import UploadFile from "@mui/icons-material/UploadFile";
+import { SxProps } from "@mui/material";
+import { nanoid } from "nanoid";
 
 
 import { TaipyContext } from "../../context/taipyContext";
 import { TaipyContext } from "../../context/taipyContext";
-import { createAlertAction, createSendActionNameAction } from "../../context/taipyReducers";
+import { createNotificationAction, createSendActionNameAction } from "../../context/taipyReducers";
 import { useClassNames, useDynamicProperty, useModule } from "../../utils/hooks";
 import { useClassNames, useDynamicProperty, useModule } from "../../utils/hooks";
-import { expandSx, getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
 import { uploadFile } from "../../workers/fileupload";
 import { uploadFile } from "../../workers/fileupload";
-import { SxProps } from "@mui/material";
 import { getComponentClassName } from "./TaipyStyle";
 import { getComponentClassName } from "./TaipyStyle";
-
-
+import { expandSx, getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
 
 
 interface FileSelectorProps extends TaipyActiveProps {
 interface FileSelectorProps extends TaipyActiveProps {
     onAction?: string;
     onAction?: string;
@@ -75,22 +74,27 @@ const FileSelector = (props: FileSelectorProps) => {
         notify = true,
         notify = true,
         withBorder = true,
         withBorder = true,
     } = props;
     } = props;
-    const directoryProps = ["d", "dir", "directory", "folder"].includes(selectionType?.toLowerCase()) ? 
-                           {webkitdirectory: "", directory: "", mozdirectory: "", nwdirectory: ""} : 
-                           undefined;
     const [dropLabel, setDropLabel] = useState("");
     const [dropLabel, setDropLabel] = useState("");
     const [dropSx, setDropSx] = useState<SxProps | undefined>(defaultSx);
     const [dropSx, setDropSx] = useState<SxProps | undefined>(defaultSx);
     const [upload, setUpload] = useState(false);
     const [upload, setUpload] = useState(false);
     const [progress, setProgress] = useState(0);
     const [progress, setProgress] = useState(0);
     const { state, dispatch } = useContext(TaipyContext);
     const { state, dispatch } = useContext(TaipyContext);
     const butRef = useRef<HTMLElement>(null);
     const butRef = useRef<HTMLElement>(null);
-    const inputId = useMemo(() => (id || `tp-${Date.now()}-${Math.random()}`) + "-upload-file", [id]);
+    const inputId = useMemo(() => (id || `tp-${nanoid()}`) + "-upload-file", [id]);
     const module = useModule();
     const module = useModule();
 
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
 
 
+    const directoryProps = useMemo(
+        () =>
+            selectionType.toLowerCase().startsWith("d") || "folder" == selectionType.toLowerCase()
+                ? { webkitdirectory: "", directory: "", mozdirectory: "", nwdirectory: "" }
+                : undefined,
+        [selectionType]
+    );
+
     useEffect(
     useEffect(
         () =>
         () =>
             setDropSx((sx: SxProps | undefined) =>
             setDropSx((sx: SxProps | undefined) =>
@@ -123,20 +127,34 @@ const FileSelector = (props: FileSelectorProps) => {
                         onAction && dispatch(createSendActionNameAction(id, module, onAction));
                         onAction && dispatch(createSendActionNameAction(id, module, onAction));
                         notify &&
                         notify &&
                             dispatch(
                             dispatch(
-                                createAlertAction({ atype: "success", message: value, system: false, duration: 3000 })
+                                createNotificationAction({
+                                    atype: "success",
+                                    message: value,
+                                    system: false,
+                                    duration: 3000,
+                                })
                             );
                             );
+                        const fileInput = document.getElementById(inputId) as HTMLInputElement;
+                        fileInput && (fileInput.value = "");
                     },
                     },
                     (reason) => {
                     (reason) => {
                         setUpload(false);
                         setUpload(false);
                         notify &&
                         notify &&
                             dispatch(
                             dispatch(
-                                createAlertAction({ atype: "error", message: reason, system: false, duration: 3000 })
+                                createNotificationAction({
+                                    atype: "error",
+                                    message: reason,
+                                    system: false,
+                                    duration: 3000,
+                                })
                             );
                             );
+                        const fileInput = document.getElementById(inputId) as HTMLInputElement;
+                        fileInput && (fileInput.value = "");
                     }
                     }
                 );
                 );
             }
             }
         },
         },
-        [state.id, id, onAction, props.onUploadAction, props.uploadData, notify, updateVarName, dispatch, module]
+        [state.id, id, onAction, props.onUploadAction, props.uploadData, notify, updateVarName, dispatch, module, inputId]
     );
     );
 
 
     const handleChange = useCallback(
     const handleChange = useCallback(

+ 16 - 14
frontend/taipy-gui/src/components/Taipy/Input.spec.tsx

@@ -173,6 +173,19 @@ describe("Input Component", () => {
         const visibilityButton = getByLabelText("toggle password visibility");
         const visibilityButton = getByLabelText("toggle password visibility");
         expect(visibilityButton).toBeInTheDocument();
         expect(visibilityButton).toBeInTheDocument();
     });
     });
+    it("should prevent default action when mouse down event occurs on password visibility button", async () => {
+        const { getByLabelText } = render(<Input value={"Test Input"} type="password" />);
+        const visibilityButton = getByLabelText("toggle password visibility");
+        const keyDown = createEvent.mouseDown(visibilityButton);
+        fireEvent(visibilityButton, keyDown);
+        expect(keyDown.defaultPrevented).toBe(true);
+    });
+    it("parses actionKeys correctly", () => {
+        const { rerender } = render(<Input type="text" value="test" actionKeys="Enter;Escape;F1" />);
+        rerender(<Input type="text" value="test" actionKeys="Enter;F1;F2" />);
+        rerender(<Input type="text" value="test" actionKeys="F1;F2;F3" />);
+        rerender(<Input type="text" value="test" actionKeys="F2;F3;F4" />);
+    });
 });
 });
 
 
 describe("Number Component", () => {
 describe("Number Component", () => {
@@ -195,9 +208,11 @@ describe("Number Component", () => {
         getByDisplayValue("1");
         getByDisplayValue("1");
     });
     });
     it("is disabled", async () => {
     it("is disabled", async () => {
-        const { getByDisplayValue } = render(<Input value={"33"} type="number" active={false} />);
+        const { getByDisplayValue, getByLabelText } = render(<Input value={"33"} type="number" active={false} />);
         const elt = getByDisplayValue("33");
         const elt = getByDisplayValue("33");
         expect(elt).toBeDisabled();
         expect(elt).toBeDisabled();
+        const upSpinner = getByLabelText("Increment value");
+        expect(upSpinner).toBeDisabled();
     });
     });
     it("is enabled by default", async () => {
     it("is enabled by default", async () => {
         const { getByDisplayValue } = render(<Input value={"33"} type="number" />);
         const { getByDisplayValue } = render(<Input value={"33"} type="number" />);
@@ -309,12 +324,6 @@ describe("Number Component", () => {
         await user.keyboard("[ArrowDown]");
         await user.keyboard("[ArrowDown]");
         expect(elt.value).toBe("0");
         expect(elt.value).toBe("0");
     });
     });
-    it("parses actionKeys correctly", () => {
-        const { rerender } = render(<Input type="text" value="test" actionKeys="Enter;Escape;F1" />);
-        rerender(<Input type="text" value="test" actionKeys="Enter;F1;F2" />);
-        rerender(<Input type="text" value="test" actionKeys="F1;F2;F3" />);
-        rerender(<Input type="text" value="test" actionKeys="F2;F3;F4" />);
-    });
     it("it should not decrement below the min value", () => {
     it("it should not decrement below the min value", () => {
         const { getByLabelText } = render(<Input id={"Test Input"} type="number" value="0" min={0} />);
         const { getByLabelText } = render(<Input id={"Test Input"} type="number" value="0" min={0} />);
         const downSpinner = getByLabelText("Decrement value");
         const downSpinner = getByLabelText("Decrement value");
@@ -333,11 +342,4 @@ describe("Number Component", () => {
             expect(inputElement.value).toBe("20");
             expect(inputElement.value).toBe("20");
         });
         });
     });
     });
-    it("should prevent default action when mouse down event occurs on password visibility button", async () => {
-        const { getByLabelText } = render(<Input value={"Test Input"} type="password" />);
-        const visibilityButton = getByLabelText("toggle password visibility");
-        const keyDown = createEvent.mouseDown(visibilityButton);
-        fireEvent(visibilityButton, keyDown);
-        expect(keyDown.defaultPrevented).toBe(true);
-    });
 });
 });

+ 20 - 17
frontend/taipy-gui/src/components/Taipy/Input.tsx

@@ -277,6 +277,7 @@ const Input = (props: TaipyInputProps) => {
                                       aria-label="Increment value"
                                       aria-label="Increment value"
                                       size="small"
                                       size="small"
                                       onMouseDown={handleUpStepperMouseDown}
                                       onMouseDown={handleUpStepperMouseDown}
+                                      disabled={!active}
                                   >
                                   >
                                       <ArrowDropUpIcon fontSize="inherit" />
                                       <ArrowDropUpIcon fontSize="inherit" />
                                   </IconButton>
                                   </IconButton>
@@ -284,6 +285,7 @@ const Input = (props: TaipyInputProps) => {
                                       aria-label="Decrement value"
                                       aria-label="Decrement value"
                                       size="small"
                                       size="small"
                                       onMouseDown={handleDownStepperMouseDown}
                                       onMouseDown={handleDownStepperMouseDown}
+                                      disabled={!active}
                                   >
                                   >
                                       <ArrowDropDownIcon fontSize="inherit" />
                                       <ArrowDropDownIcon fontSize="inherit" />
                                   </IconButton>
                                   </IconButton>
@@ -309,6 +311,7 @@ const Input = (props: TaipyInputProps) => {
                   }
                   }
                 : undefined,
                 : undefined,
         [
         [
+            active,
             type,
             type,
             step,
             step,
             min,
             min,
@@ -330,23 +333,23 @@ const Input = (props: TaipyInputProps) => {
     return (
     return (
         <Tooltip title={hover || ""}>
         <Tooltip title={hover || ""}>
             <>
             <>
-            <TextField
-                sx={textSx}
-                margin="dense"
-                hiddenLabel
-                value={value ?? ""}
-                className={`${className} ${getComponentClassName(props.children)}`}
-                type={showPassword && type == "password" ? "text" : type}
-                id={id}
-                slotProps={inputProps}
-                label={props.label}
-                onChange={handleInput}
-                disabled={!active}
-                onKeyDown={handleAction}
-                multiline={multiline}
-                minRows={linesShown}
-            />
-            {props.children}
+                <TextField
+                    sx={textSx}
+                    margin="dense"
+                    hiddenLabel
+                    value={value ?? ""}
+                    className={`${className} ${getComponentClassName(props.children)}`}
+                    type={showPassword && type == "password" ? "text" : type}
+                    id={id}
+                    slotProps={inputProps}
+                    label={props.label}
+                    onChange={handleInput}
+                    disabled={!active}
+                    onKeyDown={handleAction}
+                    multiline={multiline}
+                    minRows={linesShown}
+                />
+                {props.children}
             </>
             </>
         </Tooltip>
         </Tooltip>
     );
     );

+ 21 - 21
frontend/taipy-gui/src/components/Taipy/Notification.spec.tsx

@@ -16,13 +16,13 @@ import { render, screen, waitFor } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import "@testing-library/jest-dom";
 import { SnackbarProvider } from "notistack";
 import { SnackbarProvider } from "notistack";
 
 
-import Alert from "./Notification";
-import { AlertMessage } from "../../context/taipyReducers";
+import TaipyNotification from "./Notification";
+import { NotificationMessage } from "../../context/taipyReducers";
 import userEvent from "@testing-library/user-event";
 import userEvent from "@testing-library/user-event";
 
 
 const defaultMessage = "message";
 const defaultMessage = "message";
-const defaultAlerts: AlertMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
-const getAlertsWithType = (aType: string) => [{ ...defaultAlerts[0], atype: aType }];
+const defaultNotifications: NotificationMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
+const getNotificationsWithType = (aType: string) => [{ ...defaultNotifications[0], atype: aType }];
 
 
 class myNotification {
 class myNotification {
     static requestPermission = jest.fn(() => Promise.resolve("granted"));
     static requestPermission = jest.fn(() => Promise.resolve("granted"));
@@ -39,7 +39,7 @@ describe("Alert Component", () => {
     it("renders", async () => {
     it("renders", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
+                <TaipyNotification notifications={defaultNotifications} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -48,7 +48,7 @@ describe("Alert Component", () => {
     it("displays a success alert", async () => {
     it("displays a success alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
+                <TaipyNotification notifications={defaultNotifications} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -57,7 +57,7 @@ describe("Alert Component", () => {
     it("displays an error alert", async () => {
     it("displays an error alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("error")} />
+                <TaipyNotification notifications={getNotificationsWithType("error")} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -66,7 +66,7 @@ describe("Alert Component", () => {
     it("displays a warning alert", async () => {
     it("displays a warning alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("warning")} />
+                <TaipyNotification notifications={getNotificationsWithType("warning")} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -75,7 +75,7 @@ describe("Alert Component", () => {
     it("displays an info alert", async () => {
     it("displays an info alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("info")} />
+                <TaipyNotification notifications={getNotificationsWithType("info")} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -86,12 +86,12 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.rel = "icon";
         link.href = "/test-icon.png";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='icon']");
         const linkElement = document.querySelector("link[rel='icon']");
@@ -107,7 +107,7 @@ describe("Alert Component", () => {
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const closeButton = await screen.findByRole("button", { name: /close/i });
         const closeButton = await screen.findByRole("button", { name: /close/i });
@@ -122,14 +122,14 @@ describe("Alert Component", () => {
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         const { rerender } = render(
         const { rerender } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         await screen.findByRole("button", { name: /close/i });
         await screen.findByRole("button", { name: /close/i });
         const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         rerender(
         rerender(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={newAlerts} />
+                <TaipyNotification notifications={newAlerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         await waitFor(() => {
         await waitFor(() => {
@@ -141,7 +141,7 @@ describe("Alert Component", () => {
     it("does nothing when alert is undefined", async () => {
     it("does nothing when alert is undefined", async () => {
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={[]} />
+                <TaipyNotification notifications={[]} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         expect(Notification.requestPermission).not.toHaveBeenCalled();
         expect(Notification.requestPermission).not.toHaveBeenCalled();
@@ -152,12 +152,12 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.rel = "icon";
         link.href = "/test-icon.png";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='icon']");
         const linkElement = document.querySelector("link[rel='icon']");
@@ -169,12 +169,12 @@ describe("Alert Component", () => {
         const link = document.createElement("link");
         const link = document.createElement("link");
         link.rel = "icon";
         link.rel = "icon";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='icon']");
         const linkElement = document.querySelector("link[rel='icon']");
@@ -187,12 +187,12 @@ describe("Alert Component", () => {
         link.rel = "shortcut icon";
         link.rel = "shortcut icon";
         link.href = "/test-shortcut-icon.png";
         link.href = "/test-shortcut-icon.png";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='shortcut icon']");
         const linkElement = document.querySelector("link[rel='shortcut icon']");

+ 21 - 19
frontend/taipy-gui/src/components/Taipy/Notification.tsx

@@ -16,32 +16,32 @@ import { SnackbarKey, useSnackbar, VariantType } from "notistack";
 import IconButton from "@mui/material/IconButton";
 import IconButton from "@mui/material/IconButton";
 import CloseIcon from "@mui/icons-material/Close";
 import CloseIcon from "@mui/icons-material/Close";
 
 
-import { AlertMessage, createDeleteAlertAction } from "../../context/taipyReducers";
+import { NotificationMessage, createDeleteAlertAction } from "../../context/taipyReducers";
 import { useDispatch } from "../../utils/hooks";
 import { useDispatch } from "../../utils/hooks";
 
 
 interface NotificationProps {
 interface NotificationProps {
-    alerts: AlertMessage[];
+    notifications: NotificationMessage[];
 }
 }
 
 
-const TaipyNotification = ({ alerts }: NotificationProps) => {
-    const alert = alerts.length ? alerts[0] : undefined;
+const TaipyNotification = ({ notifications }: NotificationProps) => {
+    const notification = notifications.length ? notifications[0] : undefined;
     const { enqueueSnackbar, closeSnackbar } = useSnackbar();
     const { enqueueSnackbar, closeSnackbar } = useSnackbar();
     const dispatch = useDispatch();
     const dispatch = useDispatch();
 
 
-    const resetAlert = useCallback(
+    const resetNotification = useCallback(
         (key: SnackbarKey) => () => {
         (key: SnackbarKey) => () => {
             closeSnackbar(key);
             closeSnackbar(key);
         },
         },
         [closeSnackbar]
         [closeSnackbar]
     );
     );
 
 
-    const notifAction = useCallback(
+    const notificationAction = useCallback(
         (key: SnackbarKey) => (
         (key: SnackbarKey) => (
-            <IconButton size="small" aria-label="close" color="inherit" onClick={resetAlert(key)}>
+            <IconButton size="small" aria-label="close" color="inherit" onClick={resetNotification(key)}>
                 <CloseIcon fontSize="small" />
                 <CloseIcon fontSize="small" />
             </IconButton>
             </IconButton>
         ),
         ),
-        [resetAlert]
+        [resetNotification]
     );
     );
 
 
     const faviconUrl = useMemo(() => {
     const faviconUrl = useMemo(() => {
@@ -55,25 +55,27 @@ const TaipyNotification = ({ alerts }: NotificationProps) => {
     }, []);
     }, []);
 
 
     useEffect(() => {
     useEffect(() => {
-        if (alert) {
-            const notificationId = alert.notificationId || "";
-            if (alert.atype === "") {
+        if (notification) {
+            const notificationId = notification.notificationId || "";
+            if (notification.atype === "") {
                 closeSnackbar(notificationId);
                 closeSnackbar(notificationId);
             } else {
             } else {
-                enqueueSnackbar(alert.message, {
-                    variant: alert.atype as VariantType,
-                    action: notifAction,
-                    autoHideDuration: alert.duration,
+                enqueueSnackbar(notification.message, {
+                    variant: notification.atype as VariantType,
+                    action: notificationAction,
+                    autoHideDuration: notification.duration,
                     key: notificationId,
                     key: notificationId,
                 });
                 });
-                alert.system && new Notification(document.title || "Taipy", { body: alert.message, icon: faviconUrl });
+                notification.system &&
+                    new Notification(document.title || "Taipy", { body: notification.message, icon: faviconUrl });
             }
             }
             dispatch(createDeleteAlertAction(notificationId));
             dispatch(createDeleteAlertAction(notificationId));
         }
         }
-    }, [alert, enqueueSnackbar, closeSnackbar, notifAction, faviconUrl, dispatch]);
+    }, [notification, enqueueSnackbar, closeSnackbar, notificationAction, faviconUrl, dispatch]);
+
     useEffect(() => {
     useEffect(() => {
-        alert?.system && window.Notification && Notification.requestPermission();
-    }, [alert?.system]);
+        notification?.system && window.Notification && Notification.requestPermission();
+    }, [notification?.system]);
 
 
     return null;
     return null;
 };
 };

+ 7 - 7
frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx

@@ -425,8 +425,8 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
     }, [pageSizeOptions, allowAllRows, pageSize]);
     }, [pageSizeOptions, allowAllRows, pageSize]);
 
 
     const { rows, rowCount, filteredCount, compRows } = useMemo(() => {
     const { rows, rowCount, filteredCount, compRows } = useMemo(() => {
-        const ret = { rows: [], rowCount: 0, filteredCount: 0, compRows: [] } as {
-            rows: RowType[];
+        const ret = { rows: undefined, rowCount: 0, filteredCount: 0, compRows: [] } as {
+            rows?: RowType[];
             rowCount: number;
             rowCount: number;
             filteredCount: number;
             filteredCount: number;
             compRows: RowType[];
             compRows: RowType[];
@@ -454,7 +454,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                 createSendActionNameAction(updateVarName, module, {
                 createSendActionNameAction(updateVarName, module, {
                     action: onEdit,
                     action: onEdit,
                     value: value,
                     value: value,
-                    index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
+                    index: rows ? getRowIndex(rows[rowIndex], rowIndex, startIndex) : startIndex,
                     col: colName,
                     col: colName,
                     user_value: userValue,
                     user_value: userValue,
                     tz: tz,
                     tz: tz,
@@ -469,7 +469,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
             dispatch(
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                 createSendActionNameAction(updateVarName, module, {
                     action: onDelete,
                     action: onDelete,
-                    index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
+                    index: rows ? getRowIndex(rows[rowIndex], rowIndex, startIndex): startIndex,
                     user_data: userData,
                     user_data: userData,
                 })
                 })
             ),
             ),
@@ -481,7 +481,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
             dispatch(
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                 createSendActionNameAction(updateVarName, module, {
                     action: onAction,
                     action: onAction,
-                    index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
+                    index: rows ? getRowIndex(rows[rowIndex], rowIndex, startIndex): startIndex,
                     col: colName === undefined ? null : colName,
                     col: colName === undefined ? null : colName,
                     value,
                     value,
                     reason: value === undefined ? "click" : "button",
                     reason: value === undefined ? "click" : "button",
@@ -614,7 +614,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                 </TableRow>
                                 </TableRow>
                             </TableHead>
                             </TableHead>
                             <TableBody>
                             <TableBody>
-                                {rows.map((row, index) => {
+                                {rows?.map((row, index) => {
                                     const sel = selected.indexOf(index + startIndex);
                                     const sel = selected.indexOf(index + startIndex);
                                     if (sel == 0) {
                                     if (sel == 0) {
                                         Promise.resolve().then(
                                         Promise.resolve().then(
@@ -664,7 +664,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                         </TableRow>
                                         </TableRow>
                                     );
                                     );
                                 })}
                                 })}
-                                {rows.length == 0 &&
+                                {!rows &&
                                     loading &&
                                     loading &&
                                     Array.from(Array(30).keys(), (v, idx) => (
                                     Array.from(Array(30).keys(), (v, idx) => (
                                         <TableRow hover key={"rowSkel" + idx}>
                                         <TableRow hover key={"rowSkel" + idx}>

+ 6 - 1
frontend/taipy-gui/src/components/Taipy/Selector.tsx

@@ -125,7 +125,12 @@ const renderBoxSx = {
     width: "100%",
     width: "100%",
 } as CSSProperties;
 } as CSSProperties;
 
 
-const Selector = (props: SelTreeProps) => {
+interface SelectorProps extends SelTreeProps {
+    dropdown?: boolean;
+    mode?: string;
+}
+
+const Selector = (props: SelectorProps) => {
     const {
     const {
         id,
         id,
         defaultValue = "",
         defaultValue = "",

+ 0 - 2
frontend/taipy-gui/src/components/Taipy/lovUtils.tsx

@@ -30,8 +30,6 @@ export interface SelTreeProps extends LovProps, TaipyLabelProps {
     filter?: boolean;
     filter?: boolean;
     multiple?: boolean;
     multiple?: boolean;
     width?: string | number;
     width?: string | number;
-    dropdown?: boolean;
-    mode?: string;
 }
 }
 
 
 export interface LovProps<T = string | string[], U = string> extends TaipyActiveProps, TaipyChangeProps {
 export interface LovProps<T = string | string[], U = string> extends TaipyActiveProps, TaipyChangeProps {

+ 95 - 54
frontend/taipy-gui/src/context/taipyReducers.spec.ts

@@ -14,10 +14,10 @@
 import "@testing-library/jest-dom";
 import "@testing-library/jest-dom";
 import {
 import {
     addRows,
     addRows,
-    AlertMessage,
+    NotificationMessage,
     BlockMessage,
     BlockMessage,
     createAckAction,
     createAckAction,
-    createAlertAction,
+    createNotificationAction,
     createBlockAction,
     createBlockAction,
     createDownloadAction,
     createDownloadAction,
     createIdAction,
     createIdAction,
@@ -43,6 +43,7 @@ import {
     TaipyBaseAction,
     TaipyBaseAction,
     taipyReducer,
     taipyReducer,
     Types,
     Types,
+    TaipyState,
 } from "./taipyReducers";
 } from "./taipyReducers";
 import { WsMessage } from "./wsUtils";
 import { WsMessage } from "./wsUtils";
 import { changeFavicon, getLocalStorageValue, IdMessage } from "./utils";
 import { changeFavicon, getLocalStorageValue, IdMessage } from "./utils";
@@ -50,7 +51,7 @@ import { Socket } from "socket.io-client";
 import { Dispatch } from "react";
 import { Dispatch } from "react";
 import { parseData } from "../utils/dataFormat";
 import { parseData } from "../utils/dataFormat";
 import * as wsUtils from "./wsUtils";
 import * as wsUtils from "./wsUtils";
-import { nanoid } from 'nanoid';
+import { nanoid } from "nanoid";
 
 
 jest.mock("./utils", () => ({
 jest.mock("./utils", () => ({
     ...jest.requireActual("./utils"),
     ...jest.requireActual("./utils"),
@@ -85,14 +86,14 @@ describe("reducer", () => {
             } as TaipyBaseAction).locations
             } as TaipyBaseAction).locations
         ).toBeDefined();
         ).toBeDefined();
     });
     });
-    it("set alert", async () => {
+    it("set notification", async () => {
         expect(
         expect(
             taipyReducer({ ...INITIAL_STATE }, {
             taipyReducer({ ...INITIAL_STATE }, {
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "i",
                 atype: "i",
                 message: "message",
                 message: "message",
                 system: "system",
                 system: "system",
-            } as TaipyBaseAction).alerts
+            } as TaipyBaseAction).notifications
         ).toHaveLength(1);
         ).toHaveLength(1);
     });
     });
     it("set show block", async () => {
     it("set show block", async () => {
@@ -201,12 +202,20 @@ describe("reducer", () => {
             } as TaipyBaseAction).data.partial
             } as TaipyBaseAction).data.partial
         ).toBeUndefined();
         ).toBeUndefined();
     });
     });
-    it("creates an alert action", () => {
-        expect(createAlertAction({ atype: "I", message: "message" } as AlertMessage).type).toBe("SET_ALERT");
-        expect(createAlertAction({ atype: "err", message: "message" } as AlertMessage).atype).toBe("error");
-        expect(createAlertAction({ atype: "Wa", message: "message" } as AlertMessage).atype).toBe("warning");
-        expect(createAlertAction({ atype: "sUc", message: "message" } as AlertMessage).atype).toBe("success");
-        expect(createAlertAction({ atype: "  ", message: "message" } as AlertMessage).atype).toBe("");
+    it("creates a notification action", () => {
+        expect(createNotificationAction({ atype: "I", message: "message" } as NotificationMessage).type).toBe(
+            "SET_NOTIFICATION"
+        );
+        expect(createNotificationAction({ atype: "err", message: "message" } as NotificationMessage).atype).toBe(
+            "error"
+        );
+        expect(createNotificationAction({ atype: "Wa", message: "message" } as NotificationMessage).atype).toBe(
+            "warning"
+        );
+        expect(createNotificationAction({ atype: "sUc", message: "message" } as NotificationMessage).atype).toBe(
+            "success"
+        );
+        expect(createNotificationAction({ atype: "  ", message: "message" } as NotificationMessage).atype).toBe("");
     });
     });
 });
 });
 
 
@@ -543,7 +552,7 @@ describe("createSendUpdateAction function", () => {
 describe("taipyReducer function", () => {
 describe("taipyReducer function", () => {
     it("should not change state for SOCKET_CONNECTED action if isSocketConnected is already true", () => {
     it("should not change state for SOCKET_CONNECTED action if isSocketConnected is already true", () => {
         const action = { type: Types.SocketConnected };
         const action = { type: Types.SocketConnected };
-        const initialState = { ...INITIAL_STATE, isSocketConnected: true };
+        const initialState: TaipyState = { ...INITIAL_STATE, isSocketConnected: true };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         const expectedState = { ...initialState, isSocketConnected: true };
         const expectedState = { ...initialState, isSocketConnected: true };
         expect(newState).toEqual(expectedState);
         expect(newState).toEqual(expectedState);
@@ -569,9 +578,9 @@ describe("taipyReducer function", () => {
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         expect(newState.locations).toEqual(action.payload.value);
         expect(newState.locations).toEqual(action.payload.value);
     });
     });
-    it("should handle SET_ALERT action", () => {
+    it("should handle SET_NOTIFICATION action", () => {
         const action = {
         const action = {
-            type: Types.SetAlert,
+            type: Types.SetNotification,
             atype: "error",
             atype: "error",
             message: "some error message",
             message: "some error message",
             system: true,
             system: true,
@@ -579,7 +588,7 @@ describe("taipyReducer function", () => {
             notificationId: nanoid(),
             notificationId: nanoid(),
         };
         };
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
-        expect(newState.alerts).toContainEqual({
+        expect(newState.notifications).toContainEqual({
             atype: action.atype,
             atype: action.atype,
             message: action.message,
             message: action.message,
             system: action.system,
             system: action.system,
@@ -587,57 +596,89 @@ describe("taipyReducer function", () => {
             notificationId: action.notificationId,
             notificationId: action.notificationId,
         });
         });
     });
     });
-    it("should handle DELETE_ALERT action", () => {
+    it("should handle DELETE_NOTIFICATION action", () => {
         const notificationId1 = "id-1234";
         const notificationId1 = "id-1234";
         const notificationId2 = "id-5678";
         const notificationId2 = "id-5678";
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
-            alerts: [
-                { atype: "error", message: "First Alert", system: true, duration: 5000, notificationId: notificationId1 },
-                { atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 },
+            notifications: [
+                {
+                    atype: "error",
+                    message: "First Notification",
+                    system: true,
+                    duration: 5000,
+                    notificationId: notificationId1,
+                },
+                {
+                    atype: "warning",
+                    message: "Second Notification",
+                    system: false,
+                    duration: 3000,
+                    notificationId: notificationId2,
+                },
             ],
             ],
         };
         };
-        const action = { type: Types.DeleteAlert, notificationId: notificationId1 };
+        const action = { type: Types.DeleteNotification, notificationId: notificationId1 };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
-        expect(newState.alerts).toEqual([{ atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 }]);
+        expect(newState.notifications).toEqual([
+            {
+                atype: "warning",
+                message: "Second Notification",
+                system: false,
+                duration: 3000,
+                notificationId: notificationId2,
+            },
+        ]);
     });
     });
-    it('should not modify state if DELETE_ALERT does not match any notificationId', () => {
+    it("should not modify state if DELETE_NOTIFICATION does not match any notificationId", () => {
         const notificationId1 = "id-1234";
         const notificationId1 = "id-1234";
         const notificationId2 = "id-5678";
         const notificationId2 = "id-5678";
         const nonExistentId = "000000";
         const nonExistentId = "000000";
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
-            alerts: [
-                { atype: "error", message: "First Alert", system: true, duration: 5000, notificationId: notificationId1 },
-                { atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 },
+            notifications: [
+                {
+                    atype: "error",
+                    message: "First Notification",
+                    system: true,
+                    duration: 5000,
+                    notificationId: notificationId1,
+                },
+                {
+                    atype: "warning",
+                    message: "Second Notification",
+                    system: false,
+                    duration: 3000,
+                    notificationId: notificationId2,
+                },
             ],
             ],
         };
         };
-        const action = { type: Types.DeleteAlert, notificationId: nonExistentId };
+        const action = { type: Types.DeleteNotification, notificationId: nonExistentId };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
         expect(newState).toEqual(initialState);
     });
     });
-    it("should not modify state if no alerts are present", () => {
-        const initialState = { ...INITIAL_STATE, alerts: [] };
-        const action = { type: Types.DeleteAlert };
+    it("should not modify state if no notification are present", () => {
+        const initialState: TaipyState = { ...INITIAL_STATE, notifications: [] };
+        const action = { type: Types.DeleteNotification };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
         expect(newState).toEqual(initialState);
     });
     });
-    it("should handle DELETE_ALERT action even when no notificationId is passed", () => {
+    it("should handle DELETE_NOTIFICATION action even when no notificationId is passed", () => {
         const notificationId1 = "id-1234";
         const notificationId1 = "id-1234";
         const notificationId2 = "id-5678";
         const notificationId2 = "id-5678";
 
 
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
-            alerts: [
+            notifications: [
                 {
                 {
-                    message: "alert1",
+                    message: "Notification1",
                     atype: "type1",
                     atype: "type1",
                     system: true,
                     system: true,
                     duration: 5000,
                     duration: 5000,
                     notificationId: notificationId1,
                     notificationId: notificationId1,
                 },
                 },
                 {
                 {
-                    message: "alert2",
+                    message: "Notification2",
                     atype: "type2",
                     atype: "type2",
                     system: false,
                     system: false,
                     duration: 3000,
                     duration: 3000,
@@ -645,11 +686,11 @@ describe("taipyReducer function", () => {
                 },
                 },
             ],
             ],
         };
         };
-        const action = { type: Types.DeleteAlert, notificationId: notificationId1 };
+        const action = { type: Types.DeleteNotification, notificationId: notificationId1 };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
-        expect(newState.alerts).toEqual([
+        expect(newState.notifications).toEqual([
             {
             {
-                message: "alert2",
+                message: "Notification2",
                 atype: "type2",
                 atype: "type2",
                 system: false,
                 system: false,
                 duration: 3000,
                 duration: 3000,
@@ -658,7 +699,7 @@ describe("taipyReducer function", () => {
         ]);
         ]);
     });
     });
     it("should handle SET_BLOCK action", () => {
     it("should handle SET_BLOCK action", () => {
-        const initialState = { ...INITIAL_STATE, block: undefined };
+        const initialState: TaipyState = { ...INITIAL_STATE, block: undefined };
         const action = {
         const action = {
             type: Types.SetBlock,
             type: Types.SetBlock,
             noCancel: false,
             noCancel: false,
@@ -675,7 +716,7 @@ describe("taipyReducer function", () => {
         });
         });
     });
     });
     it("should handle NAVIGATE action", () => {
     it("should handle NAVIGATE action", () => {
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
             navigateTo: undefined,
             navigateTo: undefined,
             navigateParams: undefined,
             navigateParams: undefined,
@@ -696,31 +737,31 @@ describe("taipyReducer function", () => {
         expect(newState.navigateForce).toEqual(true);
         expect(newState.navigateForce).toEqual(true);
     });
     });
     it("should handle CLIENT_ID action", () => {
     it("should handle CLIENT_ID action", () => {
-        const initialState = { ...INITIAL_STATE, id: "oldId" };
+        const initialState: TaipyState = { ...INITIAL_STATE, id: "oldId" };
         const action = { type: Types.ClientId, id: "newId" };
         const action = { type: Types.ClientId, id: "newId" };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.id).toEqual("newId");
         expect(newState.id).toEqual("newId");
     });
     });
     it("should handle ACKNOWLEDGEMENT action", () => {
     it("should handle ACKNOWLEDGEMENT action", () => {
-        const initialState = { ...INITIAL_STATE, ackList: ["ack1", "ack2"] };
+        const initialState: TaipyState = { ...INITIAL_STATE, ackList: ["ack1", "ack2"] };
         const action = { type: Types.Acknowledgement, id: "ack1" };
         const action = { type: Types.Acknowledgement, id: "ack1" };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.ackList).toEqual(["ack2"]);
         expect(newState.ackList).toEqual(["ack2"]);
     });
     });
     it("should handle SET_MENU action", () => {
     it("should handle SET_MENU action", () => {
-        const initialState = { ...INITIAL_STATE, menu: {} };
+        const initialState: TaipyState = { ...INITIAL_STATE, menu: {} };
         const action = { type: Types.SetMenu, menu: { menu1: "item1", menu2: "item2" } };
         const action = { type: Types.SetMenu, menu: { menu1: "item1", menu2: "item2" } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.menu).toEqual({ menu1: "item1", menu2: "item2" });
         expect(newState.menu).toEqual({ menu1: "item1", menu2: "item2" });
     });
     });
     it("should handle DOWNLOAD_FILE action", () => {
     it("should handle DOWNLOAD_FILE action", () => {
-        const initialState = { ...INITIAL_STATE, download: undefined };
+        const initialState: TaipyState = { ...INITIAL_STATE, download: undefined };
         const action = { type: Types.DownloadFile, content: "fileContent", name: "fileName", onAction: "fileAction" };
         const action = { type: Types.DownloadFile, content: "fileContent", name: "fileName", onAction: "fileAction" };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.download).toEqual({ content: "fileContent", name: "fileName", onAction: "fileAction" });
         expect(newState.download).toEqual({ content: "fileContent", name: "fileName", onAction: "fileAction" });
     });
     });
     it("should handle PARTIAL action", () => {
     it("should handle PARTIAL action", () => {
-        const initialState = { ...INITIAL_STATE, data: { test: false } };
+        const initialState: TaipyState = { ...INITIAL_STATE, data: { test: false } };
         const actionCreate = {
         const actionCreate = {
             type: Types.Partial,
             type: Types.Partial,
             name: "test",
             name: "test",
@@ -738,7 +779,7 @@ describe("taipyReducer function", () => {
         expect(newState.data.test).toBeUndefined();
         expect(newState.data.test).toBeUndefined();
     });
     });
     it("should handle MULTIPLE_UPDATE action", () => {
     it("should handle MULTIPLE_UPDATE action", () => {
-        const initialState = { ...INITIAL_STATE, data: { test1: false, test2: false } };
+        const initialState: TaipyState = { ...INITIAL_STATE, data: { test1: false, test2: false } };
         const action = {
         const action = {
             type: Types.MultipleUpdate,
             type: Types.MultipleUpdate,
             payload: [
             payload: [
@@ -757,13 +798,13 @@ describe("taipyReducer function", () => {
         expect(newState.data.test2).toEqual(true);
         expect(newState.data.test2).toEqual(true);
     });
     });
     it("should handle SetTimeZone action with fromBackend true", () => {
     it("should handle SetTimeZone action with fromBackend true", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: true } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: true } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.timeZone).toEqual("newTimeZone");
         expect(newState.timeZone).toEqual("newTimeZone");
     });
     });
     it("should handle SetTimeZone action with fromBackend false and localStorage value", () => {
     it("should handle SetTimeZone action with fromBackend false and localStorage value", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const localStorageTimeZone = "localStorageTimeZone";
         const localStorageTimeZone = "localStorageTimeZone";
         localStorage.setItem("timeZone", localStorageTimeZone);
         localStorage.setItem("timeZone", localStorageTimeZone);
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
@@ -772,13 +813,13 @@ describe("taipyReducer function", () => {
         localStorage.removeItem("timeZone");
         localStorage.removeItem("timeZone");
     });
     });
     it("should handle SetTimeZone action with fromBackend false and no localStorage value", () => {
     it("should handle SetTimeZone action with fromBackend false and no localStorage value", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.timeZone).toEqual("UTC");
         expect(newState.timeZone).toEqual("UTC");
     });
     });
     it("should handle SetTimeZone action with no change in timeZone", () => {
     it("should handle SetTimeZone action with no change in timeZone", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "oldTimeZone", fromBackend: true } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "oldTimeZone", fromBackend: true } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
         expect(newState).toEqual(initialState);
@@ -888,7 +929,7 @@ describe("messageToAction function", () => {
         expect(result).toEqual(expected);
         expect(result).toEqual(expected);
     });
     });
     it('should call createAlertAction if message type is "AL"', () => {
     it('should call createAlertAction if message type is "AL"', () => {
-        const message: WsMessage & Partial<AlertMessage> = {
+        const message: WsMessage & Partial<NotificationMessage> = {
             type: "AL",
             type: "AL",
             atype: "I",
             atype: "I",
             name: "someName",
             name: "someName",
@@ -899,7 +940,7 @@ describe("messageToAction function", () => {
             ack_id: "someAckId",
             ack_id: "someAckId",
         };
         };
         const result = messageToAction(message);
         const result = messageToAction(message);
-        const expectedResult = createAlertAction(message as unknown as AlertMessage);
+        const expectedResult = createNotificationAction(message as unknown as NotificationMessage);
         expect(result).toEqual(expectedResult);
         expect(result).toEqual(expectedResult);
     });
     });
     it('should call createBlockAction if message type is "BL"', () => {
     it('should call createBlockAction if message type is "BL"', () => {

+ 28 - 28
frontend/taipy-gui/src/context/taipyReducers.ts

@@ -37,8 +37,8 @@ export enum Types {
     SetLocations = "SET_LOCATIONS",
     SetLocations = "SET_LOCATIONS",
     SetTheme = "SET_THEME",
     SetTheme = "SET_THEME",
     SetTimeZone = "SET_TIMEZONE",
     SetTimeZone = "SET_TIMEZONE",
-    SetAlert = "SET_ALERT",
-    DeleteAlert = "DELETE_ALERT",
+    SetNotification = "SET_NOTIFICATION",
+    DeleteNotification = "DELETE_NOTIFICATION",
     SetBlock = "SET_BLOCK",
     SetBlock = "SET_BLOCK",
     Navigate = "NAVIGATE",
     Navigate = "NAVIGATE",
     ClientId = "CLIENT_ID",
     ClientId = "CLIENT_ID",
@@ -63,7 +63,7 @@ export interface TaipyState {
     dateFormat?: string;
     dateFormat?: string;
     dateTimeFormat?: string;
     dateTimeFormat?: string;
     numberFormat?: string;
     numberFormat?: string;
-    alerts: AlertMessage[];
+    notifications: NotificationMessage[];
     block?: BlockMessage;
     block?: BlockMessage;
     navigateTo?: string;
     navigateTo?: string;
     navigateParams?: Record<string, string>;
     navigateParams?: Record<string, string>;
@@ -87,7 +87,7 @@ export interface NamePayload {
     payload: Record<string, unknown>;
     payload: Record<string, unknown>;
 }
 }
 
 
-export interface AlertMessage {
+export interface NotificationMessage {
     atype: string;
     atype: string;
     message: string;
     message: string;
     system: boolean;
     system: boolean;
@@ -108,7 +108,7 @@ interface TaipyMultipleMessageAction extends TaipyBaseAction {
     actions: TaipyBaseAction[];
     actions: TaipyBaseAction[];
 }
 }
 
 
-interface TaipyAlertAction extends TaipyBaseAction, AlertMessage {}
+interface TaipyNotificationAction extends TaipyBaseAction, NotificationMessage {}
 
 
 interface TaipyDeleteAlertAction extends TaipyBaseAction {
 interface TaipyDeleteAlertAction extends TaipyBaseAction {
     notificationId: string;
     notificationId: string;
@@ -201,7 +201,7 @@ export const INITIAL_STATE: TaipyState = {
     id: getLocalStorageValue(TAIPY_CLIENT_ID, ""),
     id: getLocalStorageValue(TAIPY_CLIENT_ID, ""),
     menu: {},
     menu: {},
     ackList: [],
     ackList: [],
-    alerts: [],
+    notifications: [],
 };
 };
 
 
 export const taipyInitialize = (initialState: TaipyState): TaipyState => ({
 export const taipyInitialize = (initialState: TaipyState): TaipyState => ({
@@ -217,7 +217,7 @@ export const messageToAction = (message: WsMessage) => {
         } else if (message.type === "U") {
         } else if (message.type === "U") {
             return createUpdateAction(message as unknown as NamePayload);
             return createUpdateAction(message as unknown as NamePayload);
         } else if (message.type === "AL") {
         } else if (message.type === "AL") {
-            return createAlertAction(message as unknown as AlertMessage);
+            return createNotificationAction(message as unknown as NotificationMessage);
         } else if (message.type === "BL") {
         } else if (message.type === "BL") {
             return createBlockAction(message as unknown as BlockMessage);
             return createBlockAction(message as unknown as BlockMessage);
         } else if (message.type === "NA") {
         } else if (message.type === "NA") {
@@ -374,26 +374,26 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta
             };
             };
         case Types.SetLocations:
         case Types.SetLocations:
             return { ...state, locations: action.payload.value as Record<string, string> };
             return { ...state, locations: action.payload.value as Record<string, string> };
-        case Types.SetAlert:
-            const alertAction = action as unknown as TaipyAlertAction;
+        case Types.SetNotification:
+            const notificationAction = action as unknown as TaipyNotificationAction;
             return {
             return {
                 ...state,
                 ...state,
-                alerts: [
-                    ...state.alerts,
+                notifications: [
+                    ...state.notifications,
                     {
                     {
-                        atype: alertAction.atype,
-                        message: alertAction.message,
-                        system: alertAction.system,
-                        duration: alertAction.duration,
-                        notificationId: alertAction.notificationId || nanoid(),
+                        atype: notificationAction.atype,
+                        message: notificationAction.message,
+                        system: notificationAction.system,
+                        duration: notificationAction.duration,
+                        notificationId: notificationAction.notificationId || nanoid(),
                     },
                     },
                 ],
                 ],
             };
             };
-        case Types.DeleteAlert:
-            const deleteAlertAction = action as unknown as TaipyAlertAction;
+        case Types.DeleteNotification:
+            const deleteNotificationAction = action as unknown as TaipyNotificationAction;
             return {
             return {
                 ...state,
                 ...state,
-                alerts: state.alerts.filter(alert => alert.notificationId !== deleteAlertAction.notificationId),
+                notifications: state.notifications.filter(notification => notification.notificationId !== deleteNotificationAction.notificationId),
             };
             };
         case Types.SetBlock:
         case Types.SetBlock:
             const blockAction = action as unknown as TaipyBlockAction;
             const blockAction = action as unknown as TaipyBlockAction;
@@ -802,7 +802,7 @@ export const createTimeZoneAction = (timeZone: string, fromBackend = false): Tai
     payload: { timeZone: timeZone, fromBackend: fromBackend },
     payload: { timeZone: timeZone, fromBackend: fromBackend },
 });
 });
 
 
-const getAlertType = (aType: string) => {
+const getNotificationType = (aType: string) => {
     aType = aType.trim();
     aType = aType.trim();
     if (aType) {
     if (aType) {
         aType = aType.charAt(0).toLowerCase();
         aType = aType.charAt(0).toLowerCase();
@@ -820,17 +820,17 @@ const getAlertType = (aType: string) => {
     return aType;
     return aType;
 };
 };
 
 
-export const createAlertAction = (alert: AlertMessage): TaipyAlertAction => ({
-    type: Types.SetAlert,
-    atype: getAlertType(alert.atype),
-    message: alert.message,
-    system: alert.system,
-    duration: alert.duration,
-    notificationId: alert.notificationId,
+export const createNotificationAction = (notification: NotificationMessage): TaipyNotificationAction => ({
+    type: Types.SetNotification,
+    atype: getNotificationType(notification.atype),
+    message: notification.message,
+    system: notification.system,
+    duration: notification.duration,
+    notificationId: notification.notificationId,
 });
 });
 
 
 export const createDeleteAlertAction = (notificationId: string): TaipyDeleteAlertAction => ({
 export const createDeleteAlertAction = (notificationId: string): TaipyDeleteAlertAction => ({
-    type: Types.DeleteAlert,
+    type: Types.DeleteNotification,
     notificationId,
     notificationId,
 });
 });
 
 

+ 12 - 0
frontend/taipy-gui/src/themes/darkThemeTemplate.ts

@@ -1,3 +1,15 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
 export const darkThemeTemplate = {
 export const darkThemeTemplate = {
     data: {
     data: {
         barpolar: [
         barpolar: [

+ 30 - 0
frontend/taipy-gui/src/utils/image.ts

@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+export const toDataUrl = (url: string | null) =>
+    new Promise((resolve, reject) => {
+        if (!url) {
+            resolve(null);
+        }
+        const xhr = new XMLHttpRequest();
+        xhr.onload = () => {
+            const reader = new FileReader();
+            reader.onloadend = () => resolve(reader.result);
+            reader.onerror = () => reject(reader.error);
+            reader.readAsDataURL(xhr.response);
+        };
+        xhr.onerror = reject;
+        xhr.open("GET", url || "");
+        xhr.responseType = "blob";
+        xhr.send();
+    });

+ 2 - 2
taipy/common/config/config.pyi

@@ -282,7 +282,7 @@ class Config:
                 corresponds to the data node configuration id. During the scenarios'
                 corresponds to the data node configuration id. During the scenarios'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 comparison, each comparator is applied to all the data nodes instantiated from
                 the data node configuration attached to the comparator. See
                 the data node configuration attached to the comparator. See
-                `(taipy.)compare_scenarios()^` more more details.
+                `(taipy.)compare_scenarios()^` more details.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequence descriptions.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequence descriptions.
                 The default value is None.
                 The default value is None.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
@@ -321,7 +321,7 @@ class Config:
                 corresponds to the data node configuration id. During the scenarios'
                 corresponds to the data node configuration id. During the scenarios'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 comparison, each comparator is applied to all the data nodes instantiated from
                 the data node configuration attached to the comparator. See
                 the data node configuration attached to the comparator. See
-                `taipy.compare_scenarios()^` more more details.
+                `taipy.compare_scenarios()^` more details.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequences. The default value is None.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequences. The default value is None.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
 
 

+ 1 - 1
taipy/core/_entity/_entity.py

@@ -19,7 +19,7 @@ class _Entity:
     _ID_PREFIX: str
     _ID_PREFIX: str
     _MANAGER_NAME: str
     _MANAGER_NAME: str
     _is_in_context = False
     _is_in_context = False
-    _in_context_attributes_changed_collector: List
+    _in_context_attributes_changed_collector: List = []
 
 
     def __enter__(self):
     def __enter__(self):
         self._is_in_context = True
         self._is_in_context = True

+ 3 - 0
taipy/core/_entity/_properties.py

@@ -13,6 +13,7 @@ from collections import UserDict
 
 
 from taipy.common.config.common._template_handler import _TemplateHandler as _tpl
 from taipy.common.config.common._template_handler import _TemplateHandler as _tpl
 
 
+from ..common._utils import _normalize_path
 from ..notification import EventOperation, Notifier, _make_event
 from ..notification import EventOperation, Notifier, _make_event
 
 
 
 
@@ -26,6 +27,8 @@ class _Properties(UserDict):
         self._pending_deletions = set()
         self._pending_deletions = set()
 
 
     def __setitem__(self, key, value):
     def __setitem__(self, key, value):
+        if key == "path":
+            value = _normalize_path(value)
         super(_Properties, self).__setitem__(key, value)
         super(_Properties, self).__setitem__(key, value)
 
 
         if hasattr(self, "_entity_owner"):
         if hasattr(self, "_entity_owner"):

+ 5 - 0
taipy/core/common/_utils.py

@@ -10,6 +10,7 @@
 # specific language governing permissions and limitations under the License.
 # specific language governing permissions and limitations under the License.
 
 
 import functools
 import functools
+import re
 import time
 import time
 from collections import namedtuple
 from collections import namedtuple
 from importlib import import_module
 from importlib import import_module
@@ -79,4 +80,8 @@ def _fcts_to_dict(objs):
     return [d for obj in objs if (d := _fct_to_dict(obj)) is not None]
     return [d for obj in objs if (d := _fct_to_dict(obj)) is not None]
 
 
 
 
+def _normalize_path(path: str) -> str:
+    return re.sub(r"[\\]+", "/", path)
+
+
 _Subscriber = namedtuple("_Subscriber", "callback params")
 _Subscriber = namedtuple("_Subscriber", "callback params")

+ 3 - 0
taipy/core/config/data_node_config.py

@@ -24,6 +24,7 @@ from taipy.common.config.common._template_handler import _TemplateHandler as _tp
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.section import Section
 from taipy.common.config.section import Section
 
 
+from ..common._utils import _normalize_path
 from ..common._warnings import _warn_deprecated
 from ..common._warnings import _warn_deprecated
 from ..common.mongo_default_document import MongoDefaultDocument
 from ..common.mongo_default_document import MongoDefaultDocument
 
 
@@ -284,6 +285,8 @@ class DataNodeConfig(Section):
         self._storage_type = storage_type
         self._storage_type = storage_type
         self._scope = scope
         self._scope = scope
         self._validity_period = validity_period
         self._validity_period = validity_period
+        if "path" in properties:
+            properties["path"] = _normalize_path(properties["path"])
         super().__init__(id, **properties)
         super().__init__(id, **properties)
 
 
         # modin exposed type is deprecated since taipy 3.1.0
         # modin exposed type is deprecated since taipy 3.1.0

+ 63 - 3
taipy/core/config/scenario_config.py

@@ -33,7 +33,6 @@ class ScenarioConfig(Section):
     _TASKS_KEY = "tasks"
     _TASKS_KEY = "tasks"
     _ADDITIONAL_DATA_NODES_KEY = "additional_data_nodes"
     _ADDITIONAL_DATA_NODES_KEY = "additional_data_nodes"
     _FREQUENCY_KEY = "frequency"
     _FREQUENCY_KEY = "frequency"
-    _SEQUENCES_KEY = "sequences"
     _COMPARATOR_KEY = "comparators"
     _COMPARATOR_KEY = "comparators"
 
 
     frequency: Optional[Frequency]
     frequency: Optional[Frequency]
@@ -305,7 +304,7 @@ class ScenarioConfig(Section):
                 corresponds to the data node configuration id. During the scenarios'
                 corresponds to the data node configuration id. During the scenarios'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 comparison, each comparator is applied to all the data nodes instantiated from
                 the data node configuration attached to the comparator. See
                 the data node configuration attached to the comparator. See
-                `(taipy.)compare_scenarios()^` more more details.
+                `(taipy.)compare_scenarios()^` more details.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequence descriptions.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequence descriptions.
                 The default value is None.
                 The default value is None.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
@@ -355,7 +354,7 @@ class ScenarioConfig(Section):
                 corresponds to the data node configuration id. During the scenarios'
                 corresponds to the data node configuration id. During the scenarios'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 comparison, each comparator is applied to all the data nodes instantiated from
                 the data node configuration attached to the comparator. See
                 the data node configuration attached to the comparator. See
-                `taipy.compare_scenarios()^` more more details.
+                `taipy.compare_scenarios()^` more details.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequences. The default value is None.
             sequences (Optional[Dict[str, List[TaskConfig]]]): Dictionary of sequences. The default value is None.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
 
 
@@ -373,3 +372,64 @@ class ScenarioConfig(Section):
         )
         )
         Config._register(section)
         Config._register(section)
         return Config.sections[ScenarioConfig.name][_Config.DEFAULT_KEY]
         return Config.sections[ScenarioConfig.name][_Config.DEFAULT_KEY]
+
+    def draw(self, file_path: Optional[str]=None) -> None:
+        """
+        Export the scenario configuration graph as a PNG file.
+
+        This function uses the `matplotlib` library to draw the scenario configuration graph.
+        `matplotlib` must be installed independently of `taipy` as it is not a dependency.
+        If `matplotlib` is not installed, the function will log an error message, and do nothing.
+
+        Arguments:
+            file_path (Optional[str]): The path to save the PNG file.
+                If not provided, the file will be saved with the scenario configuration id.
+        """
+        from importlib import util
+
+        from taipy.common.logger._taipy_logger import _TaipyLogger
+        logger = _TaipyLogger._get_logger()
+
+        if not util.find_spec("matplotlib"):
+            logger.error("Cannot draw the scenario configuration as `matplotlib` is not installed.")
+            return
+        import matplotlib.pyplot as plt
+        import networkx as nx
+
+        from taipy.core._entity._dag import _DAG
+
+        def build_dag() -> nx.DiGraph:
+            g = nx.DiGraph()
+            for task in set(self.tasks):
+                if has_input := task.inputs:
+                    for predecessor in task.inputs:
+                        g.add_edges_from([(predecessor, task)])
+                if has_output := task.outputs:
+                    for successor in task.outputs:
+                        g.add_edges_from([(task, successor)])
+                if not has_input and not has_output:
+                    g.add_node(task)
+            return g
+        graph = build_dag()
+        dag = _DAG(graph)
+        pos = {node.entity: (node.x, node.y) for node in dag.nodes.values()}
+        labls = {node.entity: node.entity.id for node in dag.nodes.values()}
+
+        # Draw the graph
+        plt.figure(figsize=(10, 10))
+        nx.draw_networkx_nodes(graph, pos,
+                               nodelist=[node for node in graph.nodes if isinstance(node, DataNodeConfig)],
+                               node_color="skyblue",
+                               node_shape="s",
+                               node_size=2000)
+        nx.draw_networkx_nodes(graph, pos,
+                               nodelist=[node for node in graph.nodes if isinstance(node, TaskConfig)],
+                               node_color="orange",
+                               node_shape="D",
+                               node_size=2000)
+        nx.draw_networkx_labels(graph, pos, labels=labls)
+        nx.draw_networkx_edges(graph, pos, node_size=2000, edge_color="black", arrowstyle="->", arrowsize=25)
+        path = file_path or f"{self.id}.png"
+        plt.savefig(path)
+        plt.close()  # Close the plot to avoid display
+        logger.info(f"The graph image of the scenario configuration `{self.id}` is exported: {path}")

+ 59 - 27
taipy/core/data/_file_datanode_mixin.py

@@ -20,12 +20,20 @@ from taipy.common.config import Config
 from taipy.common.logger._taipy_logger import _TaipyLogger
 from taipy.common.logger._taipy_logger import _TaipyLogger
 
 
 from .._entity._reload import _self_reload
 from .._entity._reload import _self_reload
-from ..reason import InvalidUploadFile, NoFileToDownload, NotAFile, ReasonCollection, UploadFileCanNotBeRead
+from ..common._utils import _normalize_path
+from ..reason import (
+    DataNodeEditInProgress,
+    InvalidUploadFile,
+    NoFileToDownload,
+    NotAFile,
+    ReasonCollection,
+    UploadFileCanNotBeRead,
+)
 from .data_node import DataNode
 from .data_node import DataNode
 from .data_node_id import Edit
 from .data_node_id import Edit
 
 
 
 
-class _FileDataNodeMixin(object):
+class _FileDataNodeMixin:
     """Mixin class designed to handle file-based data nodes."""
     """Mixin class designed to handle file-based data nodes."""
 
 
     __EXTENSION_MAP = {"csv": "csv", "excel": "xlsx", "parquet": "parquet", "pickle": "p", "json": "json"}
     __EXTENSION_MAP = {"csv": "csv", "excel": "xlsx", "parquet": "parquet", "pickle": "p", "json": "json"}
@@ -60,13 +68,14 @@ class _FileDataNodeMixin(object):
     @_self_reload(DataNode._MANAGER_NAME)
     @_self_reload(DataNode._MANAGER_NAME)
     def path(self) -> str:
     def path(self) -> str:
         """The path to the file data of the data node."""
         """The path to the file data of the data node."""
-        return self._path
+        return _normalize_path(self._path)
 
 
     @path.setter
     @path.setter
     def path(self, value) -> None:
     def path(self, value) -> None:
-        self._path = value
-        self.properties[self._PATH_KEY] = value
-        self.properties[self._IS_GENERATED_KEY] = False
+        _path = _normalize_path(value)
+        self._path = _path
+        self.properties[self._PATH_KEY] = _path  # type: ignore[attr-defined]
+        self.properties[self._IS_GENERATED_KEY] = False  # type: ignore[attr-defined]
 
 
     def is_downloadable(self) -> ReasonCollection:
     def is_downloadable(self) -> ReasonCollection:
         """Check if the data node is downloadable.
         """Check if the data node is downloadable.
@@ -100,53 +109,76 @@ class _FileDataNodeMixin(object):
 
 
         return ""
         return ""
 
 
-    def _upload(self, path: str, upload_checker: Optional[Callable[[str, Any], bool]] = None) -> ReasonCollection:
+    def _upload(self,
+                path: str,
+                upload_checker: Optional[Callable[[str, Any], bool]] = None,
+                editor_id: Optional[str] = None,
+                comment: Optional[str] = None,
+                **kwargs: Any) -> ReasonCollection:
         """Upload a file data to the data node.
         """Upload a file data to the data node.
 
 
         Arguments:
         Arguments:
             path (str): The path of the file to upload to the data node.
             path (str): The path of the file to upload to the data node.
-            upload_checker (Optional[Callable[[str, Any], bool]]): A function to check if the upload is allowed.
-                The function takes the title of the upload data and the data itself as arguments and returns
-                True if the upload is allowed, otherwise False.
+            upload_checker (Optional[Callable[[str, Any], bool]]): A function to check if the
+                upload is allowed. The function takes the title of the upload data and the data
+                itself as arguments and returns True if the upload is allowed, otherwise False.
+            editor_id (Optional[str]): The ID of the user who is uploading the file.
+            comment (Optional[str]): A comment to add to the edit history of the data node.
+            **kwargs: Additional keyword arguments. These arguments are stored in the edit
+                history of the data node. In particular, an `editor_id` or a `comment` can be
+                passed. The `editor_id` is the ID of the user who is uploading the file, and the
+                `comment` is a comment to add to the edit history.
 
 
         Returns:
         Returns:
-            True if the upload was successful, otherwise False.
+            True if the upload was successful, the reasons why the upload was not successful
+            otherwise.
         """
         """
         from ._data_manager_factory import _DataManagerFactory
         from ._data_manager_factory import _DataManagerFactory
 
 
-        reason_collection = ReasonCollection()
-
-        upload_path = pathlib.Path(path)
+        reasons = ReasonCollection()
+        if (editor_id
+            and self.edit_in_progress # type: ignore[attr-defined]
+            and self.editor_id != editor_id # type: ignore[attr-defined]
+            and (not self.editor_expiration_date # type: ignore[attr-defined]
+                 or self.editor_expiration_date > datetime.now())):  # type: ignore[attr-defined]
+            reasons._add_reason(self.id, DataNodeEditInProgress(self.id))  # type: ignore[attr-defined]
+            return reasons
 
 
+        up_path = pathlib.Path(path)
         try:
         try:
-            upload_data = self._read_from_path(str(upload_path))
+            upload_data = self._read_from_path(str(up_path))
         except Exception as err:
         except Exception as err:
-            self.__logger.error(f"Error while uploading {upload_path.name} to data node {self.id}:")  # type: ignore[attr-defined]
+            self.__logger.error(f"Error uploading `{up_path.name}` to data "
+                                f"node `{self.id}`:")  # type: ignore[attr-defined]
             self.__logger.error(f"Error: {err}")
             self.__logger.error(f"Error: {err}")
-            reason_collection._add_reason(self.id, UploadFileCanNotBeRead(upload_path.name, self.id))  # type: ignore[attr-defined]
-            return reason_collection
+            reasons._add_reason(self.id, UploadFileCanNotBeRead(up_path.name, self.id))  # type: ignore[attr-defined]
+            return reasons
 
 
         if upload_checker is not None:
         if upload_checker is not None:
             try:
             try:
-                can_upload = upload_checker(upload_path.name, upload_data)
+                can_upload = upload_checker(up_path.name, upload_data)
             except Exception as err:
             except Exception as err:
                 self.__logger.error(
                 self.__logger.error(
-                    f"Error while checking if {upload_path.name} can be uploaded to data node {self.id}"  # type: ignore[attr-defined]
-                    f" using the upload checker {upload_checker.__name__}: {err}"
-                )
+                    f"Error with the upload checker `{upload_checker.__name__}` "
+                    f"while checking `{up_path.name}` file for upload to the data "
+                    f"node `{self.id}`:") # type: ignore[attr-defined]
+                self.__logger.error(f"Error: {err}")
                 can_upload = False
                 can_upload = False
 
 
             if not can_upload:
             if not can_upload:
-                reason_collection._add_reason(self.id, InvalidUploadFile(upload_path.name, self.id))  # type: ignore[attr-defined]
-                return reason_collection
+                reasons._add_reason(self.id, InvalidUploadFile(up_path.name, self.id))  # type: ignore[attr-defined]
+                return reasons
 
 
-        shutil.copy(upload_path, self.path)
+        shutil.copy(up_path, self.path)
 
 
-        self.track_edit(timestamp=datetime.now())  # type: ignore[attr-defined]
+        self.track_edit(timestamp=datetime.now(),  # type: ignore[attr-defined]
+                        editor_id=editor_id,
+                        comment=comment, **kwargs)
         self.unlock_edit()  # type: ignore[attr-defined]
         self.unlock_edit()  # type: ignore[attr-defined]
+
         _DataManagerFactory._build_manager()._set(self)  # type: ignore[arg-type]
         _DataManagerFactory._build_manager()._set(self)  # type: ignore[arg-type]
 
 
-        return reason_collection
+        return reasons
 
 
     def _read_from_path(self, path: Optional[str] = None, **read_kwargs) -> Any:
     def _read_from_path(self, path: Optional[str] = None, **read_kwargs) -> Any:
         raise NotImplementedError
         raise NotImplementedError

+ 22 - 5
taipy/core/data/data_node.py

@@ -421,35 +421,52 @@ class DataNode(_Entity, _Labeled):
             )
             )
             return None
             return None
 
 
-    def append(self, data, editor_id: Optional[str] = None, **kwargs: Any):
+    def append(self, data, editor_id: Optional[str] = None, comment: Optional[str] = None, **kwargs: Any):
         """Append some data to this data node.
         """Append some data to this data node.
 
 
         Arguments:
         Arguments:
             data (Any): The data to write to this data node.
             data (Any): The data to write to this data node.
             editor_id (str): An optional identifier of the editor.
             editor_id (str): An optional identifier of the editor.
+            comment (str): An optional comment to attach to the edit document.
             **kwargs (Any): Extra information to attach to the edit document
             **kwargs (Any): Extra information to attach to the edit document
                 corresponding to this write.
                 corresponding to this write.
         """
         """
         from ._data_manager_factory import _DataManagerFactory
         from ._data_manager_factory import _DataManagerFactory
-
+        if (editor_id
+            and self.edit_in_progress
+            and self.editor_id != editor_id
+            and (not self.editor_expiration_date or self.editor_expiration_date > datetime.now())):
+            raise DataNodeIsBeingEdited(self.id, self.editor_id)
         self._append(data)
         self._append(data)
-        self.track_edit(editor_id=editor_id, **kwargs)
+        self.track_edit(editor_id=editor_id, comment=comment, **kwargs)
         self.unlock_edit()
         self.unlock_edit()
         _DataManagerFactory._build_manager()._set(self)
         _DataManagerFactory._build_manager()._set(self)
 
 
-    def write(self, data, job_id: Optional[JobId] = None, **kwargs: Any):
+    def write(self,
+              data,
+              job_id: Optional[JobId] = None,
+              editor_id: Optional[str] = None,
+              comment: Optional[str] = None,
+              **kwargs: Any):
         """Write some data to this data node.
         """Write some data to this data node.
 
 
         Arguments:
         Arguments:
             data (Any): The data to write to this data node.
             data (Any): The data to write to this data node.
             job_id (JobId): An optional identifier of the job writing the data.
             job_id (JobId): An optional identifier of the job writing the data.
+            editor_id (str): An optional identifier of the editor writing the data.
+            comment (str): An optional comment to attach to the edit document.
             **kwargs (Any): Extra information to attach to the edit document
             **kwargs (Any): Extra information to attach to the edit document
                 corresponding to this write.
                 corresponding to this write.
         """
         """
         from ._data_manager_factory import _DataManagerFactory
         from ._data_manager_factory import _DataManagerFactory
 
 
+        if (editor_id
+            and self.edit_in_progress
+            and self.editor_id != editor_id
+            and (not self.editor_expiration_date or self.editor_expiration_date > datetime.now())):
+            raise DataNodeIsBeingEdited(self.id, self.editor_id)
         self._write(data)
         self._write(data)
-        self.track_edit(job_id=job_id, **kwargs)
+        self.track_edit(job_id=job_id, editor_id=editor_id, comment=comment, **kwargs)
         self.unlock_edit()
         self.unlock_edit()
         _DataManagerFactory._build_manager()._set(self)
         _DataManagerFactory._build_manager()._set(self)
 
 

+ 3 - 2
taipy/gui/_renderers/factory.py

@@ -116,9 +116,10 @@ class _Factory:
                 ("users", PropertyType.lov),
                 ("users", PropertyType.lov),
                 ("sender_id",),
                 ("sender_id",),
                 ("height",),
                 ("height",),
-                ("page_size", PropertyType.number, 50),
-                ("max_file_size", PropertyType.number, 1 * 1024 * 1024),
+                ("page_size", PropertyType.number),
+                ("max_file_size", PropertyType.number),
                 ("show_sender", PropertyType.boolean, False),
                 ("show_sender", PropertyType.boolean, False),
+                ("allow_send_images", PropertyType.boolean, True),
                 ("mode",),
                 ("mode",),
             ]
             ]
         ),
         ),

+ 1 - 1
taipy/gui/builder/_api_generator.py

@@ -39,7 +39,7 @@ class _ElementApiGenerator(object, metaclass=_Singleton):
 
 
     @staticmethod
     @staticmethod
     def get_properties_dict(property_list: t.List[VisElementProperties]) -> t.Dict[str, t.Any]:
     def get_properties_dict(property_list: t.List[VisElementProperties]) -> t.Dict[str, t.Any]:
-        return {prop["name"]: prop.get("type", "str") for prop in property_list}
+        return {prop["name"]: prop.get("type", "str") for prop in property_list if not prop.get("hide", False)}
 
 
     def add_default(self):
     def add_default(self):
         if self.__module is not None:
         if self.__module is not None:

+ 4 - 1
taipy/gui/utils/viselements.py

@@ -19,6 +19,7 @@ class VisElementProperties(t.TypedDict):
     doc: str
     doc: str
     default_value: t.Any
     default_value: t.Any
     default_property: t.Any
     default_property: t.Any
+    hide: t.Optional[bool]
 
 
 
 
 class VisElementDetail(t.TypedDict):
 class VisElementDetail(t.TypedDict):
@@ -40,6 +41,7 @@ def _resolve_inherit_property(element: VisElement, viselements: VisElements) ->
     properties = deepcopy(element_detail["properties"])
     properties = deepcopy(element_detail["properties"])
     if "inherits" not in element_detail:
     if "inherits" not in element_detail:
         return properties
         return properties
+    hidden_property_names = [p.get("name") for p in properties if p.get("hide", False)]
     for inherit in element_detail["inherits"]:
     for inherit in element_detail["inherits"]:
         inherit_element = None
         inherit_element = None
         for element_type in "blocks", "controls", "undocumented":
         for element_type in "blocks", "controls", "undocumented":
@@ -48,7 +50,8 @@ def _resolve_inherit_property(element: VisElement, viselements: VisElements) ->
                 break
                 break
         if inherit_element is None:
         if inherit_element is None:
             raise RuntimeError(f"Error resolving inherit element with name {inherit} in viselements.json")
             raise RuntimeError(f"Error resolving inherit element with name {inherit} in viselements.json")
-        properties = properties + _resolve_inherit_property(inherit_element, viselements)
+        inherited_props = _resolve_inherit_property(inherit_element, viselements)
+        properties = properties + [p for p in inherited_props if p.get("name") not in hidden_property_names]
     return properties
     return properties
 
 
 
 

+ 22 - 5
taipy/gui/viselements.json

@@ -53,8 +53,9 @@
                         "type": "dynamic(Union[str,Icon])",
                         "type": "dynamic(Union[str,Icon])",
                         "default_value": "\"\"",
                         "default_value": "\"\"",
                         "doc": "The label displayed in the button."
                         "doc": "The label displayed in the button."
-                    },                                        {
-                    "name": "size",
+                    },
+                    {
+                        "name": "size",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"medium\"",
                         "default_value": "\"medium\"",
                         "doc": "The size of the button. Valid values: \"small\", \"medium\", or \"large\"."
                         "doc": "The size of the button. Valid values: \"small\", \"medium\", or \"large\"."
@@ -1639,7 +1640,9 @@
         [
         [
             "alert",
             "alert",
             {
             {
-                "inherits": ["shared"],
+                "inherits": [
+                    "shared"
+                ],
                 "properties": [
                 "properties": [
                     {
                     {
                         "name": "message",
                         "name": "message",
@@ -1815,8 +1818,14 @@
                     {
                     {
                         "name": "max_file_size",
                         "name": "max_file_size",
                         "type": "int",
                         "type": "int",
-                        "default_value": "1024 * 1024",
-                        "doc": "The maximum allowable file size, in bytes, for files uploaded to a chat message.\nThe default is 1 MB."
+                        "default_value": "0.8 * 1024 * 1024",
+                        "doc": "The maximum allowable file size, in bytes, for files uploaded to a chat message.\nThe default is 0.8 MB."
+                    },
+                    {
+                        "name": "allow_send_images",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "TODO if True, an upload image icon is shown."
                     }
                     }
                 ]
                 ]
             }
             }
@@ -1850,6 +1859,14 @@
                         "name": "row_height",
                         "name": "row_height",
                         "type": "str",
                         "type": "str",
                         "doc": "The height of each row of this tree, in CSS units."
                         "doc": "The height of each row of this tree, in CSS units."
+                    },
+                    {
+                        "name": "mode",
+                        "hide": true
+                    },
+                    {
+                        "name": "dropdown",
+                        "hide": true
                     }
                     }
                 ]
                 ]
             }
             }

+ 43 - 33
taipy/gui_core/_context.py

@@ -70,6 +70,7 @@ from ._adapters import (
     _GuiCoreScenarioProperties,
     _GuiCoreScenarioProperties,
     _invoke_action,
     _invoke_action,
 )
 )
+from ._utils import _ClientStatus
 from .filters import CustomScenarioFilter
 from .filters import CustomScenarioFilter
 
 
 
 
@@ -92,7 +93,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         self.data_nodes_by_owner: t.Optional[t.Dict[t.Optional[str], t.List[DataNode]]] = None
         self.data_nodes_by_owner: t.Optional[t.Dict[t.Optional[str], t.List[DataNode]]] = None
         self.scenario_configs: t.Optional[t.List[t.Tuple[str, str]]] = None
         self.scenario_configs: t.Optional[t.List[t.Tuple[str, str]]] = None
         self.jobs_list: t.Optional[t.List[Job]] = None
         self.jobs_list: t.Optional[t.List[Job]] = None
-        self.client_submission: t.Dict[str, SubmissionStatus] = {}
+        self.client_submission: t.Dict[str, _ClientStatus] = {}
         # register to taipy core notification
         # register to taipy core notification
         reg_id, reg_queue = Notifier.register()
         reg_id, reg_queue = Notifier.register()
         # locks
         # locks
@@ -162,28 +163,32 @@ class _GuiCoreContext(CoreEventConsumerBase):
         self.broadcast_core_changed({"scenario": scenario_id or True})
         self.broadcast_core_changed({"scenario": scenario_id or True})
 
 
     def submission_status_callback(self, submission_id: t.Optional[str] = None, event: t.Optional[Event] = None):
     def submission_status_callback(self, submission_id: t.Optional[str] = None, event: t.Optional[Event] = None):
-        if not submission_id or not is_readable(t.cast(SubmissionId, submission_id)):
+        if not submission_id:
             return
             return
         submission = None
         submission = None
         new_status = None
         new_status = None
         payload: t.Optional[t.Dict[str, t.Any]] = None
         payload: t.Optional[t.Dict[str, t.Any]] = None
         client_id: t.Optional[str] = None
         client_id: t.Optional[str] = None
         try:
         try:
-            last_status = self.client_submission.get(submission_id)
-            if not last_status:
+            last_client_status = self.client_submission.get(submission_id)
+            if not last_client_status:
                 return
                 return
 
 
-            submission = t.cast(Submission, core_get(submission_id))
-            if not submission or not submission.entity_id:
-                return
+            client_id = last_client_status.client_id
+
+            with self.gui._get_authorization(client_id):
+                if not is_readable(t.cast(SubmissionId, submission_id)):
+                    return
+                submission = t.cast(Submission, core_get(submission_id))
+                if not submission or not submission.entity_id:
+                    return
 
 
-            payload = {}
-            new_status = t.cast(SubmissionStatus, submission.submission_status)
+                payload = {}
+                new_status = t.cast(SubmissionStatus, submission.submission_status)
 
 
-            client_id = submission.properties.get("client_id")
-            if client_id:
-                running_tasks = {}
-                with self.gui._get_authorization(client_id):
+                if client_id:
+                    running_tasks = {}
+                    # with self.gui._get_authorization(client_id):
                     for job in submission.jobs:
                     for job in submission.jobs:
                         job = job if isinstance(job, Job) else t.cast(Job, core_get(job))
                         job = job if isinstance(job, Job) else t.cast(Job, core_get(job))
                         running_tasks[job.task.id] = (
                         running_tasks[job.task.id] = (
@@ -195,7 +200,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         )
                         )
                     payload.update(tasks=running_tasks)
                     payload.update(tasks=running_tasks)
 
 
-                    if last_status is not new_status:
+                    if last_client_status.submission_status is not new_status:
                         # callback
                         # callback
                         submission_name = submission.properties.get("on_submission")
                         submission_name = submission.properties.get("on_submission")
                         if submission_name:
                         if submission_name:
@@ -213,15 +218,15 @@ class _GuiCoreContext(CoreEventConsumerBase):
                                 submission.properties.get("module_context"),
                                 submission.properties.get("module_context"),
                             )
                             )
 
 
-            with self.submissions_lock:
-                if new_status in (
-                    SubmissionStatus.COMPLETED,
-                    SubmissionStatus.FAILED,
-                    SubmissionStatus.CANCELED,
-                ):
+            if new_status in (
+                SubmissionStatus.COMPLETED,
+                SubmissionStatus.FAILED,
+                SubmissionStatus.CANCELED,
+            ):
+                with self.submissions_lock:
                     self.client_submission.pop(submission_id, None)
                     self.client_submission.pop(submission_id, None)
-                else:
-                    self.client_submission[submission_id] = new_status
+            else:
+                last_client_status.submission_status = new_status
 
 
         except Exception as e:
         except Exception as e:
             _warn(f"Submission ({submission_id}) is not available", e)
             _warn(f"Submission ({submission_id}) is not available", e)
@@ -634,11 +639,11 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     client_id=self.gui._get_client_id(),
                     client_id=self.gui._get_client_id(),
                     module_context=self.gui._get_locals_context(),
                     module_context=self.gui._get_locals_context(),
                 )
                 )
+                client_status = _ClientStatus(self.gui._get_client_id(), submission_entity.submission_status)
                 with self.submissions_lock:
                 with self.submissions_lock:
-                    self.client_submission[submission_entity.id] = submission_entity.submission_status
+                    self.client_submission[submission_entity.id] = client_status
                 if Config.core.mode == "development":
                 if Config.core.mode == "development":
-                    with self.submissions_lock:
-                        self.client_submission[submission_entity.id] = SubmissionStatus.SUBMITTED
+                    client_status.submission_status = SubmissionStatus.SUBMITTED
                     self.submission_status_callback(submission_entity.id)
                     self.submission_status_callback(submission_entity.id)
                 _GuiCoreContext.__assign_var(state, error_var, "")
                 _GuiCoreContext.__assign_var(state, error_var, "")
         except Exception as e:
         except Exception as e:
@@ -1015,10 +1020,10 @@ class _GuiCoreContext(CoreEventConsumerBase):
 
 
     def __check_readable_editable(self, state: State, id: str, ent_type: str, var: t.Optional[str]):
     def __check_readable_editable(self, state: State, id: str, ent_type: str, var: t.Optional[str]):
         if not (reason := is_readable(t.cast(ScenarioId, id))):
         if not (reason := is_readable(t.cast(ScenarioId, id))):
-            _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not readable: {_get_reason(reason)}.")
+            _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not readable: {_get_reason(reason)}")
             return False
             return False
         if not (reason := is_editable(t.cast(ScenarioId, id))):
         if not (reason := is_editable(t.cast(ScenarioId, id))):
-            _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not editable: {_get_reason(reason)}.")
+            _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not editable: {_get_reason(reason)}")
             return False
             return False
         return True
         return True
 
 
@@ -1028,7 +1033,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
         if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
             return
             return
         data = t.cast(dict, args[0])
         data = t.cast(dict, args[0])
-        error_var = payload.get("error_id")
+        error_var = data.get("error_id")
         entity_id = t.cast(str, data.get(_GuiCoreContext.__PROP_ENTITY_ID))
         entity_id = t.cast(str, data.get(_GuiCoreContext.__PROP_ENTITY_ID))
         if not self.__check_readable_editable(state, entity_id, "Data node", error_var):
         if not self.__check_readable_editable(state, entity_id, "Data node", error_var):
             return
             return
@@ -1044,9 +1049,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     else float(val)
                     else float(val)
                     if data.get("type") == "float"
                     if data.get("type") == "float"
                     else data.get("value"),
                     else data.get("value"),
+                    editor_id=self.gui._get_client_id(),
                     comment=t.cast(dict, data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT)),
                     comment=t.cast(dict, data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT)),
                 )
                 )
-                entity.unlock_edit(self.gui._get_client_id())
                 _GuiCoreContext.__assign_var(state, error_var, "")
                 _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
             except Exception as e:
                 _GuiCoreContext.__assign_var(state, error_var, f"Error updating Data node value. {e}")
                 _GuiCoreContext.__assign_var(state, error_var, f"Error updating Data node value. {e}")
@@ -1130,7 +1135,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
                             "Error updating data node tabular value: type does not support at[] indexer.",
                             "Error updating data node tabular value: type does not support at[] indexer.",
                         )
                         )
                 if new_data is not None:
                 if new_data is not None:
-                    datanode.write(new_data, comment=user_data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT))
+                    datanode.write(new_data,
+                                   editor_id=self.gui._get_client_id(),
+                                   comment=user_data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT))
                     _GuiCoreContext.__assign_var(state, error_var, "")
                     _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
             except Exception as e:
                 _GuiCoreContext.__assign_var(state, error_var, f"Error updating data node tabular value. {e}")
                 _GuiCoreContext.__assign_var(state, error_var, f"Error updating data node tabular value. {e}")
@@ -1217,6 +1224,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
 
 
     def on_file_action(self, state: State, id: str, payload: t.Dict[str, t.Any]):
     def on_file_action(self, state: State, id: str, payload: t.Dict[str, t.Any]):
         args = t.cast(list, payload.get("args"))
         args = t.cast(list, payload.get("args"))
+        if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
+            return
         act_payload = t.cast(t.Dict[str, str], args[0])
         act_payload = t.cast(t.Dict[str, str], args[0])
         dn_id = t.cast(DataNodeId, act_payload.get("id"))
         dn_id = t.cast(DataNodeId, act_payload.get("id"))
         error_id = act_payload.get("error_id", "")
         error_id = act_payload.get("error_id", "")
@@ -1224,11 +1233,10 @@ class _GuiCoreContext(CoreEventConsumerBase):
             try:
             try:
                 dn = t.cast(_FileDataNodeMixin, core_get(dn_id))
                 dn = t.cast(_FileDataNodeMixin, core_get(dn_id))
                 if act_payload.get("action") == "export":
                 if act_payload.get("action") == "export":
-                    path = dn._get_downloadable_path()
-                    if path:
+                    if reason := dn.is_downloadable():
+                        path = dn._get_downloadable_path()
                         self.gui._download(Path(path), dn_id)
                         self.gui._download(Path(path), dn_id)
                     else:
                     else:
-                        reason = dn.is_downloadable()
                         state.assign(
                         state.assign(
                             error_id,
                             error_id,
                             "Data unavailable: "
                             "Data unavailable: "
@@ -1241,6 +1249,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         reason := dn._upload(
                         reason := dn._upload(
                             act_payload.get("path", ""),
                             act_payload.get("path", ""),
                             t.cast(t.Callable[[str, t.Any], bool], checker) if callable(checker) else None,
                             t.cast(t.Callable[[str, t.Any], bool], checker) if callable(checker) else None,
+                            editor_id=self.gui._get_client_id(),
+                            comment=None
                         )
                         )
                     ):
                     ):
                         state.assign(error_id, f"Data unavailable: {reason.reasons}")
                         state.assign(error_id, f"Data unavailable: {reason.reasons}")

+ 20 - 0
taipy/gui_core/_utils.py

@@ -0,0 +1,20 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+import typing as t
+from dataclasses import dataclass
+
+from taipy.core.submission.submission_status import SubmissionStatus
+
+
+@dataclass
+class _ClientStatus:
+    client_id: t.Optional[str]
+    submission_status: SubmissionStatus

+ 5 - 0
tests/core/config/test_data_node_config.py

@@ -405,3 +405,8 @@ def test_clean_config():
     assert dn1_config.validity_period is dn2_config.validity_period is None
     assert dn1_config.validity_period is dn2_config.validity_period is None
     assert dn1_config.default_path is dn2_config.default_path is None
     assert dn1_config.default_path is dn2_config.default_path is None
     assert dn1_config.properties == dn2_config.properties == {}
     assert dn1_config.properties == dn2_config.properties == {}
+
+
+def test_normalize_path():
+    data_node_config = Config.configure_data_node(id="data_nodes1", storage_type="csv", path=r"data\file.csv")
+    assert data_node_config.path == "data/file.csv"

+ 79 - 0
tests/core/config/test_scenario_config.py

@@ -12,6 +12,8 @@
 import os
 import os
 from unittest import mock
 from unittest import mock
 
 
+import pytest
+
 from taipy.common.config import Config
 from taipy.common.config import Config
 from taipy.common.config.common.frequency import Frequency
 from taipy.common.config.common.frequency import Frequency
 from tests.core.utils.named_temporary_file import NamedTemporaryFile
 from tests.core.utils.named_temporary_file import NamedTemporaryFile
@@ -299,3 +301,80 @@ def test_add_sequence():
     assert len(scenario_config.sequences) == 2
     assert len(scenario_config.sequences) == 2
     scenario_config.remove_sequences(["sequence2", "sequence3"])
     scenario_config.remove_sequences(["sequence2", "sequence3"])
     assert len(scenario_config.sequences) == 0
     assert len(scenario_config.sequences) == 0
+
+@pytest.mark.skip(reason="Generates a png that must be visually verified.")
+def test_draw_1():
+    dn_config_1 = Config.configure_data_node("dn1")
+    dn_config_2 = Config.configure_data_node("dn2")
+    dn_config_3 = Config.configure_data_node("dn3")
+    dn_config_4 = Config.configure_data_node("dn4")
+    dn_config_5 = Config.configure_data_node("dn5")
+    task_config_1 = Config.configure_task("task1", sum, input=[dn_config_1, dn_config_2], output=dn_config_3)
+    task_config_2 = Config.configure_task("task2", sum, input=[dn_config_1, dn_config_3], output=dn_config_4)
+    task_config_3 = Config.configure_task("task3", print, input=dn_config_4)
+    scenario_cfg = Config.configure_scenario(
+        "scenario1",
+        [task_config_1, task_config_2, task_config_3],
+        [dn_config_5],
+    )
+    scenario_cfg.draw()
+
+@pytest.mark.skip(reason="Generates a png that must be visually verified.")
+def test_draw_2():
+    data_node_1 = Config.configure_data_node("s1")
+    data_node_2 = Config.configure_data_node("s2")
+    data_node_4 = Config.configure_data_node("s4")
+    data_node_5 = Config.configure_data_node("s5")
+    data_node_6 = Config.configure_data_node("s6")
+    data_node_7 = Config.configure_data_node("s7")
+    task_1 = Config.configure_task("t1", print, [data_node_1, data_node_2], [data_node_4])
+    task_2 = Config.configure_task("t2", print, None, [data_node_5])
+    task_3 = Config.configure_task("t3", print, [data_node_5, data_node_4], [data_node_6])
+    task_4 = Config.configure_task("t4", print, [data_node_4], [data_node_7])
+    scenario_cfg = Config.configure_scenario("scenario1", [task_4, task_2, task_1, task_3])
+
+    #  6  |   t2 _____
+    #  5  |           \
+    #  4  |            s5 _________________ t3 _______ s6
+    #  3  |   s1 __            _ s4 _____/
+    #  2  |        \ _ t1 ____/          \_ t4 _______ s7
+    #  1  |        /
+    #  0  |   s2 --
+    #     |________________________________________________
+    #         0        1         2          3          4
+    scenario_cfg.draw("draw_2")
+
+@pytest.mark.skip(reason="Generates a png that must be visually verified.")
+def test_draw_3():
+    data_node_1 = Config.configure_data_node("s1")
+    data_node_2 = Config.configure_data_node("s2")
+    data_node_3 = Config.configure_data_node("s3")
+    data_node_4 = Config.configure_data_node("s4")
+    data_node_5 = Config.configure_data_node("s5")
+    data_node_6 = Config.configure_data_node("s6")
+    data_node_7 = Config.configure_data_node("s7")
+
+    task_1 = Config.configure_task("t1", print, [data_node_1, data_node_2, data_node_3], [data_node_4])
+    task_2 = Config.configure_task("t2", print, [data_node_4], None)
+    task_3 = Config.configure_task("t3", print, [data_node_4], [data_node_5])
+    task_4 = Config.configure_task("t4", print, None, output=[data_node_6])
+    task_5 = Config.configure_task("t5", print, [data_node_7], None)
+    scenario_cfg = Config.configure_scenario("scenario1", [task_5, task_3, task_4, task_2, task_1])
+
+
+    #  12 |  s7 __
+    #  11 |       \
+    #  10 |        \
+    #  9  |  t4 _   \_ t5
+    #  8  |      \                     ____ t3 ___
+    #  7  |       \                   /           \
+    #  6  |  s3 _  \__ s6      _ s4 _/             \___ s5
+    #  5  |      \            /      \
+    #  4  |       \          /        \____ t2
+    #  3  |  s2 ___\__ t1 __/
+    #  2  |        /
+    #  1  |       /
+    #  0  |  s1 _/
+    #     |________________________________________________
+    #         0         1         2          3          4
+    scenario_cfg.draw("draw_3")

+ 28 - 9
tests/core/data/test_csv_data_node.py

@@ -12,6 +12,7 @@
 import dataclasses
 import dataclasses
 import os
 import os
 import pathlib
 import pathlib
+import re
 import uuid
 import uuid
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from time import sleep
 from time import sleep
@@ -25,6 +26,7 @@ from pandas.testing import assert_frame_equal
 from taipy.common.config import Config
 from taipy.common.config import Config
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
+from taipy.core.common._utils import _normalize_path
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data.csv import CSVDataNode
 from taipy.core.data.csv import CSVDataNode
@@ -129,7 +131,7 @@ class TestCSVDataNode:
     )
     )
     def test_create_with_default_data(self, properties, exists):
     def test_create_with_default_data(self, properties, exists):
         dn = CSVDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
         dn = CSVDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
-        assert dn.path == os.path.join(Config.core.storage_folder.strip("/"), "csvs", dn.id + ".csv")
+        assert dn.path == f"{Config.core.storage_folder}csvs/{dn.id}.csv"
         assert os.path.exists(dn.path) is exists
         assert os.path.exists(dn.path) is exists
 
 
     def test_set_path(self):
     def test_set_path(self):
@@ -218,7 +220,7 @@ class TestCSVDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+        assert str(NoFileToDownload(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_is_not_downloadable_not_a_file(self):
     def test_is_not_downloadable_not_a_file(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -226,12 +228,12 @@ class TestCSVDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NotAFile(path, dn.id)) in reasons.reasons
+        assert str(NotAFile(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_get_downloadable_path(self):
     def test_get_downloadable_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.csv")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.csv")
         dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
         dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
-        assert dn._get_downloadable_path() == path
+        assert re.split(r"[\\/]", dn._get_downloadable_path()) == re.split(r"[\\/]", path)
 
 
     def test_get_downloadable_path_with_not_existing_file(self):
     def test_get_downloadable_path_with_not_existing_file(self):
         dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.csv", "exposed_type": "pandas"})
         dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.csv", "exposed_type": "pandas"})
@@ -257,7 +259,23 @@ class TestCSVDataNode:
 
 
         assert_frame_equal(dn.read(), upload_content)  # The content of the dn should change to the uploaded content
         assert_frame_equal(dn.read(), upload_content)  # The content of the dn should change to the uploaded content
         assert dn.last_edit_date > old_last_edit_date
         assert dn.last_edit_date > old_last_edit_date
-        assert dn.path == old_csv_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_csv_path)  # The path of the dn should not change
+
+    def test_upload_fails_if_data_node_locked(self, csv_file, tmpdir_factory):
+        old_csv_path = tmpdir_factory.mktemp("data").join("df.csv").strpath
+        old_data = pd.DataFrame([{"a": 0, "b": 1, "c": 2}, {"a": 3, "b": 4, "c": 5}])
+
+        dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": old_csv_path, "exposed_type": "pandas"})
+        dn.write(old_data)
+        upload_content = pd.read_csv(csv_file)
+        dn.lock_edit("editor_id_1")
+
+        reasons = dn._upload(csv_file, editor_id="editor_id_2")
+        assert not reasons
+
+        assert dn._upload(csv_file, editor_id="editor_id_1")
+
+        assert_frame_equal(dn.read(), upload_content)  # The content of the dn should change to the uploaded content
 
 
     def test_upload_with_upload_check_with_exception(self, csv_file, tmpdir_factory, caplog):
     def test_upload_with_upload_check_with_exception(self, csv_file, tmpdir_factory, caplog):
         old_csv_path = tmpdir_factory.mktemp("data").join("df.csv").strpath
         old_csv_path = tmpdir_factory.mktemp("data").join("df.csv").strpath
@@ -269,8 +287,9 @@ class TestCSVDataNode:
         reasons = dn._upload(csv_file, upload_checker=check_with_exception)
         reasons = dn._upload(csv_file, upload_checker=check_with_exception)
         assert bool(reasons) is False
         assert bool(reasons) is False
         assert (
         assert (
-            f"Error while checking if df.csv can be uploaded to data node {dn.id} using "
-            "the upload checker check_with_exception: An error with check_with_exception" in caplog.text
+            f"Error with the upload checker `check_with_exception` "
+            f"while checking `df.csv` file for upload to the data "
+            f"node `{dn.id}`:" in caplog.text
         )
         )
 
 
     def test_upload_with_upload_check_pandas(self, csv_file, tmpdir_factory):
     def test_upload_with_upload_check_pandas(self, csv_file, tmpdir_factory):
@@ -314,7 +333,7 @@ class TestCSVDataNode:
 
 
         assert_frame_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert_frame_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_csv_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_csv_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_column() return True
         # The upload should succeed when check_data_column() return True
         assert dn._upload(csv_file, upload_checker=check_data_column)
         assert dn._upload(csv_file, upload_checker=check_data_column)
@@ -364,7 +383,7 @@ class TestCSVDataNode:
 
 
         np.array_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         np.array_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_csv_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_csv_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_is_positive() return True
         # The upload should succeed when check_data_is_positive() return True
         assert dn._upload(new_csv_path, upload_checker=check_data_is_positive)
         assert dn._upload(new_csv_path, upload_checker=check_data_is_positive)

+ 124 - 0
tests/core/data/test_data_node.py

@@ -14,6 +14,8 @@ from datetime import datetime, timedelta
 from time import sleep
 from time import sleep
 from unittest import mock
 from unittest import mock
 
 
+import freezegun
+import pandas as pd
 import pytest
 import pytest
 
 
 import taipy.core as tp
 import taipy.core as tp
@@ -752,6 +754,116 @@ class TestDataNode:
         dn.properties["name"] = "baz"
         dn.properties["name"] = "baz"
         assert dn.name == "baz"
         assert dn.name == "baz"
 
 
+    def test_locked_data_node_write_should_fail_with_wrong_editor(self):
+        dn_config = Config.configure_data_node("A")
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+        dn.lock_edit("editor_1")
+
+        # Should raise exception for wrong editor
+        with pytest.raises(DataNodeIsBeingEdited):
+            dn.write("data", editor_id="editor_2")
+
+        # Should succeed with correct editor
+        dn.write("data", editor_id="editor_1")
+        assert dn.read() == "data"
+
+    def test_locked_data_node_write_should_fail_before_expiration_date_and_succeed_after(self):
+        dn_config = Config.configure_data_node("A")
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+
+        lock_time = datetime.now()
+        with freezegun.freeze_time(lock_time):
+            dn.lock_edit("editor_1")
+
+        with freezegun.freeze_time(lock_time + timedelta(minutes=29)):
+            # Should raise exception for wrong editor and expiration date NOT passed
+            with pytest.raises(DataNodeIsBeingEdited):
+                dn.write("data", editor_id="editor_2")
+
+        with freezegun.freeze_time(lock_time + timedelta(minutes=31)):
+            # Should succeed with wrong editor but expiration date passed
+            dn.write("data", editor_id="editor_2")
+            assert dn.read() == "data"
+
+    def test_locked_data_node_append_should_fail_with_wrong_editor(self):
+        dn_config = Config.configure_csv_data_node("A")
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+        first_line = pd.DataFrame(data={'col1': [1], 'col2': [3]})
+        second_line = pd.DataFrame(data={'col1': [2], 'col2': [4]})
+        data = pd.DataFrame(data={'col1': [1, 2], 'col2': [3, 4]})
+        dn.write(first_line)
+        assert first_line.equals(dn.read())
+
+        dn.lock_edit("editor_1")
+
+        with pytest.raises(DataNodeIsBeingEdited):
+            dn.append(second_line, editor_id="editor_2")
+
+        dn.append(second_line, editor_id="editor_1")
+        assert dn.read().equals(data)
+
+    def test_locked_data_node_append_should_fail_before_expiration_date_and_succeed_after(self):
+        dn_config = Config.configure_csv_data_node("A")
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+        first_line = pd.DataFrame(data={'col1': [1], 'col2': [3]})
+        second_line = pd.DataFrame(data={'col1': [2], 'col2': [4]})
+        data = pd.DataFrame(data={'col1': [1, 2], 'col2': [3, 4]})
+        dn.write(first_line)
+        assert first_line.equals(dn.read())
+
+        lock_time = datetime.now()
+        with freezegun.freeze_time(lock_time):
+            dn.lock_edit("editor_1")
+
+        with freezegun.freeze_time(lock_time + timedelta(minutes=29)):
+            # Should raise exception for wrong editor and expiration date NOT passed
+            with pytest.raises(DataNodeIsBeingEdited):
+                dn.append(second_line, editor_id="editor_2")
+
+        with freezegun.freeze_time(lock_time + timedelta(minutes=31)):
+            # Should succeed with wrong editor but expiration date passed
+            dn.append(second_line, editor_id="editor_2")
+            assert dn.read().equals(data)
+
+    def test_orchestrator_write_without_editor_id(self):
+        dn_config = Config.configure_data_node("A")
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+        dn.lock_edit("editor_1")
+
+        # Orchestrator write without editor_id should succeed
+        dn.write("orchestrator_data")
+        assert dn.read() == "orchestrator_data"
+
+    def test_editor_fails_writing_a_data_node_locked_by_orchestrator(self):
+        dn_config = Config.configure_data_node("A")
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+        dn.lock_edit() # Locked by orchestrator
+
+        with pytest.raises(DataNodeIsBeingEdited):
+            dn.write("data", editor_id="editor_1")
+
+        # Orchestrator write without editor_id should succeed
+        dn.write("orchestrator_data", job_id=JobId("job_1"))
+        assert dn.read() == "orchestrator_data"
+
+    def test_editor_fails_appending_a_data_node_locked_by_orchestrator(self):
+        dn_config = Config.configure_csv_data_node("A")
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+        first_line = pd.DataFrame(data={'col1': [1], 'col2': [3]})
+        second_line = pd.DataFrame(data={'col1': [2], 'col2': [4]})
+        data = pd.DataFrame(data={'col1': [1, 2], 'col2': [3, 4]})
+        dn.write(first_line)
+        assert first_line.equals(dn.read())
+        dn = _DataManager._bulk_get_or_create([dn_config])[dn_config]
+        dn.lock_edit() # Locked by orchestrator
+
+        with pytest.raises(DataNodeIsBeingEdited):
+            dn.append(second_line, editor_id="editor_1")
+        assert dn.read().equals(first_line)
+
+        dn.append(second_line, job_id=JobId("job_1"))
+        assert dn.read().equals(data)
+
     def test_track_edit(self):
     def test_track_edit(self):
         dn_config = Config.configure_data_node("A")
         dn_config = Config.configure_data_node("A")
         data_node = _DataManager._bulk_get_or_create([dn_config])[dn_config]
         data_node = _DataManager._bulk_get_or_create([dn_config])[dn_config]
@@ -807,3 +919,15 @@ class TestDataNode:
         edit_5 = data_node.edits[5]
         edit_5 = data_node.edits[5]
         assert len(edit_5) == 1
         assert len(edit_5) == 1
         assert edit_5[EDIT_TIMESTAMP_KEY] == timestamp
         assert edit_5[EDIT_TIMESTAMP_KEY] == timestamp
+
+    def test_normalize_path(self):
+        dn = DataNode(
+            config_id="foo_bar",
+            scope=Scope.SCENARIO,
+            id=DataNodeId("an_id"),
+            path=r"data\foo\bar.csv",
+        )
+        assert dn.config_id == "foo_bar"
+        assert dn.scope == Scope.SCENARIO
+        assert dn.id == "an_id"
+        assert dn.properties["path"] == "data/foo/bar.csv"

+ 9 - 7
tests/core/data/test_excel_data_node.py

@@ -11,6 +11,7 @@
 
 
 import os
 import os
 import pathlib
 import pathlib
+import re
 import uuid
 import uuid
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from time import sleep
 from time import sleep
@@ -24,6 +25,7 @@ from pandas.testing import assert_frame_equal
 
 
 from taipy.common.config import Config
 from taipy.common.config import Config
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.common.scope import Scope
+from taipy.core.common._utils import _normalize_path
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data.data_node_id import DataNodeId
 from taipy.core.data.data_node_id import DataNodeId
@@ -183,7 +185,7 @@ class TestExcelDataNode:
     )
     )
     def test_create_with_default_data(self, properties, exists):
     def test_create_with_default_data(self, properties, exists):
         dn = ExcelDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
         dn = ExcelDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
-        assert dn.path == os.path.join(Config.core.storage_folder.strip("/"), "excels", dn.id + ".xlsx")
+        assert dn.path == f"{Config.core.storage_folder}excels/{dn.id}.xlsx"
         assert os.path.exists(dn.path) is exists
         assert os.path.exists(dn.path) is exists
 
 
     def test_read_write_after_modify_path(self):
     def test_read_write_after_modify_path(self):
@@ -443,7 +445,7 @@ class TestExcelDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+        assert str(NoFileToDownload(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_is_not_downloadable_not_a_file(self):
     def test_is_not_downloadable_not_a_file(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -451,12 +453,12 @@ class TestExcelDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NotAFile(path, dn.id)) in reasons.reasons
+        assert str(NotAFile(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_get_download_path(self):
     def test_get_download_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.xlsx")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.xlsx")
         dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
         dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
-        assert dn._get_downloadable_path() == path
+        assert re.split(r"[\\/]", dn._get_downloadable_path()) == re.split(r"[\\/]", path)
 
 
     def test_get_downloadable_path_with_not_existing_file(self):
     def test_get_downloadable_path_with_not_existing_file(self):
         dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.xlsx", "exposed_type": "pandas"})
         dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.xlsx", "exposed_type": "pandas"})
@@ -477,7 +479,7 @@ class TestExcelDataNode:
 
 
         assert_frame_equal(dn.read()["Sheet1"], upload_content)  # The data of dn should change to the uploaded content
         assert_frame_equal(dn.read()["Sheet1"], upload_content)  # The data of dn should change to the uploaded content
         assert dn.last_edit_date > old_last_edit_date
         assert dn.last_edit_date > old_last_edit_date
-        assert dn.path == old_xlsx_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_xlsx_path)  # The path of the dn should not change
 
 
     def test_upload_with_upload_check_pandas(self, excel_file, tmpdir_factory):
     def test_upload_with_upload_check_pandas(self, excel_file, tmpdir_factory):
         old_xlsx_path = tmpdir_factory.mktemp("data").join("df.xlsx").strpath
         old_xlsx_path = tmpdir_factory.mktemp("data").join("df.xlsx").strpath
@@ -523,7 +525,7 @@ class TestExcelDataNode:
 
 
         assert_frame_equal(dn.read()["Sheet1"], old_data)  # The content of the dn should not change when upload fails
         assert_frame_equal(dn.read()["Sheet1"], old_data)  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_xlsx_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_xlsx_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_column() return True
         # The upload should succeed when check_data_column() return True
         assert dn._upload(excel_file, upload_checker=check_data_column)
         assert dn._upload(excel_file, upload_checker=check_data_column)
@@ -572,7 +574,7 @@ class TestExcelDataNode:
 
 
         np.array_equal(dn.read()["Sheet1"], old_data)  # The content of the dn should not change when upload fails
         np.array_equal(dn.read()["Sheet1"], old_data)  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_excel_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_excel_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_is_positive() return True
         # The upload should succeed when check_data_is_positive() return True
         assert dn._upload(new_excel_path, upload_checker=check_data_is_positive)
         assert dn._upload(new_excel_path, upload_checker=check_data_is_positive)

+ 8 - 6
tests/core/data/test_json_data_node.py

@@ -13,6 +13,7 @@ import datetime
 import json
 import json
 import os
 import os
 import pathlib
 import pathlib
+import re
 import uuid
 import uuid
 from dataclasses import dataclass
 from dataclasses import dataclass
 from enum import Enum
 from enum import Enum
@@ -26,6 +27,7 @@ import pytest
 from taipy.common.config import Config
 from taipy.common.config import Config
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
+from taipy.core.common._utils import _normalize_path
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data.data_node_id import DataNodeId
 from taipy.core.data.data_node_id import DataNodeId
@@ -336,7 +338,7 @@ class TestJSONDataNode:
     )
     )
     def test_create_with_default_data(self, properties, exists):
     def test_create_with_default_data(self, properties, exists):
         dn = JSONDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
         dn = JSONDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
-        assert dn.path == os.path.join(Config.core.storage_folder.strip("/"), "jsons", dn.id + ".json")
+        assert dn.path == f"{Config.core.storage_folder}jsons/{dn.id}.json"
         assert os.path.exists(dn.path) is exists
         assert os.path.exists(dn.path) is exists
 
 
     def test_set_path(self):
     def test_set_path(self):
@@ -405,7 +407,7 @@ class TestJSONDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+        assert str(NoFileToDownload(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def is_not_downloadable_not_a_file(self):
     def is_not_downloadable_not_a_file(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json")
@@ -413,12 +415,12 @@ class TestJSONDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NotAFile(path, dn.id)) in reasons.reasons
+        assert str(NotAFile(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_get_download_path(self):
     def test_get_download_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json/example_dict.json")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json/example_dict.json")
         dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": path})
         dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": path})
-        assert dn._get_downloadable_path() == path
+        assert re.split(r"[\\/]", dn._get_downloadable_path()) == re.split(r"[\\/]", path)
 
 
     def test_get_download_path_with_not_existed_file(self):
     def test_get_download_path_with_not_existed_file(self):
         dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTED.json"})
         dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTED.json"})
@@ -440,7 +442,7 @@ class TestJSONDataNode:
 
 
         assert dn.read() == upload_content  # The content of the dn should change to the uploaded content
         assert dn.read() == upload_content  # The content of the dn should change to the uploaded content
         assert dn.last_edit_date > old_last_edit_date
         assert dn.last_edit_date > old_last_edit_date
-        assert dn.path == old_json_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_json_path)  # The path of the dn should not change
 
 
     def test_upload_with_upload_check(self, json_file, tmpdir_factory):
     def test_upload_with_upload_check(self, json_file, tmpdir_factory):
         old_json_path = tmpdir_factory.mktemp("data").join("df.json").strpath
         old_json_path = tmpdir_factory.mktemp("data").join("df.json").strpath
@@ -486,7 +488,7 @@ class TestJSONDataNode:
 
 
         assert dn.read() == old_data  # The content of the dn should not change when upload fails
         assert dn.read() == old_data  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_json_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_json_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_keys() return True
         # The upload should succeed when check_data_keys() return True
         assert dn._upload(json_file, upload_checker=check_data_keys)
         assert dn._upload(json_file, upload_checker=check_data_keys)

+ 9 - 7
tests/core/data/test_parquet_data_node.py

@@ -11,6 +11,7 @@
 
 
 import os
 import os
 import pathlib
 import pathlib
+import re
 import uuid
 import uuid
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from importlib import util
 from importlib import util
@@ -25,6 +26,7 @@ from pandas.testing import assert_frame_equal
 from taipy.common.config import Config
 from taipy.common.config import Config
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
+from taipy.core.common._utils import _normalize_path
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data.data_node_id import DataNodeId
 from taipy.core.data.data_node_id import DataNodeId
@@ -156,7 +158,7 @@ class TestParquetDataNode:
     )
     )
     def test_create_with_default_data(self, properties, exists):
     def test_create_with_default_data(self, properties, exists):
         dn = ParquetDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
         dn = ParquetDataNode("foo", Scope.SCENARIO, DataNodeId(f"dn_id_{uuid.uuid4()}"), properties=properties)
-        assert dn.path == os.path.join(Config.core.storage_folder.strip("/"), "parquets", dn.id + ".parquet")
+        assert dn.path == f"{Config.core.storage_folder}parquets/{dn.id}.parquet"
         assert os.path.exists(dn.path) is exists
         assert os.path.exists(dn.path) is exists
 
 
     @pytest.mark.parametrize("engine", __engine)
     @pytest.mark.parametrize("engine", __engine)
@@ -262,7 +264,7 @@ class TestParquetDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+        assert str(NoFileToDownload(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_is_not_downloadable_not_a_file(self):
     def test_is_not_downloadable_not_a_file(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -270,12 +272,12 @@ class TestParquetDataNode:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NotAFile(path, dn.id)) in reasons.reasons
+        assert str(NotAFile(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_get_downloadable_path(self):
     def test_get_downloadable_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.parquet")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.parquet")
         dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
         dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
-        assert dn._get_downloadable_path() == path
+        assert re.split(r"[\\/]", dn._get_downloadable_path()) == re.split(r"[\\/]", path)
 
 
     def test_get_downloadable_path_with_not_existing_file(self):
     def test_get_downloadable_path_with_not_existing_file(self):
         dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.parquet"})
         dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.parquet"})
@@ -301,7 +303,7 @@ class TestParquetDataNode:
 
 
         assert_frame_equal(dn.read(), upload_content)  # The content of the dn should change to the uploaded content
         assert_frame_equal(dn.read(), upload_content)  # The content of the dn should change to the uploaded content
         assert dn.last_edit_date > old_last_edit_date
         assert dn.last_edit_date > old_last_edit_date
-        assert dn.path == old_parquet_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_parquet_path)  # The path of the dn should not change
 
 
     def test_upload_with_upload_check_pandas(self, parquet_file_path, tmpdir_factory):
     def test_upload_with_upload_check_pandas(self, parquet_file_path, tmpdir_factory):
         old_parquet_path = tmpdir_factory.mktemp("data").join("df.parquet").strpath
         old_parquet_path = tmpdir_factory.mktemp("data").join("df.parquet").strpath
@@ -346,7 +348,7 @@ class TestParquetDataNode:
 
 
         assert_frame_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert_frame_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_parquet_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_parquet_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_column() return True
         # The upload should succeed when check_data_column() return True
         assert dn._upload(parquet_file_path, upload_checker=check_data_column)
         assert dn._upload(parquet_file_path, upload_checker=check_data_column)
@@ -396,7 +398,7 @@ class TestParquetDataNode:
 
 
         np.array_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         np.array_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_parquet_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_parquet_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_is_positive() return True
         # The upload should succeed when check_data_is_positive() return True
         assert dn._upload(new_parquet_path, upload_checker=check_data_is_positive)
         assert dn._upload(new_parquet_path, upload_checker=check_data_is_positive)

+ 7 - 5
tests/core/data/test_pickle_data_node.py

@@ -12,6 +12,7 @@
 import os
 import os
 import pathlib
 import pathlib
 import pickle
 import pickle
+import re
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from time import sleep
 from time import sleep
 
 
@@ -23,6 +24,7 @@ from pandas.testing import assert_frame_equal
 from taipy.common.config import Config
 from taipy.common.config import Config
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.common.scope import Scope
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
 from taipy.common.config.exceptions.exceptions import InvalidConfigurationId
+from taipy.core.common._utils import _normalize_path
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data.pickle import PickleDataNode
 from taipy.core.data.pickle import PickleDataNode
@@ -220,7 +222,7 @@ class TestPickleDataNodeEntity:
         assert not reasons
         assert not reasons
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+        assert str(NoFileToDownload(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_is_not_downloadable_not_a_file(self):
     def test_is_not_downloadable_not_a_file(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -228,12 +230,12 @@ class TestPickleDataNodeEntity:
         reasons = dn.is_downloadable()
         reasons = dn.is_downloadable()
         assert not reasons
         assert not reasons
         assert len(reasons._reasons) == 1
         assert len(reasons._reasons) == 1
-        assert str(NotAFile(path, dn.id)) in reasons.reasons
+        assert str(NotAFile(_normalize_path(path), dn.id)) in reasons.reasons
 
 
     def test_get_download_path(self):
     def test_get_download_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.p")
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.p")
         dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": path})
         dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": path})
-        assert dn._get_downloadable_path() == path
+        assert re.split(r"[\\/]", dn._get_downloadable_path()) == re.split(r"[\\/]", path)
 
 
     def test_get_download_path_with_not_existed_file(self):
     def test_get_download_path_with_not_existed_file(self):
         dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTED.p"})
         dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTED.p"})
@@ -254,7 +256,7 @@ class TestPickleDataNodeEntity:
 
 
         assert_frame_equal(dn.read(), upload_content)  # The content of the dn should change to the uploaded content
         assert_frame_equal(dn.read(), upload_content)  # The content of the dn should change to the uploaded content
         assert dn.last_edit_date > old_last_edit_date
         assert dn.last_edit_date > old_last_edit_date
-        assert dn.path == old_pickle_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_pickle_path)  # The path of the dn should not change
 
 
     def test_upload_with_upload_check(self, pickle_file_path, tmpdir_factory):
     def test_upload_with_upload_check(self, pickle_file_path, tmpdir_factory):
         old_pickle_path = tmpdir_factory.mktemp("data").join("df.p").strpath
         old_pickle_path = tmpdir_factory.mktemp("data").join("df.p").strpath
@@ -299,7 +301,7 @@ class TestPickleDataNodeEntity:
 
 
         assert_frame_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert_frame_equal(dn.read(), old_data)  # The content of the dn should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
         assert dn.last_edit_date == old_last_edit_date  # The last edit date should not change when upload fails
-        assert dn.path == old_pickle_path  # The path of the dn should not change
+        assert dn.path == _normalize_path(old_pickle_path)  # The path of the dn should not change
 
 
         # The upload should succeed when check_data_column() return True
         # The upload should succeed when check_data_column() return True
         assert dn._upload(pickle_file_path, upload_checker=check_data_column)
         assert dn._upload(pickle_file_path, upload_checker=check_data_column)

+ 4 - 10
tests/gui_core/test_context_is_editable.py

@@ -254,10 +254,9 @@ class TestGuiCoreContext_is_editable:
                 MockState(assign=assign),
                 MockState(assign=assign),
                 "",
                 "",
                 {
                 {
-                    "args": [
-                        {"id": a_datanode.id},
-                    ],
-                    "error_id": "error_var",
+                    "args": [{
+                        "id": a_datanode.id,
+                        "error_id": "error_var"}],
                 },
                 },
             )
             )
             assign.assert_called()
             assign.assert_called()
@@ -269,12 +268,7 @@ class TestGuiCoreContext_is_editable:
                 gui_core_context.update_data(
                 gui_core_context.update_data(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
-                        "args": [
-                            {"id": a_datanode.id},
-                        ],
-                        "error_id": "error_var",
-                    },
+                    {"args": [{"id": a_datanode.id, "error_id": "error_var"}]},
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"

+ 38 - 45
tests/gui_core/test_context_is_readable.py

@@ -27,8 +27,9 @@ from taipy.core.scenario._scenario_manager_factory import _ScenarioManagerFactor
 from taipy.core.submission._submission_manager_factory import _SubmissionManagerFactory
 from taipy.core.submission._submission_manager_factory import _SubmissionManagerFactory
 from taipy.core.submission.submission import Submission, SubmissionStatus
 from taipy.core.submission.submission import Submission, SubmissionStatus
 from taipy.core.task._task_manager_factory import _TaskManagerFactory
 from taipy.core.task._task_manager_factory import _TaskManagerFactory
-from taipy.gui import Gui
+from taipy.gui import Gui, State
 from taipy.gui_core._context import _GuiCoreContext
 from taipy.gui_core._context import _GuiCoreContext
+from taipy.gui_core._utils import _ClientStatus
 
 
 a_cycle = Cycle(Frequency.DAILY, {}, datetime.now(), datetime.now(), datetime.now(), id=CycleId("CYCLE_id"))
 a_cycle = Cycle(Frequency.DAILY, {}, datetime.now(), datetime.now(), datetime.now(), id=CycleId("CYCLE_id"))
 a_scenario = Scenario("scenario_config_id", None, {}, sequences={"sequence": {}})
 a_scenario = Scenario("scenario_config_id", None, {}, sequences={"sequence": {}})
@@ -66,9 +67,14 @@ def mock_core_get(entity_id):
     return a_task
     return a_task
 
 
 
 
-class MockState:
+class MockState(State):
     def __init__(self, **kwargs) -> None:
     def __init__(self, **kwargs) -> None:
-        self.assign = kwargs.get("assign")
+        self.assign = t.cast(t.Callable, kwargs.get("assign")) # type: ignore[method-assign]
+        self.gui = t.cast(Gui, kwargs.get("gui"))
+    def get_gui(self):
+        return self.gui
+    def broadcast(self, name: str, value: t.Any):
+        pass
 
 
 
 
 class TestGuiCoreContext_is_readable:
 class TestGuiCoreContext_is_readable:
@@ -96,7 +102,7 @@ class TestGuiCoreContext_is_readable:
     def test_cycle_adapter(self):
     def test_cycle_adapter(self):
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get):
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get):
             gui_core_context = _GuiCoreContext(Mock())
             gui_core_context = _GuiCoreContext(Mock())
-            gui_core_context.scenario_by_cycle = {"a": 1}
+            gui_core_context.scenario_by_cycle = t.cast(dict, {"a": 1})
             outcome = gui_core_context.cycle_adapter(a_cycle)
             outcome = gui_core_context.cycle_adapter(a_cycle)
             assert isinstance(outcome, list)
             assert isinstance(outcome, list)
             assert outcome[0] == a_cycle.id
             assert outcome[0] == a_cycle.id
@@ -120,9 +126,9 @@ class TestGuiCoreContext_is_readable:
             gui_core_context = _GuiCoreContext(Mock())
             gui_core_context = _GuiCoreContext(Mock())
             assign = Mock()
             assign = Mock()
             gui_core_context.crud_scenario(
             gui_core_context.crud_scenario(
-                MockState(assign=assign),
+                MockState(assign=assign, gui=gui_core_context.gui),
                 "",
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                     "args": [
                         "",
                         "",
                         "",
                         "",
@@ -132,7 +138,7 @@ class TestGuiCoreContext_is_readable:
                         {"name": "name", "id": a_scenario.id},
                         {"name": "name", "id": a_scenario.id},
                     ],
                     ],
                     "error_id": "error_var",
                     "error_id": "error_var",
-                },
+                }),
             )
             )
             assign.assert_not_called()
             assign.assert_not_called()
 
 
@@ -141,7 +147,7 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.crud_scenario(
                 gui_core_context.crud_scenario(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                         "args": [
                             "",
                             "",
                             "",
                             "",
@@ -151,7 +157,7 @@ class TestGuiCoreContext_is_readable:
                             {"name": "name", "id": a_scenario.id},
                             {"name": "name", "id": a_scenario.id},
                         ],
                         ],
                         "error_id": "error_var",
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"
@@ -164,12 +170,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.edit_entity(
             gui_core_context.edit_entity(
                 MockState(assign=assign),
                 MockState(assign=assign),
                 "",
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                     "args": [
                         {"name": "name", "id": a_scenario.id},
                         {"name": "name", "id": a_scenario.id},
                     ],
                     ],
                     "error_id": "error_var",
                     "error_id": "error_var",
-                },
+                }),
             )
             )
             assign.assert_called_once()
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[0] == "error_var"
@@ -180,12 +186,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.edit_entity(
                 gui_core_context.edit_entity(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                         "args": [
                             {"name": "name", "id": a_scenario.id},
                             {"name": "name", "id": a_scenario.id},
                         ],
                         ],
                         "error_id": "error_var",
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"
@@ -198,10 +204,7 @@ class TestGuiCoreContext_is_readable:
             mockGui._get_authorization = lambda s: contextlib.nullcontext()
             mockGui._get_authorization = lambda s: contextlib.nullcontext()
             gui_core_context = _GuiCoreContext(mockGui)
             gui_core_context = _GuiCoreContext(mockGui)
 
 
-            def sub_cb():
-                return True
-
-            gui_core_context.client_submission[a_submission.id] = SubmissionStatus.UNDEFINED
+            gui_core_context.client_submission[a_submission.id] = _ClientStatus("client_id", SubmissionStatus.UNDEFINED)
             gui_core_context.submission_status_callback(a_submission.id)
             gui_core_context.submission_status_callback(a_submission.id)
             mockget.assert_called()
             mockget.assert_called()
             found = False
             found = False
@@ -248,12 +251,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.act_on_jobs(
             gui_core_context.act_on_jobs(
                 MockState(assign=assign),
                 MockState(assign=assign),
                 "",
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                     "args": [
                         {"id": [a_job.id], "action": "delete"},
                         {"id": [a_job.id], "action": "delete"},
                     ],
                     ],
                     "error_id": "error_var",
                     "error_id": "error_var",
-                },
+                }),
             )
             )
             assign.assert_called_once()
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[0] == "error_var"
@@ -263,12 +266,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.act_on_jobs(
             gui_core_context.act_on_jobs(
                 MockState(assign=assign),
                 MockState(assign=assign),
                 "",
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                     "args": [
                         {"id": [a_job.id], "action": "cancel"},
                         {"id": [a_job.id], "action": "cancel"},
                     ],
                     ],
                     "error_id": "error_var",
                     "error_id": "error_var",
-                },
+                }),
             )
             )
             assign.assert_called_once()
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[0] == "error_var"
@@ -279,12 +282,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.act_on_jobs(
                 gui_core_context.act_on_jobs(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                         "args": [
                             {"id": [a_job.id], "action": "delete"},
                             {"id": [a_job.id], "action": "delete"},
                         ],
                         ],
                         "error_id": "error_var",
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"
@@ -294,12 +297,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.act_on_jobs(
                 gui_core_context.act_on_jobs(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                         "args": [
                             {"id": [a_job.id], "action": "cancel"},
                             {"id": [a_job.id], "action": "cancel"},
                         ],
                         ],
                         "error_id": "error_var",
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"
@@ -312,12 +315,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.edit_data_node(
             gui_core_context.edit_data_node(
                 MockState(assign=assign),
                 MockState(assign=assign),
                 "",
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                     "args": [
                         {"id": a_datanode.id},
                         {"id": a_datanode.id},
                     ],
                     ],
                     "error_id": "error_var",
                     "error_id": "error_var",
-                },
+                }),
             )
             )
             assign.assert_called_once()
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[0] == "error_var"
@@ -328,12 +331,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.edit_data_node(
                 gui_core_context.edit_data_node(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                         "args": [
                             {"id": a_datanode.id},
                             {"id": a_datanode.id},
                         ],
                         ],
                         "error_id": "error_var",
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"
@@ -348,12 +351,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.lock_datanode_for_edit(
             gui_core_context.lock_datanode_for_edit(
                 MockState(assign=assign),
                 MockState(assign=assign),
                 "",
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                     "args": [
                         {"id": a_datanode.id},
                         {"id": a_datanode.id},
                     ],
                     ],
                     "error_id": "error_var",
                     "error_id": "error_var",
-                },
+                }),
             )
             )
             assign.assert_called_once()
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[0] == "error_var"
@@ -364,12 +367,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.lock_datanode_for_edit(
                 gui_core_context.lock_datanode_for_edit(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                         "args": [
                             {"id": a_datanode.id},
                             {"id": a_datanode.id},
                         ],
                         ],
                         "error_id": "error_var",
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"
@@ -395,12 +398,7 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.update_data(
             gui_core_context.update_data(
                 MockState(assign=assign),
                 MockState(assign=assign),
                 "",
                 "",
-                {
-                    "args": [
-                        {"id": a_datanode.id},
-                    ],
-                    "error_id": "error_var",
-                },
+                t.cast(dict, {"args": [{"id": a_datanode.id, "error_id": "error_var"}]})
             )
             )
             assign.assert_called()
             assign.assert_called()
             assert assign.call_args_list[0].args[0] == "error_var"
             assert assign.call_args_list[0].args[0] == "error_var"
@@ -411,12 +409,7 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.update_data(
                 gui_core_context.update_data(
                     MockState(assign=assign),
                     MockState(assign=assign),
                     "",
                     "",
-                    {
-                        "args": [
-                            {"id": a_datanode.id},
-                        ],
-                        "error_id": "error_var",
-                    },
+                    t.cast(dict, {"args": [{"id": a_datanode.id, "error_id": "error_var"}]})
                 )
                 )
                 assign.assert_called_once()
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
                 assert assign.call_args.args[0] == "error_var"

+ 209 - 0
tests/gui_core/test_context_on_file_action.py

@@ -0,0 +1,209 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import typing as t
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import pytest
+
+from taipy import DataNode, Gui, Scope
+from taipy.core.data import PickleDataNode
+from taipy.core.data._data_manager_factory import _DataManagerFactory
+from taipy.core.data._file_datanode_mixin import _FileDataNodeMixin
+from taipy.core.reason import Reason, ReasonCollection
+from taipy.gui_core._context import _GuiCoreContext
+
+dn = PickleDataNode("dn_config_id",
+                    scope = Scope.GLOBAL,
+                    properties={"default_path": "pa/th"})
+
+def core_get(entity_id):
+    if entity_id == dn.id:
+        return dn
+    return None
+
+
+def not_downloadable ():
+    return ReasonCollection()._add_reason(dn.id, Reason("foo"))
+
+
+def downloadable():
+    return ReasonCollection()
+
+
+def not_readable(entity_id):
+    return ReasonCollection()._add_reason(entity_id, Reason("foo"))
+
+
+def readable(entity_id):
+    return ReasonCollection()
+
+
+def mock_checker(**kwargs):
+    return True
+
+
+def check_fails(**kwargs):
+    raise Exception("Failed")
+
+
+def upload_fails (a, b, editor_id, comment):
+    return ReasonCollection()._add_reason(dn.id, Reason("bar"))
+
+
+def download_fails (a, b, editor_id, comment):
+    return ReasonCollection()._add_reason(dn.id, Reason("bar"))
+
+
+class MockState:
+    def __init__(self, **kwargs) -> None:
+        self.assign = kwargs.get("assign")
+
+
+class TestGuiCoreContext_on_file_action:
+
+    @pytest.fixture(scope="class", autouse=True)
+    def set_entities(self):
+        _DataManagerFactory._build_manager()._set(dn)
+
+    def test_does_not_fail_if_wrong_args(self):
+        gui_core_context = _GuiCoreContext(Mock(Gui))
+        gui_core_context.on_file_action(state=Mock(), id="", payload={})
+        gui_core_context.on_file_action(state=Mock(), id="", payload={"args": "wrong_args"})
+        gui_core_context.on_file_action(state=Mock(), id="", payload={"args": ["wrong_args"]})
+
+    def test_datanode_not_readable(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=not_readable):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(DataNode, "write") as mock_write:
+                    mockGui = Mock(Gui)
+                    mockGui._get_client_id = lambda: "a_client_id"
+                    gui_core_context = _GuiCoreContext(mockGui)
+                    assign = Mock()
+                    gui_core_context.on_file_action(
+                        state=MockState(assign=assign),
+                        id="",
+                        payload={"args": [{"id": dn.id, "error_id": "error_var"}]},
+                    )
+                    mock_core_get.assert_not_called()
+                    mock_write.assert_not_called()
+                    assign.assert_called_once_with("error_var", "foo.")
+
+    def test_upload_file_without_checker(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=readable):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(_FileDataNodeMixin, "_upload") as mock_upload:
+                    mockGui = Mock(Gui)
+                    mockGui._get_client_id = lambda: "a_client_id"
+                    gui_core_context = _GuiCoreContext(mockGui)
+                    assign = Mock()
+                    gui_core_context.on_file_action(
+                        state=MockState(assign=assign),
+                        id="",
+                        payload={"args": [{"id": dn.id, "error_id": "error_var", "path": "pa/th"}]},
+                    )
+                    mock_core_get.assert_called_once_with(dn.id)
+                    mock_upload.assert_called_once_with(
+                        "pa/th",
+                        None,
+                        editor_id="a_client_id",
+                        comment=None)
+                    assign.assert_not_called()
+
+    def test_upload_file_with_checker(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=readable):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(_FileDataNodeMixin, "_upload") as mock_upload:
+                    mockGui = Mock(Gui)
+                    mockGui._get_client_id = lambda: "a_client_id"
+                    mockGui._get_user_function = lambda _ : _
+                    gui_core_context = _GuiCoreContext(mockGui)
+                    assign = Mock()
+                    gui_core_context.on_file_action(
+                        state=MockState(assign=assign),
+                        id="",
+                        payload={"args": [
+                            {"id": dn.id, "error_id": "error_var", "path": "pa/th", "upload_check": mock_checker}]},
+                    )
+                    mock_core_get.assert_called_once_with(dn.id)
+                    mock_upload.assert_called_once_with(
+                        "pa/th",
+                        t.cast(t.Callable[[str, t.Any], bool], mock_checker),
+                        editor_id="a_client_id",
+                        comment=None)
+                    assign.assert_not_called()
+
+    def test_upload_file_with_failing_checker(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=readable):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(_FileDataNodeMixin, "_upload", side_effect=upload_fails) as mock_upload:
+                    mockGui = Mock(Gui)
+                    mockGui._get_client_id = lambda: "a_client_id"
+                    mockGui._get_user_function = lambda _ : _
+                    gui_core_context = _GuiCoreContext(mockGui)
+                    assign = Mock()
+                    gui_core_context.on_file_action(
+                        state=MockState(assign=assign),
+                        id="",
+                        payload={"args": [
+                            {"id": dn.id, "error_id": "error_var", "path": "pa/th", "upload_check": check_fails}]},
+                    )
+                    mock_core_get.assert_called_once_with(dn.id)
+                    mock_upload.assert_called_once_with(
+                        "pa/th",
+                        t.cast(t.Callable[[str, t.Any], bool], check_fails),
+                        editor_id="a_client_id",
+                        comment=None)
+                    assign.assert_called_once_with("error_var", "Data unavailable: bar.")
+
+    def test_download_file_not_downloadable(self):
+        with patch.object(_FileDataNodeMixin, "is_downloadable", side_effect=not_downloadable):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(_FileDataNodeMixin, "_get_downloadable_path") as mock_download:
+                    mockGui = Mock(Gui)
+                    mockGui._get_client_id = lambda: "a_client_id"
+                    mockGui._get_user_function = lambda _ : _
+                    gui_core_context = _GuiCoreContext(mockGui)
+                    assign = Mock()
+                    gui_core_context.on_file_action(
+                        state=MockState(assign=assign),
+                        id="",
+                        payload={"args": [
+                            {"id": dn.id,
+                             "action": "export",
+                             "error_id": "error_var"}]},
+                    )
+                    mock_core_get.assert_called_once_with(dn.id)
+                    mock_download.assert_not_called()
+                    assign.assert_called_once_with("error_var", "Data unavailable: foo.")
+
+    def test_download(self):
+        with patch.object(_FileDataNodeMixin, "is_downloadable", side_effect=downloadable):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(_FileDataNodeMixin, "_get_downloadable_path") as mock_download:
+                    mockGui = Mock(Gui)
+                    mockGui._get_client_id = lambda: "a_client_id"
+                    mockGui._download.return_value = None
+                    gui_core_context = _GuiCoreContext(mockGui)
+                    assign = Mock()
+                    gui_core_context.on_file_action(
+                        state=MockState(assign=assign),
+                        id="",
+                        payload={"args": [
+                            {"id": dn.id,
+                             "action": "export",
+                             "error_id": "error_var"}]},
+                    )
+                    mock_core_get.assert_called_once_with(dn.id)
+                    mock_download.assert_called_once()
+                    mockGui._download.assert_called_once_with(Path(dn._get_downloadable_path()), dn.id)
+                    assign.assert_not_called()

+ 388 - 0
tests/gui_core/test_context_tabular_data_edit.py

@@ -0,0 +1,388 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+from unittest.mock import Mock, patch
+
+import pandas as pd
+
+from taipy import DataNode, Gui
+from taipy.common.config.common.scope import Scope
+from taipy.core.data._data_manager_factory import _DataManagerFactory
+from taipy.core.data.pickle import PickleDataNode
+from taipy.core.reason import Reason, ReasonCollection
+from taipy.gui_core._context import _GuiCoreContext
+
+dn = PickleDataNode("dn_config_id", scope = Scope.GLOBAL)
+
+
+def core_get(entity_id):
+    if entity_id == dn.id:
+        return dn
+    return None
+
+
+def is_false(entity_id):
+    return ReasonCollection()._add_reason(entity_id, Reason("foo"))
+
+
+def is_true(entity_id):
+    return True
+
+def fails(**kwargs):
+    raise Exception("Failed")
+
+
+class MockState:
+    def __init__(self, **kwargs) -> None:
+        self.assign = kwargs.get("assign")
+
+
+class TestGuiCoreContext_update_data:
+
+    def test_do_not_edit_tabular_data_if_not_readable(self):
+        _DataManagerFactory._build_manager()._set(dn)
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_false):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(DataNode, "write") as mock_write:
+                    assign = self.__call_update_data()
+
+                    mock_core_get.assert_not_called()
+                    mock_write.assert_not_called()
+                    assign.assert_called_once_with("error_var", f"Data node {dn.id} is not readable: foo.")
+
+    def test_do_not_edit_tabular_data_if_not_editable(self):
+        _DataManagerFactory._build_manager()._set(dn)
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_false):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data()
+
+                        mock_core_get.assert_not_called()
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with("error_var", f"Data node {dn.id} is not editable: foo.")
+
+    def test_edit_pandas_data(self):
+        dn.write(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
+        idx = 0
+        col = "a"
+        new_value = 100
+        new_data = pd.DataFrame({"a": [new_value, 2, 3], "b": [4, 5, 6]})
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once()
+                        # Cannot use the following line because of the pandas DataFrame comparison
+                        # mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None
+                        # Instead, we will compare the arguments of the call manually
+                        assert mock_write.call_args_list[0].args[0].equals(new_data)
+                        assert mock_write.call_args_list[0].kwargs["editor_id"] == "a_client_id"
+                        assert mock_write.call_args_list[0].kwargs["comment"] is None
+                        assign.assert_called_once_with("error_var", "")
+
+    def __call_update_data(self, col=None, idx=None, new_value=None):
+        mockGui = Mock(Gui)
+        mockGui._get_client_id = lambda: "a_client_id"
+        gui_core_context = _GuiCoreContext(mockGui)
+        payload = {"user_data": {"dn_id": dn.id}, "error_id": "error_var"}
+        if idx is not None:
+            payload["index"] = idx
+        if col is not None:
+            payload["col"] = col
+        if new_value is not None:
+            payload["value"] = new_value
+        assign = Mock()
+        gui_core_context.tabular_data_edit(
+            state=MockState(assign=assign),
+            var_name="",
+            payload=payload,
+        )
+        return assign
+
+    def test_edit_pandas_wrong_idx(self):
+        data = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+        dn.write(data)
+        idx = 5
+        col = "a"
+        new_value = 100
+        new_data = data.copy()
+        new_data.at[idx, col] = new_value
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+
+                        assign = self.__call_update_data(col, idx, new_value)
+
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once()
+                        # Cannot use the following line because of the pandas DataFrame comparison
+                        # mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None
+                        # Instead, we will compare the arguments of the call manually
+                        assert mock_write.call_args_list[0].args[0].equals(new_data)
+                        assert mock_write.call_args_list[0].kwargs["editor_id"] == "a_client_id"
+                        assert mock_write.call_args_list[0].kwargs["comment"] is None
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_edit_pandas_wrong_col(self):
+        data = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+        dn.write(data)
+        idx = 0
+        col = "c"
+        new_value = 100
+        new_data = data.copy()
+        new_data.at[idx, col] = new_value
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once()
+                        # Cannot use the following line because of the pandas DataFrame comparison
+                        # mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None
+                        # Instead, we will compare the arguments of the call manually
+                        assert mock_write.call_args_list[0].args[0].equals(new_data)
+                        assert mock_write.call_args_list[0].kwargs["editor_id"] == "a_client_id"
+                        assert mock_write.call_args_list[0].kwargs["comment"] is None
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_edit_pandas_series(self):
+        data = pd.Series([1, 2, 3])
+        dn.write(data)
+        idx = 0
+        col = "WHATEVER"
+        new_value = 100
+        new_data = pd.Series([100, 2, 3])
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once()
+                        # Cannot use the following line because of the pandas Series comparison
+                        # mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None
+                        # Instead, we will compare the arguments of the call manually
+                        assert mock_write.call_args_list[0].args[0].equals(new_data)
+                        assert mock_write.call_args_list[0].kwargs["editor_id"] == "a_client_id"
+                        assert mock_write.call_args_list[0].kwargs["comment"] is None
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_edit_pandas_series_wrong_idx(self):
+        data = pd.Series([1, 2, 3])
+        dn.write(data)
+        idx = 5
+        col = "WHATEVER"
+        new_value = 100
+        new_data = data.copy()
+        new_data.at[idx] = new_value
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once()
+                        # Cannot use the following line because of the pandas Series comparison
+                        # mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None
+                        # Instead, we will compare the arguments of the call manually
+                        assert mock_write.call_args_list[0].args[0].equals(new_data)
+                        assert mock_write.call_args_list[0].kwargs["editor_id"] == "a_client_id"
+                        assert mock_write.call_args_list[0].kwargs["comment"] is None
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_edit_dict(self):
+        data = {"a": [1, 2, 3], "b": [4, 5, 6]}
+        dn.write(data)
+        idx = 0
+        col = "a"
+        new_value = 100
+        new_data = {"a": [100, 2, 3], "b": [4, 5, 6]}
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None)
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_edit_dict_wrong_idx(self):
+        data = {"a": [1, 2, 3], "b": [4, 5, 6]}
+        dn.write(data)
+        idx = 5
+        col = "a"
+        new_value = 100
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with(
+                            "error_var",
+                            "Error updating data node tabular value. list assignment index out of range")
+
+    def test_edit_dict_wrong_col(self):
+        data = {"a": [1, 2, 3], "b": [4, 5, 6]}
+        dn.write(data)
+        idx = 0
+        col = "c"
+        new_value = 100
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with(
+                            "error_var",
+                            "Error updating Data node: dict values must be list or tuple.")
+
+    def test_edit_dict_of_tuples(self):
+        data = {"a": (1, 2, 3), "b": (4, 5, 6)}
+        dn.write(data)
+        idx = 0
+        col = "a"
+        new_value = 100
+        new_data = {"a": (100, 2, 3), "b": (4, 5, 6)}
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None)
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_edit_dict_of_tuples_wrong_idx(self):
+        data = {"a": (1, 2, 3), "b": (4, 5, 6)}
+        dn.write(data)
+        idx = 5
+        col = "a"
+        new_value = 100
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with(
+                            "error_var",
+                            "Error updating data node tabular value. list assignment index out of range")
+
+    def test_edit_dict_of_tuples_wrong_col(self):
+        data = {"a": (1, 2, 3), "b": (4, 5, 6)}
+        dn.write(data)
+        idx = 0
+        col = "c"
+        new_value = 100
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with(
+                            "error_var",
+                            "Error updating Data node: dict values must be list or tuple.")
+
+    def test_edit_wrong_dict(self):
+        data = {"a": 1, "b": 2}
+        dn.write(data)
+        idx = 0
+        col = "a"
+        new_value = 100
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with(
+                            "error_var",
+                            "Error updating Data node: dict values must be list or tuple.")
+
+    def test_edit_list(self):
+        data = [[1, 2, 3], [4, 5, 6]]
+        dn.write(data)
+        idx = 0
+        col = 1
+        new_value = 100
+        new_data = [[1, 100, 3], [4, 5, 6]]
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None)
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_edit_list_wrong_idx(self):
+        data = [[1, 2, 3], [4, 5, 6]]
+        dn.write(data)
+        idx = 5
+        col = 0
+        new_value = 100
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with(
+                            "error_var",
+                            "Error updating data node tabular value. list index out of range")
+
+    def test_edit_list_wrong_col(self):
+        data = [[1, 2, 3], [4, 5, 6]]
+        dn.write(data)
+        idx = 0
+        col = 5
+        new_value = 100
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with(
+                            "error_var",
+                            "Error updating data node tabular value. list assignment index out of range")
+
+    def test_edit_tuple(self):
+        data = ([1, 2, 3], [4, 5, 6])
+        dn.write(data)
+        idx = 0
+        col = 1
+        new_value = 100
+        new_data = ([1, 100, 3], [4, 5, 6])
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        assign = self.__call_update_data(col, idx, new_value)
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(new_data, editor_id="a_client_id", comment=None)
+                        assign.assert_called_once_with("error_var", "")

+ 211 - 0
tests/gui_core/test_context_update_data.py

@@ -0,0 +1,211 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+from datetime import datetime
+from unittest.mock import Mock, patch
+
+import pytest
+
+from taipy import DataNode, Gui
+from taipy.common.config.common.scope import Scope
+from taipy.core.data._data_manager_factory import _DataManagerFactory
+from taipy.core.data.pickle import PickleDataNode
+from taipy.core.reason import Reason, ReasonCollection
+from taipy.gui_core._context import _GuiCoreContext
+
+dn = PickleDataNode("data_node_config_id", Scope.SCENARIO)
+
+
+def core_get(entity_id):
+    if entity_id == dn.id:
+        return dn
+    return None
+
+
+def is_false(entity_id):
+    return ReasonCollection()._add_reason(entity_id, Reason("foo"))
+
+
+def is_true(entity_id):
+    return True
+
+def fails(**kwargs):
+    raise Exception("Failed")
+
+
+class MockState:
+    def __init__(self, **kwargs) -> None:
+        self.assign = kwargs.get("assign")
+
+
+class TestGuiCoreContext_update_data:
+
+    @pytest.fixture(scope="class", autouse=True)
+    def set_entities(self):
+        _DataManagerFactory._build_manager()._set(dn)
+
+    def test_does_not_fail_if_wrong_args(self):
+        gui_core_context = _GuiCoreContext(Mock(Gui))
+        gui_core_context.update_data(state=Mock(), id="", payload={})
+        gui_core_context.update_data(state=Mock(), id="", payload={"args": "wrong_args"})
+        gui_core_context.update_data(state=Mock(), id="", payload={"args": ["wrong_args"]})
+
+    def test_do_not_update_data_if_not_readable(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_false):
+            with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                with patch.object(DataNode, "write") as mock_write:
+                    mockGui = Mock(Gui)
+                    mockGui._get_client_id = lambda: "a_client_id"
+                    gui_core_context = _GuiCoreContext(mockGui)
+                    assign = Mock()
+                    gui_core_context.update_data(
+                        state=MockState(assign=assign),
+                        id="",
+                        payload={"args": [{"id": dn.id,"error_id": "error_var"}]},
+                    )
+                    mock_core_get.assert_not_called()
+                    mock_write.assert_not_called()
+                    assign.assert_called_once_with("error_var", f"Data node {dn.id} is not readable: foo.")
+
+    def test_do_not_update_data_if_not_editable(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_false):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        mockGui = Mock(Gui)
+                        mockGui._get_client_id = lambda: "a_client_id"
+                        gui_core_context = _GuiCoreContext(mockGui)
+                        assign = Mock()
+                        gui_core_context.update_data(
+                            state=MockState(assign=assign),
+                            id="",
+                            payload={"args": [{"id": dn.id,"error_id": "error_var"}]},
+                        )
+                        mock_core_get.assert_not_called()
+                        mock_write.assert_not_called()
+                        assign.assert_called_once_with("error_var", f"Data node {dn.id} is not editable: foo.")
+
+    def test_write_str_data_with_editor_and_comment(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        mockGui = Mock(Gui)
+                        mockGui._get_client_id = lambda: "a_client_id"
+                        gui_core_context = _GuiCoreContext(mockGui)
+                        assign = Mock()
+                        gui_core_context.update_data(
+                            state=MockState(assign=assign),
+                            id="",
+                            payload={
+                                "args": [{
+                                    "id": dn.id,
+                                    "value": "data to write",
+                                    "comment": "The comment",
+                                    "error_id": "error_var"}],
+                            },
+                        )
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with("data to write",
+                                                           editor_id="a_client_id",
+                                                           comment="The comment")
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_write_date_data_with_editor_and_comment(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        mockGui = Mock(Gui)
+                        mockGui._get_client_id = lambda: "a_client_id"
+                        gui_core_context = _GuiCoreContext(mockGui)
+                        assign = Mock()
+                        date = datetime(2000, 1, 1, 0, 0, 0)
+                        gui_core_context.update_data(
+                            state=MockState(assign=assign),
+                            id="",
+                            payload={
+                                "args": [
+                                    {
+                                        "id": dn.id,
+                                        "value": "2000-01-01 00:00:00",
+                                        "type": "date",
+                                        "comment": "The comment",
+                                        "error_id": "error_var"
+                                    }],
+                            },
+                        )
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(date,
+                                                           editor_id="a_client_id",
+                                                           comment="The comment")
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_write_int_data_with_editor_and_comment(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        mockGui = Mock(Gui)
+                        mockGui._get_client_id = lambda: "a_client_id"
+                        gui_core_context = _GuiCoreContext(mockGui)
+                        assign = Mock()
+                        gui_core_context.update_data(
+                            state=MockState(assign=assign),
+                            id="",
+                            payload={
+                                "args": [{"id": dn.id, "value": "1", "type": "int", "error_id": "error_var"}],
+                            },
+                        )
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(1, editor_id="a_client_id", comment=None)
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_write_float_data_with_editor_and_comment(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write") as mock_write:
+                        mockGui = Mock(Gui)
+                        mockGui._get_client_id = lambda: "a_client_id"
+                        gui_core_context = _GuiCoreContext(mockGui)
+                        assign = Mock()
+                        gui_core_context.update_data(
+                            state=MockState(assign=assign),
+                            id="",
+                            payload={
+                                "args": [{"id": dn.id, "value": "1.9", "type": "float", "error_id": "error_var"}],
+                            },
+                        )
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(1.9, editor_id="a_client_id", comment=None)
+                        assign.assert_called_once_with("error_var", "")
+
+    def test_fails_and_catch_the_error(self):
+        with patch("taipy.gui_core._context.is_readable", side_effect=is_true):
+            with patch("taipy.gui_core._context.is_editable", side_effect=is_true):
+                with patch("taipy.gui_core._context.core_get", side_effect=core_get) as mock_core_get:
+                    with patch.object(DataNode, "write", side_effect=fails) as mock_write:
+                        mockGui = Mock(Gui)
+                        mockGui._get_client_id = lambda: "a_client_id"
+                        gui_core_context = _GuiCoreContext(mockGui)
+                        assign = Mock()
+                        gui_core_context.update_data(
+                            state=MockState(assign=assign),
+                            id="",
+                            payload={
+                                "args": [{"id": dn.id, "value": "1.9", "type": "float", "error_id": "error_var"}],
+                            },
+                        )
+                        mock_core_get.assert_called_once_with(dn.id)
+                        mock_write.assert_called_once_with(1.9, editor_id="a_client_id", comment=None)
+                        assign.assert_called_once()
+                        assert assign.call_args_list[0].args[0] == "error_var"
+                        assert "Error updating Data node value." in assign.call_args_list[0].args[1]