Bläddra i källkod

Merge branch 'develop' into feature/#398-expand-exposed-type-parameter

Sohamkumar Chauhan 5 månader sedan
förälder
incheckning
b72cb2d28c
48 ändrade filer med 1965 tillägg och 531 borttagningar
  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.
 # -----------------------------------------------------------------------------------------
 from math import cos, pi, sin, sqrt, tan  # noqa: F401
-from typing import Optional
 
 from taipy.gui import Gui
 
 # The user interacts with the Python interpreter
 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):
     # 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
-    messages.append((f"{len(messages)}", expression, sender_id, image_url))
+    messages.append((f"{len(messages)}", expression, sender_id))
     # Default message used if evaluation fails
     result = "Invalid expression"
     try:
@@ -38,12 +37,12 @@ def evaluate(state, var_name: str, payload: dict):
     except Exception:
         pass
     # 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
 
 
 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")

+ 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
 
 username = ""
-users: list[Union[str, Icon]] = []
+users: list[tuple[str, Union[str, Icon]]] = []
 messages: list[tuple[str, str, str, Optional[str]]] = []
 
 Gui.add_shared_variables("messages", "users")
@@ -82,4 +82,6 @@ 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 = [
     ["1", "msg 1", "Alice", 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],
 ]
 users = [
@@ -25,15 +25,12 @@ users = [
 
 
 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
 
 
-Gui(
-    """
-<|toggle|theme|>
-# Test Chat
+page="""
 <|1 1 1|layout|
 <|{msgs}|chat|users={users}|show_sender={True}|>
 
@@ -42,5 +39,7 @@ Gui(
 <|{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}
                                     </Box>
                                     <ErrorBoundary FallbackComponent={ErrorFallback}>
-                                        <TaipyNotification alerts={state.alerts} />
+                                        <TaipyNotification notifications={state.notifications} />
                                         <UIBlocker block={state.block} />
                                         <Navigate
                                             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 Skeleton from "@mui/material/Skeleton";
 import Tooltip from "@mui/material/Tooltip";
+import merge from "lodash/merge";
 import { nanoid } from "nanoid";
 import {
     Config,
@@ -300,7 +301,7 @@ const Chart = (props: ChartProp) => {
     const theme = useTheme();
     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 active = useDynamicProperty(props.active, props.defaultActive, true);
     const render = useDynamicProperty(props.render, props.defaultRender, true);
@@ -394,12 +395,7 @@ const Chart = (props: ChartProp) => {
             layout.template = template;
         }
         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 {
             ...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 { 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 messages: TableValueType = {
     [valueKey]: {
         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 user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const users = [user1, user2];
@@ -46,32 +55,48 @@ describe("Chat Component", () => {
         expect(input.tagName).toBe("INPUT");
     });
     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);
-        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 () => {
-        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");
         expect(elt.tagName).toBe("IMG");
     });
     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 () => {
-        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 () => {
-        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 () => {
-        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");
         expect(elt).toBeNull();
     });
@@ -81,13 +106,17 @@ describe("Chat Component", () => {
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
     });
     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);
         expect(elt.tagName).toBe("PRE");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
     });
     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);
         expect(elt).toHaveClass("taipy-chat-raw");
     });
@@ -96,7 +125,7 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText } = render(
             <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>
         );
         const elt = getByLabelText("message (taipy)");
@@ -108,7 +137,7 @@ describe("Chat Component", () => {
             context: undefined,
             payload: {
                 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 { getByLabelText, getByRole } = render(
             <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>
         );
         const elt = getByLabelText("message (taipy)");
         await userEvent.click(elt);
         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({
             type: "SEND_ACTION_ACTION",
             name: "",
             context: undefined,
             payload: {
                 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 state: TaipyState = INITIAL_STATE;
-        const { getByLabelText,getByText,getByAltText,queryByText,getByRole } = render(
+        const { getByLabelText, getByText, getByAltText, queryByText, getByRole } = render(
             <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>
         );
-        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();
 
-        const attachButton = getByLabelText('upload image');
+        const attachButton = getByLabelText("upload image");
         expect(attachButton).toBeInTheDocument();
 
-
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         expect(fileInput).toBeInTheDocument();
         fireEvent.change(fileInput, { target: { files: [file] } });
 
         await waitFor(() => {
-            const chipWithImage = getByText('test.png');
+            const chipWithImage = getByText("test.png");
             expect(chipWithImage).toBeInTheDocument();
-            const previewImg = getByAltText('Image preview');
+            const previewImg = getByAltText("Image preview");
             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();
-          });
-          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 state: TaipyState = INITIAL_STATE;
-        const { getByText,getByAltText } = render(
+        const { getByText, getByAltText } = render(
             <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>
         );
-        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;
         expect(fileInput).toBeInTheDocument();
         fireEvent.change(fileInput, { target: { files: [file] } });
 
         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,
     ReactNode,
     lazy,
+    ChangeEvent,
 } from "react";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 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 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 { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { LoVElt, useLovListMemo } from "./lovUtils";
@@ -49,6 +54,7 @@ import { RowType, TableValueType } from "./tableUtils";
 import { Stack } from "@mui/material";
 import { getComponentClassName } from "./TaipyStyle";
 import { noDisplayStyle } from "./utils";
+import { toDataUrl } from "../../utils/image";
 
 const Markdown = lazy(() => import("react-markdown"));
 
@@ -65,6 +71,7 @@ interface ChatProps extends TaipyActiveProps {
     pageSize?: number;
     showSender?: boolean;
     mode?: string;
+    allowSendImages?: boolean;
 }
 
 const ENTER_KEY = "Enter";
@@ -135,7 +142,7 @@ const defaultBoxSx = {
 } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } 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 {
     key: string;
 }
@@ -166,16 +173,11 @@ const ChatRow = (props: ChatRowProps) => {
             justifyContent={sender ? "flex-end" : 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 ? (
                     <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
@@ -227,9 +229,10 @@ const Chat = (props: ChatProps) => {
         onAction,
         withInput = true,
         defaultKey = "",
-        maxFileSize= 1 * 1024 * 1024, // 1 MB
+        maxFileSize = .8 * 1024 * 1024, // 0.8 MB
         pageSize = 50,
         showSender = false,
+        allowSendImages = true,
     } = props;
     const dispatch = useDispatch();
     const module = useModule();
@@ -240,6 +243,7 @@ const Chat = (props: ChatProps) => {
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
+    const [enableSend, setEnableSend] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
     const [selectedFile, setSelectedFile] = useState<File | null>(null);
@@ -269,61 +273,94 @@ const Chat = (props: ChatProps) => {
         [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(
         (evt: KeyboardEvent<HTMLDivElement>) => {
             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();
             }
         },
-        [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
     );
 
     const handleClick = useCallback(
         (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();
         },
-        [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(() => {
         return users.reduce((pv, elt) => {
@@ -477,7 +514,11 @@ const Chat = (props: ChatProps) => {
                                 senderId={senderId}
                                 message={`${row[columns[1]]}`}
                                 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}
                                 getAvatar={getAvatar}
                                 index={idx}
@@ -498,60 +539,67 @@ const Chat = (props: ChatProps) => {
                 </Popper>
                 {withInput ? (
                     <>
-                    {imagePreview && selectedFile && (
+                        {imagePreview && (
                             <Box mb={1}>
                                 <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"
                                 />
                             </Box>
                         )}
-                    <input
+                        <input
                             type="file"
                             ref={fileInputRef}
                             style={noDisplayStyle}
-                            onChange={(e) => handleFileSelect(e)}
+                            onChange={handleFileSelect}
                             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}
                 {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
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "success",
                 duration: 3000,
                 message: "mocked response",
@@ -225,7 +225,7 @@ describe("FileSelector Component", () => {
         // Check if the alert action has been dispatched
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "error",
                 duration: 3000,
                 message: "Upload failed",
@@ -302,7 +302,7 @@ describe("FileSelector Component", () => {
 
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
-        
+
         // Check for input element
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         expect(inputElt).toBeInTheDocument();
@@ -331,7 +331,7 @@ describe("FileSelector Component", () => {
 
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
-        
+
         // Check for input element
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         expect(inputElt).toBeInTheDocument();

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

@@ -22,20 +22,19 @@ import React, {
     useRef,
     useState,
 } from "react";
+import UploadFile from "@mui/icons-material/UploadFile";
 import Button from "@mui/material/Button";
 import LinearProgress from "@mui/material/LinearProgress";
 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 { createAlertAction, createSendActionNameAction } from "../../context/taipyReducers";
+import { createNotificationAction, createSendActionNameAction } from "../../context/taipyReducers";
 import { useClassNames, useDynamicProperty, useModule } from "../../utils/hooks";
-import { expandSx, getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
 import { uploadFile } from "../../workers/fileupload";
-import { SxProps } from "@mui/material";
 import { getComponentClassName } from "./TaipyStyle";
-
-
+import { expandSx, getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
 
 interface FileSelectorProps extends TaipyActiveProps {
     onAction?: string;
@@ -75,22 +74,27 @@ const FileSelector = (props: FileSelectorProps) => {
         notify = true,
         withBorder = true,
     } = props;
-    const directoryProps = ["d", "dir", "directory", "folder"].includes(selectionType?.toLowerCase()) ? 
-                           {webkitdirectory: "", directory: "", mozdirectory: "", nwdirectory: ""} : 
-                           undefined;
     const [dropLabel, setDropLabel] = useState("");
     const [dropSx, setDropSx] = useState<SxProps | undefined>(defaultSx);
     const [upload, setUpload] = useState(false);
     const [progress, setProgress] = useState(0);
     const { state, dispatch } = useContext(TaipyContext);
     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 className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     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(
         () =>
             setDropSx((sx: SxProps | undefined) =>
@@ -123,20 +127,34 @@ const FileSelector = (props: FileSelectorProps) => {
                         onAction && dispatch(createSendActionNameAction(id, module, onAction));
                         notify &&
                             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) => {
                         setUpload(false);
                         notify &&
                             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(

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

@@ -173,6 +173,19 @@ describe("Input Component", () => {
         const visibilityButton = getByLabelText("toggle password visibility");
         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", () => {
@@ -195,9 +208,11 @@ describe("Number Component", () => {
         getByDisplayValue("1");
     });
     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");
         expect(elt).toBeDisabled();
+        const upSpinner = getByLabelText("Increment value");
+        expect(upSpinner).toBeDisabled();
     });
     it("is enabled by default", async () => {
         const { getByDisplayValue } = render(<Input value={"33"} type="number" />);
@@ -309,12 +324,6 @@ describe("Number Component", () => {
         await user.keyboard("[ArrowDown]");
         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", () => {
         const { getByLabelText } = render(<Input id={"Test Input"} type="number" value="0" min={0} />);
         const downSpinner = getByLabelText("Decrement value");
@@ -333,11 +342,4 @@ describe("Number Component", () => {
             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"
                                       size="small"
                                       onMouseDown={handleUpStepperMouseDown}
+                                      disabled={!active}
                                   >
                                       <ArrowDropUpIcon fontSize="inherit" />
                                   </IconButton>
@@ -284,6 +285,7 @@ const Input = (props: TaipyInputProps) => {
                                       aria-label="Decrement value"
                                       size="small"
                                       onMouseDown={handleDownStepperMouseDown}
+                                      disabled={!active}
                                   >
                                       <ArrowDropDownIcon fontSize="inherit" />
                                   </IconButton>
@@ -309,6 +311,7 @@ const Input = (props: TaipyInputProps) => {
                   }
                 : undefined,
         [
+            active,
             type,
             step,
             min,
@@ -330,23 +333,23 @@ const Input = (props: TaipyInputProps) => {
     return (
         <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>
     );

+ 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 { 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";
 
 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 {
     static requestPermission = jest.fn(() => Promise.resolve("granted"));
@@ -39,7 +39,7 @@ describe("Alert Component", () => {
     it("renders", async () => {
         const { getByText } = render(
             <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
+                <TaipyNotification notifications={defaultNotifications} />
             </SnackbarProvider>,
         );
         const elt = getByText(defaultMessage);
@@ -48,7 +48,7 @@ describe("Alert Component", () => {
     it("displays a success alert", async () => {
         const { getByText } = render(
             <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
+                <TaipyNotification notifications={defaultNotifications} />
             </SnackbarProvider>,
         );
         const elt = getByText(defaultMessage);
@@ -57,7 +57,7 @@ describe("Alert Component", () => {
     it("displays an error alert", async () => {
         const { getByText } = render(
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("error")} />
+                <TaipyNotification notifications={getNotificationsWithType("error")} />
             </SnackbarProvider>,
         );
         const elt = getByText(defaultMessage);
@@ -66,7 +66,7 @@ describe("Alert Component", () => {
     it("displays a warning alert", async () => {
         const { getByText } = render(
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("warning")} />
+                <TaipyNotification notifications={getNotificationsWithType("warning")} />
             </SnackbarProvider>,
         );
         const elt = getByText(defaultMessage);
@@ -75,7 +75,7 @@ describe("Alert Component", () => {
     it("displays an info alert", async () => {
         const { getByText } = render(
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("info")} />
+                <TaipyNotification notifications={getNotificationsWithType("info")} />
             </SnackbarProvider>,
         );
         const elt = getByText(defaultMessage);
@@ -86,12 +86,12 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         render(
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
         );
         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 }];
         render(
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
         );
         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 { rerender } = render(
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
         );
         await screen.findByRole("button", { name: /close/i });
         const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         rerender(
             <SnackbarProvider>
-                <Alert alerts={newAlerts} />
+                <TaipyNotification notifications={newAlerts} />
             </SnackbarProvider>,
         );
         await waitFor(() => {
@@ -141,7 +141,7 @@ describe("Alert Component", () => {
     it("does nothing when alert is undefined", async () => {
         render(
             <SnackbarProvider>
-                <Alert alerts={[]} />
+                <TaipyNotification notifications={[]} />
             </SnackbarProvider>,
         );
         expect(Notification.requestPermission).not.toHaveBeenCalled();
@@ -152,12 +152,12 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         render(
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
         );
         const linkElement = document.querySelector("link[rel='icon']");
@@ -169,12 +169,12 @@ describe("Alert Component", () => {
         const link = document.createElement("link");
         link.rel = "icon";
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         render(
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
         );
         const linkElement = document.querySelector("link[rel='icon']");
@@ -187,12 +187,12 @@ describe("Alert Component", () => {
         link.rel = "shortcut icon";
         link.href = "/test-shortcut-icon.png";
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         render(
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
         );
         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 CloseIcon from "@mui/icons-material/Close";
 
-import { AlertMessage, createDeleteAlertAction } from "../../context/taipyReducers";
+import { NotificationMessage, createDeleteAlertAction } from "../../context/taipyReducers";
 import { useDispatch } from "../../utils/hooks";
 
 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 dispatch = useDispatch();
 
-    const resetAlert = useCallback(
+    const resetNotification = useCallback(
         (key: SnackbarKey) => () => {
             closeSnackbar(key);
         },
         [closeSnackbar]
     );
 
-    const notifAction = useCallback(
+    const notificationAction = useCallback(
         (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" />
             </IconButton>
         ),
-        [resetAlert]
+        [resetNotification]
     );
 
     const faviconUrl = useMemo(() => {
@@ -55,25 +55,27 @@ const TaipyNotification = ({ alerts }: NotificationProps) => {
     }, []);
 
     useEffect(() => {
-        if (alert) {
-            const notificationId = alert.notificationId || "";
-            if (alert.atype === "") {
+        if (notification) {
+            const notificationId = notification.notificationId || "";
+            if (notification.atype === "") {
                 closeSnackbar(notificationId);
             } 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,
                 });
-                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));
         }
-    }, [alert, enqueueSnackbar, closeSnackbar, notifAction, faviconUrl, dispatch]);
+    }, [notification, enqueueSnackbar, closeSnackbar, notificationAction, faviconUrl, dispatch]);
+
     useEffect(() => {
-        alert?.system && window.Notification && Notification.requestPermission();
-    }, [alert?.system]);
+        notification?.system && window.Notification && Notification.requestPermission();
+    }, [notification?.system]);
 
     return null;
 };

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

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

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

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

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

@@ -30,8 +30,6 @@ export interface SelTreeProps extends LovProps, TaipyLabelProps {
     filter?: boolean;
     multiple?: boolean;
     width?: string | number;
-    dropdown?: boolean;
-    mode?: string;
 }
 
 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 {
     addRows,
-    AlertMessage,
+    NotificationMessage,
     BlockMessage,
     createAckAction,
-    createAlertAction,
+    createNotificationAction,
     createBlockAction,
     createDownloadAction,
     createIdAction,
@@ -43,6 +43,7 @@ import {
     TaipyBaseAction,
     taipyReducer,
     Types,
+    TaipyState,
 } from "./taipyReducers";
 import { WsMessage } from "./wsUtils";
 import { changeFavicon, getLocalStorageValue, IdMessage } from "./utils";
@@ -50,7 +51,7 @@ import { Socket } from "socket.io-client";
 import { Dispatch } from "react";
 import { parseData } from "../utils/dataFormat";
 import * as wsUtils from "./wsUtils";
-import { nanoid } from 'nanoid';
+import { nanoid } from "nanoid";
 
 jest.mock("./utils", () => ({
     ...jest.requireActual("./utils"),
@@ -85,14 +86,14 @@ describe("reducer", () => {
             } as TaipyBaseAction).locations
         ).toBeDefined();
     });
-    it("set alert", async () => {
+    it("set notification", async () => {
         expect(
             taipyReducer({ ...INITIAL_STATE }, {
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "i",
                 message: "message",
                 system: "system",
-            } as TaipyBaseAction).alerts
+            } as TaipyBaseAction).notifications
         ).toHaveLength(1);
     });
     it("set show block", async () => {
@@ -201,12 +202,20 @@ describe("reducer", () => {
             } as TaipyBaseAction).data.partial
         ).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", () => {
     it("should not change state for SOCKET_CONNECTED action if isSocketConnected is already true", () => {
         const action = { type: Types.SocketConnected };
-        const initialState = { ...INITIAL_STATE, isSocketConnected: true };
+        const initialState: TaipyState = { ...INITIAL_STATE, isSocketConnected: true };
         const newState = taipyReducer(initialState, action);
         const expectedState = { ...initialState, isSocketConnected: true };
         expect(newState).toEqual(expectedState);
@@ -569,9 +578,9 @@ describe("taipyReducer function", () => {
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         expect(newState.locations).toEqual(action.payload.value);
     });
-    it("should handle SET_ALERT action", () => {
+    it("should handle SET_NOTIFICATION action", () => {
         const action = {
-            type: Types.SetAlert,
+            type: Types.SetNotification,
             atype: "error",
             message: "some error message",
             system: true,
@@ -579,7 +588,7 @@ describe("taipyReducer function", () => {
             notificationId: nanoid(),
         };
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
-        expect(newState.alerts).toContainEqual({
+        expect(newState.notifications).toContainEqual({
             atype: action.atype,
             message: action.message,
             system: action.system,
@@ -587,57 +596,89 @@ describe("taipyReducer function", () => {
             notificationId: action.notificationId,
         });
     });
-    it("should handle DELETE_ALERT action", () => {
+    it("should handle DELETE_NOTIFICATION action", () => {
         const notificationId1 = "id-1234";
         const notificationId2 = "id-5678";
-        const initialState = {
+        const initialState: TaipyState = {
             ...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);
-        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 notificationId2 = "id-5678";
         const nonExistentId = "000000";
-        const initialState = {
+        const initialState: TaipyState = {
             ...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);
         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);
         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 notificationId2 = "id-5678";
 
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
-            alerts: [
+            notifications: [
                 {
-                    message: "alert1",
+                    message: "Notification1",
                     atype: "type1",
                     system: true,
                     duration: 5000,
                     notificationId: notificationId1,
                 },
                 {
-                    message: "alert2",
+                    message: "Notification2",
                     atype: "type2",
                     system: false,
                     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);
-        expect(newState.alerts).toEqual([
+        expect(newState.notifications).toEqual([
             {
-                message: "alert2",
+                message: "Notification2",
                 atype: "type2",
                 system: false,
                 duration: 3000,
@@ -658,7 +699,7 @@ describe("taipyReducer function", () => {
         ]);
     });
     it("should handle SET_BLOCK action", () => {
-        const initialState = { ...INITIAL_STATE, block: undefined };
+        const initialState: TaipyState = { ...INITIAL_STATE, block: undefined };
         const action = {
             type: Types.SetBlock,
             noCancel: false,
@@ -675,7 +716,7 @@ describe("taipyReducer function", () => {
         });
     });
     it("should handle NAVIGATE action", () => {
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             navigateTo: undefined,
             navigateParams: undefined,
@@ -696,31 +737,31 @@ describe("taipyReducer function", () => {
         expect(newState.navigateForce).toEqual(true);
     });
     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 newState = taipyReducer(initialState, action);
         expect(newState.id).toEqual("newId");
     });
     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 newState = taipyReducer(initialState, action);
         expect(newState.ackList).toEqual(["ack2"]);
     });
     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 newState = taipyReducer(initialState, action);
         expect(newState.menu).toEqual({ menu1: "item1", menu2: "item2" });
     });
     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 newState = taipyReducer(initialState, action);
         expect(newState.download).toEqual({ content: "fileContent", name: "fileName", onAction: "fileAction" });
     });
     it("should handle PARTIAL action", () => {
-        const initialState = { ...INITIAL_STATE, data: { test: false } };
+        const initialState: TaipyState = { ...INITIAL_STATE, data: { test: false } };
         const actionCreate = {
             type: Types.Partial,
             name: "test",
@@ -738,7 +779,7 @@ describe("taipyReducer function", () => {
         expect(newState.data.test).toBeUndefined();
     });
     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 = {
             type: Types.MultipleUpdate,
             payload: [
@@ -757,13 +798,13 @@ describe("taipyReducer function", () => {
         expect(newState.data.test2).toEqual(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 newState = taipyReducer(initialState, action);
         expect(newState.timeZone).toEqual("newTimeZone");
     });
     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";
         localStorage.setItem("timeZone", localStorageTimeZone);
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
@@ -772,13 +813,13 @@ describe("taipyReducer function", () => {
         localStorage.removeItem("timeZone");
     });
     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 newState = taipyReducer(initialState, action);
         expect(newState.timeZone).toEqual("UTC");
     });
     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 newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
@@ -888,7 +929,7 @@ describe("messageToAction function", () => {
         expect(result).toEqual(expected);
     });
     it('should call createAlertAction if message type is "AL"', () => {
-        const message: WsMessage & Partial<AlertMessage> = {
+        const message: WsMessage & Partial<NotificationMessage> = {
             type: "AL",
             atype: "I",
             name: "someName",
@@ -899,7 +940,7 @@ describe("messageToAction function", () => {
             ack_id: "someAckId",
         };
         const result = messageToAction(message);
-        const expectedResult = createAlertAction(message as unknown as AlertMessage);
+        const expectedResult = createNotificationAction(message as unknown as NotificationMessage);
         expect(result).toEqual(expectedResult);
     });
     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",
     SetTheme = "SET_THEME",
     SetTimeZone = "SET_TIMEZONE",
-    SetAlert = "SET_ALERT",
-    DeleteAlert = "DELETE_ALERT",
+    SetNotification = "SET_NOTIFICATION",
+    DeleteNotification = "DELETE_NOTIFICATION",
     SetBlock = "SET_BLOCK",
     Navigate = "NAVIGATE",
     ClientId = "CLIENT_ID",
@@ -63,7 +63,7 @@ export interface TaipyState {
     dateFormat?: string;
     dateTimeFormat?: string;
     numberFormat?: string;
-    alerts: AlertMessage[];
+    notifications: NotificationMessage[];
     block?: BlockMessage;
     navigateTo?: string;
     navigateParams?: Record<string, string>;
@@ -87,7 +87,7 @@ export interface NamePayload {
     payload: Record<string, unknown>;
 }
 
-export interface AlertMessage {
+export interface NotificationMessage {
     atype: string;
     message: string;
     system: boolean;
@@ -108,7 +108,7 @@ interface TaipyMultipleMessageAction extends TaipyBaseAction {
     actions: TaipyBaseAction[];
 }
 
-interface TaipyAlertAction extends TaipyBaseAction, AlertMessage {}
+interface TaipyNotificationAction extends TaipyBaseAction, NotificationMessage {}
 
 interface TaipyDeleteAlertAction extends TaipyBaseAction {
     notificationId: string;
@@ -201,7 +201,7 @@ export const INITIAL_STATE: TaipyState = {
     id: getLocalStorageValue(TAIPY_CLIENT_ID, ""),
     menu: {},
     ackList: [],
-    alerts: [],
+    notifications: [],
 };
 
 export const taipyInitialize = (initialState: TaipyState): TaipyState => ({
@@ -217,7 +217,7 @@ export const messageToAction = (message: WsMessage) => {
         } else if (message.type === "U") {
             return createUpdateAction(message as unknown as NamePayload);
         } else if (message.type === "AL") {
-            return createAlertAction(message as unknown as AlertMessage);
+            return createNotificationAction(message as unknown as NotificationMessage);
         } else if (message.type === "BL") {
             return createBlockAction(message as unknown as BlockMessage);
         } else if (message.type === "NA") {
@@ -374,26 +374,26 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta
             };
         case Types.SetLocations:
             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 {
                 ...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 {
                 ...state,
-                alerts: state.alerts.filter(alert => alert.notificationId !== deleteAlertAction.notificationId),
+                notifications: state.notifications.filter(notification => notification.notificationId !== deleteNotificationAction.notificationId),
             };
         case Types.SetBlock:
             const blockAction = action as unknown as TaipyBlockAction;
@@ -802,7 +802,7 @@ export const createTimeZoneAction = (timeZone: string, fromBackend = false): Tai
     payload: { timeZone: timeZone, fromBackend: fromBackend },
 });
 
-const getAlertType = (aType: string) => {
+const getNotificationType = (aType: string) => {
     aType = aType.trim();
     if (aType) {
         aType = aType.charAt(0).toLowerCase();
@@ -820,17 +820,17 @@ const getAlertType = (aType: string) => {
     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 => ({
-    type: Types.DeleteAlert,
+    type: Types.DeleteNotification,
     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 = {
     data: {
         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'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 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.
                 The default value is None.
             **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'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 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.
             **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
     _MANAGER_NAME: str
     _is_in_context = False
-    _in_context_attributes_changed_collector: List
+    _in_context_attributes_changed_collector: List = []
 
     def __enter__(self):
         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 ..common._utils import _normalize_path
 from ..notification import EventOperation, Notifier, _make_event
 
 
@@ -26,6 +27,8 @@ class _Properties(UserDict):
         self._pending_deletions = set()
 
     def __setitem__(self, key, value):
+        if key == "path":
+            value = _normalize_path(value)
         super(_Properties, self).__setitem__(key, value)
 
         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.
 
 import functools
+import re
 import time
 from collections import namedtuple
 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]
 
 
+def _normalize_path(path: str) -> str:
+    return re.sub(r"[\\]+", "/", path)
+
+
 _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.section import Section
 
+from ..common._utils import _normalize_path
 from ..common._warnings import _warn_deprecated
 from ..common.mongo_default_document import MongoDefaultDocument
 
@@ -284,6 +285,8 @@ class DataNodeConfig(Section):
         self._storage_type = storage_type
         self._scope = scope
         self._validity_period = validity_period
+        if "path" in properties:
+            properties["path"] = _normalize_path(properties["path"])
         super().__init__(id, **properties)
 
         # 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"
     _ADDITIONAL_DATA_NODES_KEY = "additional_data_nodes"
     _FREQUENCY_KEY = "frequency"
-    _SEQUENCES_KEY = "sequences"
     _COMPARATOR_KEY = "comparators"
 
     frequency: Optional[Frequency]
@@ -305,7 +304,7 @@ class ScenarioConfig(Section):
                 corresponds to the data node configuration id. During the scenarios'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 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.
                 The default value is None.
             **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'
                 comparison, each comparator is applied to all the data nodes instantiated from
                 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.
             **properties (dict[str, any]): A keyworded variable length list of additional arguments.
 
@@ -373,3 +372,64 @@ class ScenarioConfig(Section):
         )
         Config._register(section)
         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 .._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_id import Edit
 
 
-class _FileDataNodeMixin(object):
+class _FileDataNodeMixin:
     """Mixin class designed to handle file-based data nodes."""
 
     __EXTENSION_MAP = {"csv": "csv", "excel": "xlsx", "parquet": "parquet", "pickle": "p", "json": "json"}
@@ -60,13 +68,14 @@ class _FileDataNodeMixin(object):
     @_self_reload(DataNode._MANAGER_NAME)
     def path(self) -> str:
         """The path to the file data of the data node."""
-        return self._path
+        return _normalize_path(self._path)
 
     @path.setter
     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:
         """Check if the data node is downloadable.
@@ -100,53 +109,76 @@ class _FileDataNodeMixin(object):
 
         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.
 
         Arguments:
             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:
-            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
 
-        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:
-            upload_data = self._read_from_path(str(upload_path))
+            upload_data = self._read_from_path(str(up_path))
         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}")
-            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:
             try:
-                can_upload = upload_checker(upload_path.name, upload_data)
+                can_upload = upload_checker(up_path.name, upload_data)
             except Exception as err:
                 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
 
             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]
+
         _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:
         raise NotImplementedError

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

@@ -421,35 +421,52 @@ class DataNode(_Entity, _Labeled):
             )
             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.
 
         Arguments:
             data (Any): The data to write to this data node.
             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
                 corresponding to this write.
         """
         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.track_edit(editor_id=editor_id, **kwargs)
+        self.track_edit(editor_id=editor_id, comment=comment, **kwargs)
         self.unlock_edit()
         _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.
 
         Arguments:
             data (Any): The data to write to this data node.
             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
                 corresponding to this write.
         """
         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.track_edit(job_id=job_id, **kwargs)
+        self.track_edit(job_id=job_id, editor_id=editor_id, comment=comment, **kwargs)
         self.unlock_edit()
         _DataManagerFactory._build_manager()._set(self)
 

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

@@ -116,9 +116,10 @@ class _Factory:
                 ("users", PropertyType.lov),
                 ("sender_id",),
                 ("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),
+                ("allow_send_images", PropertyType.boolean, True),
                 ("mode",),
             ]
         ),

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

@@ -39,7 +39,7 @@ class _ElementApiGenerator(object, metaclass=_Singleton):
 
     @staticmethod
     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):
         if self.__module is not None:

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

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

+ 22 - 5
taipy/gui/viselements.json

@@ -53,8 +53,9 @@
                         "type": "dynamic(Union[str,Icon])",
                         "default_value": "\"\"",
                         "doc": "The label displayed in the button."
-                    },                                        {
-                    "name": "size",
+                    },
+                    {
+                        "name": "size",
                         "type": "str",
                         "default_value": "\"medium\"",
                         "doc": "The size of the button. Valid values: \"small\", \"medium\", or \"large\"."
@@ -1639,7 +1640,9 @@
         [
             "alert",
             {
-                "inherits": ["shared"],
+                "inherits": [
+                    "shared"
+                ],
                 "properties": [
                     {
                         "name": "message",
@@ -1815,8 +1818,14 @@
                     {
                         "name": "max_file_size",
                         "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",
                         "type": "str",
                         "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,
     _invoke_action,
 )
+from ._utils import _ClientStatus
 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.scenario_configs: t.Optional[t.List[t.Tuple[str, str]]] = 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
         reg_id, reg_queue = Notifier.register()
         # locks
@@ -162,28 +163,32 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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):
-        if not submission_id or not is_readable(t.cast(SubmissionId, submission_id)):
+        if not submission_id:
             return
         submission = None
         new_status = None
         payload: t.Optional[t.Dict[str, t.Any]] = None
         client_id: t.Optional[str] = None
         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
 
-            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:
                         job = job if isinstance(job, Job) else t.cast(Job, core_get(job))
                         running_tasks[job.task.id] = (
@@ -195,7 +200,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         )
                     payload.update(tasks=running_tasks)
 
-                    if last_status is not new_status:
+                    if last_client_status.submission_status is not new_status:
                         # callback
                         submission_name = submission.properties.get("on_submission")
                         if submission_name:
@@ -213,15 +218,15 @@ class _GuiCoreContext(CoreEventConsumerBase):
                                 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)
-                else:
-                    self.client_submission[submission_id] = new_status
+            else:
+                last_client_status.submission_status = new_status
 
         except Exception as e:
             _warn(f"Submission ({submission_id}) is not available", e)
@@ -634,11 +639,11 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     client_id=self.gui._get_client_id(),
                     module_context=self.gui._get_locals_context(),
                 )
+                client_status = _ClientStatus(self.gui._get_client_id(), submission_entity.submission_status)
                 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":
-                    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)
                 _GuiCoreContext.__assign_var(state, error_var, "")
         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]):
         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
         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 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):
             return
         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))
         if not self.__check_readable_editable(state, entity_id, "Data node", error_var):
             return
@@ -1044,9 +1049,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     else float(val)
                     if data.get("type") == "float"
                     else data.get("value"),
+                    editor_id=self.gui._get_client_id(),
                     comment=t.cast(dict, data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT)),
                 )
-                entity.unlock_edit(self.gui._get_client_id())
                 _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as 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.",
                         )
                 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, "")
             except Exception as 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]):
         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])
         dn_id = t.cast(DataNodeId, act_payload.get("id"))
         error_id = act_payload.get("error_id", "")
@@ -1224,11 +1233,10 @@ class _GuiCoreContext(CoreEventConsumerBase):
             try:
                 dn = t.cast(_FileDataNodeMixin, core_get(dn_id))
                 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)
                     else:
-                        reason = dn.is_downloadable()
                         state.assign(
                             error_id,
                             "Data unavailable: "
@@ -1241,6 +1249,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         reason := dn._upload(
                             act_payload.get("path", ""),
                             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}")

+ 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.default_path is dn2_config.default_path is None
     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
 from unittest import mock
 
+import pytest
+
 from taipy.common.config import Config
 from taipy.common.config.common.frequency import Frequency
 from tests.core.utils.named_temporary_file import NamedTemporaryFile
@@ -299,3 +301,80 @@ def test_add_sequence():
     assert len(scenario_config.sequences) == 2
     scenario_config.remove_sequences(["sequence2", "sequence3"])
     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 os
 import pathlib
+import re
 import uuid
 from datetime import datetime, timedelta
 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.common.scope import Scope
 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_factory import _DataManagerFactory
 from taipy.core.data.csv import CSVDataNode
@@ -129,7 +131,7 @@ class TestCSVDataNode:
     )
     def test_create_with_default_data(self, properties, exists):
         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
 
     def test_set_path(self):
@@ -218,7 +220,7 @@ class TestCSVDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -226,12 +228,12 @@ class TestCSVDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.csv")
         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):
         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 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):
         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)
         assert bool(reasons) is False
         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):
@@ -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 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
         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
         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
         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 unittest import mock
 
+import freezegun
+import pandas as pd
 import pytest
 
 import taipy.core as tp
@@ -752,6 +754,116 @@ class TestDataNode:
         dn.properties["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):
         dn_config = Config.configure_data_node("A")
         data_node = _DataManager._bulk_get_or_create([dn_config])[dn_config]
@@ -807,3 +919,15 @@ class TestDataNode:
         edit_5 = data_node.edits[5]
         assert len(edit_5) == 1
         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 pathlib
+import re
 import uuid
 from datetime import datetime, timedelta
 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.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_factory import _DataManagerFactory
 from taipy.core.data.data_node_id import DataNodeId
@@ -183,7 +185,7 @@ class TestExcelDataNode:
     )
     def test_create_with_default_data(self, properties, exists):
         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
 
     def test_read_write_after_modify_path(self):
@@ -443,7 +445,7 @@ class TestExcelDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -451,12 +453,12 @@ class TestExcelDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.xlsx")
         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):
         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 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):
         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 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
         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
         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
         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 os
 import pathlib
+import re
 import uuid
 from dataclasses import dataclass
 from enum import Enum
@@ -26,6 +27,7 @@ import pytest
 from taipy.common.config import Config
 from taipy.common.config.common.scope import Scope
 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_factory import _DataManagerFactory
 from taipy.core.data.data_node_id import DataNodeId
@@ -336,7 +338,7 @@ class TestJSONDataNode:
     )
     def test_create_with_default_data(self, properties, exists):
         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
 
     def test_set_path(self):
@@ -405,7 +407,7 @@ class TestJSONDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json")
@@ -413,12 +415,12 @@ class TestJSONDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json/example_dict.json")
         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):
         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.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):
         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.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
         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 pathlib
+import re
 import uuid
 from datetime import datetime, timedelta
 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.common.scope import Scope
 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_factory import _DataManagerFactory
 from taipy.core.data.data_node_id import DataNodeId
@@ -142,7 +144,7 @@ class TestParquetDataNode:
     )
     def test_create_with_default_data(self, properties, exists):
         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
 
     @pytest.mark.parametrize("engine", __engine)
@@ -248,7 +250,7 @@ class TestParquetDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -256,12 +258,12 @@ class TestParquetDataNode:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.parquet")
         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):
         dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.parquet"})
@@ -287,7 +289,7 @@ class TestParquetDataNode:
 
         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.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):
         old_parquet_path = tmpdir_factory.mktemp("data").join("df.parquet").strpath
@@ -332,7 +334,7 @@ class TestParquetDataNode:
 
         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.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
         assert dn._upload(parquet_file_path, upload_checker=check_data_column)
@@ -382,7 +384,7 @@ class TestParquetDataNode:
 
         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.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
         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 pathlib
 import pickle
+import re
 from datetime import datetime, timedelta
 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.common.scope import Scope
 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_factory import _DataManagerFactory
 from taipy.core.data.pickle import PickleDataNode
@@ -220,7 +222,7 @@ class TestPickleDataNodeEntity:
         assert not reasons
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
@@ -228,12 +230,12 @@ class TestPickleDataNodeEntity:
         reasons = dn.is_downloadable()
         assert not reasons
         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):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.p")
         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):
         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 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):
         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 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
         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),
                 "",
                 {
-                    "args": [
-                        {"id": a_datanode.id},
-                    ],
-                    "error_id": "error_var",
+                    "args": [{
+                        "id": a_datanode.id,
+                        "error_id": "error_var"}],
                 },
             )
             assign.assert_called()
@@ -269,12 +268,7 @@ class TestGuiCoreContext_is_editable:
                 gui_core_context.update_data(
                     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()
                 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 import Submission, SubmissionStatus
 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._utils import _ClientStatus
 
 a_cycle = Cycle(Frequency.DAILY, {}, datetime.now(), datetime.now(), datetime.now(), id=CycleId("CYCLE_id"))
 a_scenario = Scenario("scenario_config_id", None, {}, sequences={"sequence": {}})
@@ -66,9 +67,14 @@ def mock_core_get(entity_id):
     return a_task
 
 
-class MockState:
+class MockState(State):
     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:
@@ -96,7 +102,7 @@ class TestGuiCoreContext_is_readable:
     def test_cycle_adapter(self):
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get):
             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)
             assert isinstance(outcome, list)
             assert outcome[0] == a_cycle.id
@@ -120,9 +126,9 @@ class TestGuiCoreContext_is_readable:
             gui_core_context = _GuiCoreContext(Mock())
             assign = Mock()
             gui_core_context.crud_scenario(
-                MockState(assign=assign),
+                MockState(assign=assign, gui=gui_core_context.gui),
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                         "",
                         "",
@@ -132,7 +138,7 @@ class TestGuiCoreContext_is_readable:
                         {"name": "name", "id": a_scenario.id},
                     ],
                     "error_id": "error_var",
-                },
+                }),
             )
             assign.assert_not_called()
 
@@ -141,7 +147,7 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.crud_scenario(
                     MockState(assign=assign),
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                             "",
                             "",
@@ -151,7 +157,7 @@ class TestGuiCoreContext_is_readable:
                             {"name": "name", "id": a_scenario.id},
                         ],
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
@@ -164,12 +170,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.edit_entity(
                 MockState(assign=assign),
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                         {"name": "name", "id": a_scenario.id},
                     ],
                     "error_id": "error_var",
-                },
+                }),
             )
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
@@ -180,12 +186,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.edit_entity(
                     MockState(assign=assign),
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                             {"name": "name", "id": a_scenario.id},
                         ],
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
@@ -198,10 +204,7 @@ class TestGuiCoreContext_is_readable:
             mockGui._get_authorization = lambda s: contextlib.nullcontext()
             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)
             mockget.assert_called()
             found = False
@@ -248,12 +251,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.act_on_jobs(
                 MockState(assign=assign),
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                         {"id": [a_job.id], "action": "delete"},
                     ],
                     "error_id": "error_var",
-                },
+                }),
             )
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
@@ -263,12 +266,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.act_on_jobs(
                 MockState(assign=assign),
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                         {"id": [a_job.id], "action": "cancel"},
                     ],
                     "error_id": "error_var",
-                },
+                }),
             )
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
@@ -279,12 +282,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.act_on_jobs(
                     MockState(assign=assign),
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                             {"id": [a_job.id], "action": "delete"},
                         ],
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
@@ -294,12 +297,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.act_on_jobs(
                     MockState(assign=assign),
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                             {"id": [a_job.id], "action": "cancel"},
                         ],
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
@@ -312,12 +315,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.edit_data_node(
                 MockState(assign=assign),
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                         {"id": a_datanode.id},
                     ],
                     "error_id": "error_var",
-                },
+                }),
             )
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
@@ -328,12 +331,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.edit_data_node(
                     MockState(assign=assign),
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                             {"id": a_datanode.id},
                         ],
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
@@ -348,12 +351,12 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.lock_datanode_for_edit(
                 MockState(assign=assign),
                 "",
-                {
+                t.cast(dict, {
                     "args": [
                         {"id": a_datanode.id},
                     ],
                     "error_id": "error_var",
-                },
+                }),
             )
             assign.assert_called_once()
             assert assign.call_args.args[0] == "error_var"
@@ -364,12 +367,12 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.lock_datanode_for_edit(
                     MockState(assign=assign),
                     "",
-                    {
+                    t.cast(dict, {
                         "args": [
                             {"id": a_datanode.id},
                         ],
                         "error_id": "error_var",
-                    },
+                    }),
                 )
                 assign.assert_called_once()
                 assert assign.call_args.args[0] == "error_var"
@@ -395,12 +398,7 @@ class TestGuiCoreContext_is_readable:
             gui_core_context.update_data(
                 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()
             assert assign.call_args_list[0].args[0] == "error_var"
@@ -411,12 +409,7 @@ class TestGuiCoreContext_is_readable:
                 gui_core_context.update_data(
                     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()
                 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]