Ver Fonte

Merge branch 'develop' into feature/add-pyproject

João André há 9 meses atrás
pai
commit
75c915edc1
56 ficheiros alterados com 817 adições e 443 exclusões
  1. 1 1
      .github/actions/gui-test/pyi/action.yml
  2. 1 1
      .github/workflows/build-and-release-single-package.yml
  3. 2 2
      .github/workflows/build-and-release.yml
  4. 1 1
      .github/workflows/check-config-pyi.yml
  5. 2 1
      doc/gui/examples/controls/chat-discuss.py
  6. 15 15
      doc/gui/examples/controls/metric-color-map.py
  7. 28 0
      doc/gui/examples/controls/metric-delta-color.py
  8. 3 6
      doc/gui/examples/controls/metric-formats.py
  9. 6 14
      doc/gui/examples/controls/metric-layout.py
  10. 2 4
      doc/gui/examples/controls/metric-range.py
  11. 4 5
      doc/gui/examples/controls/metric-simple.py
  12. 4 3
      doc/gui/examples/controls/metric-type.py
  13. 0 1
      doc/gui/examples/controls/number-min-max.py
  14. 0 1
      doc/gui/examples/controls/number-step.py
  15. 4 4
      doc/gui/examples/controls/table-formatting.py
  16. 1 1
      doc/gui/examples/controls/text-md.py
  17. 19 15
      frontend/taipy-gui/src/components/Taipy/Chat.tsx
  18. 87 92
      frontend/taipy-gui/src/components/Taipy/Metric.tsx
  19. 1 1
      frontend/taipy-gui/src/components/Taipy/Progress.tsx
  20. 10 4
      frontend/taipy-gui/src/utils/hooks.ts
  21. 10 8
      frontend/taipy/src/CoreSelector.tsx
  22. 3 3
      frontend/taipy/src/DataNodeTable.tsx
  23. 13 13
      frontend/taipy/src/DataNodeViewer.tsx
  24. 4 4
      frontend/taipy/src/PropertiesEditor.tsx
  25. 19 19
      frontend/taipy/src/ScenarioViewer.tsx
  26. 5 5
      frontend/taipy/src/utils.ts
  27. 3 0
      taipy/core/_orchestrator/_dispatcher/_development_job_dispatcher.py
  28. 4 0
      taipy/core/_orchestrator/_dispatcher/_standalone_job_dispatcher.py
  29. 24 1
      taipy/core/config/checkers/_scenario_config_checker.py
  30. 11 0
      taipy/core/config/checkers/_task_config_checker.py
  31. 6 0
      taipy/core/job/_job_converter.py
  32. 7 1
      taipy/core/job/_job_model.py
  33. 35 0
      taipy/core/job/job.py
  34. 27 0
      taipy/core/submission/submission.py
  35. 2 1
      taipy/gui/_default_config.py
  36. 1 0
      taipy/gui/_renderers/factory.py
  37. 6 4
      taipy/gui/config.py
  38. 17 0
      taipy/gui/gui.py
  39. 1 1
      taipy/gui/hook.py
  40. 1 1
      taipy/gui/server.py
  41. 3 0
      taipy/gui/utils/_bindings.py
  42. 2 0
      taipy/gui/utils/types.py
  43. 82 77
      taipy/gui/viselements.json
  44. 13 9
      taipy/gui_core/_adapters.py
  45. 5 3
      taipy/gui_core/_context.py
  46. 74 1
      tests/core/_orchestrator/test_orchestrator__submit.py
  47. 41 0
      tests/core/config/checkers/test_scenario_config_checker.py
  48. 34 0
      tests/core/config/checkers/test_task_config_checker.py
  49. 3 3
      tests/core/notification/test_events_published.py
  50. 28 0
      tests/core/submission/test_submission.py
  51. 3 3
      tests/gui/builder/control/test_chat.py
  52. 1 1
      tests/gui/builder/control/test_progress.py
  53. 2 1
      tests/gui/config/test_cli.py
  54. 0 8
      tools/gui/builder/block.txt
  55. 0 8
      tools/gui/builder/control.txt
  56. 136 96
      tools/gui/generate_pyi.py

+ 1 - 1
.github/actions/gui-test/pyi/action.yml

@@ -8,7 +8,7 @@ runs:
       run: pipenv run pip install mypy black isort
     - name: Generate pyi
       shell: bash
-      run: cp tools/gui/generate_pyi.py pyi_temp.py && pipenv run python pyi_temp.py && rm pyi_temp.py
+      run: pipenv run python tools/gui/generate_pyi.py
     - name: Cleanup any untracked files
       shell: bash
       run: git clean -f

+ 1 - 1
.github/workflows/build-and-release-single-package.yml

@@ -128,7 +128,7 @@ jobs:
       - name: Generate GUI pyi file
         if: github.event.inputs.target_package == 'gui'
         run: |
-          cp tools/gui/generate_pyi.py pyi_temp.py && pipenv run python pyi_temp.py && rm pyi_temp.py
+          pipenv run python tools/gui/generate_pyi.py
 
       - name: Build frontends
         if: github.event.inputs.target_package == 'gui'

+ 2 - 2
.github/workflows/build-and-release.yml

@@ -126,7 +126,7 @@ jobs:
       - name: Generate GUI pyi file
         if: matrix.package == 'gui'
         run: |
-          cp tools/gui/generate_pyi.py pyi_temp.py && pipenv run python pyi_temp.py && rm pyi_temp.py
+          pipenv run python tools/gui/generate_pyi.py
 
       - name: Build frontends
         if: matrix.package == 'gui'
@@ -249,7 +249,7 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
-      - uses: stefanzweifel/git-auto-commit-action@v4
+      - uses: stefanzweifel/git-auto-commit-action@v5
         with:
           file_pattern: '*/version.json'
           commit_message: Update version to ${{ needs.fetch-versions.outputs.NEW_VERSION }}

+ 1 - 1
.github/workflows/check-config-pyi.yml

@@ -18,6 +18,6 @@ jobs:
           python-version: '3.11'
       - name: Update config.pyi
         run: python taipy/config/stubs/generate_pyi.py
-      - uses: stefanzweifel/git-auto-commit-action@v4
+      - uses: stefanzweifel/git-auto-commit-action@v5
         with:
           commit_message: "Update config.pyi"

+ 2 - 1
doc/gui/examples/controls/chat-discuss.py

@@ -19,12 +19,13 @@
 # incognito windows so a given user's context is not reused.
 # -----------------------------------------------------------------------------------------
 from os import path
+from typing import Union
 
 from taipy.gui import Gui, Icon
 from taipy.gui.gui_actions import navigate, notify
 
 username = ""
-users: list[str|Icon] = []
+users: list[Union[str, Icon]] = []
 messages: list[tuple[str, str, str]] = []
 
 Gui.add_shared_variables("messages", "users")

+ 15 - 15
doc/gui/examples/controls/metric-color-map.py

@@ -15,23 +15,23 @@
 # -----------------------------------------------------------------------------------------
 from taipy.gui import Gui
 
-# color_map = {
-#     # 0-20 - Let Taipy decide
-#     # 20-40 - red
-#     20: "red",
-#     # 40-60 - Let Taipy decide
-#     40: None,
-#     # 60-80 - blue
-#     60: "blue",
-#     # 80-100 - Let Taipy decide
-#     80: None
-# }
-
-value = 50
-color_map = {20: "red", 40: None, 60: "blue", 80: None}
+# Color wavelength
+color_wl = 530
+# Color ranges by wavelength
+color_map = {
+    200: None,
+    380: "violet",
+    435: "blue",
+    500: "cyan",
+    520: "green",
+    565: "yellow",
+    590: "orange",
+    625: "red",
+    740: None,
+}
 
 page = """
-<|{value}|metric|color_map={color_map}|>
+<|{color_wl}|metric|color_map={color_map}|format=%d nm|min=200|max=800|bar_color=gray|>
 """
 
 Gui(page).run()

+ 28 - 0
doc/gui/examples/controls/metric-delta-color.py

@@ -0,0 +1,28 @@
+# 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>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+# Source: https://gml.noaa.gov/ccgg/trends/gl_gr.html
+# Estimated Global Trend on january 1st:
+co2_2014 = 396.37
+co2_2024 = 421.13
+delta = co2_2024 - co2_2014
+
+page = """
+<|{co2_2024}|metric|delta={delta}|delta_color=invert|format=%.1f ppm|delta_format=%.1f ppm|min=300|max=500|>
+"""
+
+Gui(page).run()

+ 3 - 6
doc/gui/examples/controls/metric-value-format.py → doc/gui/examples/controls/metric-formats.py

@@ -15,14 +15,11 @@
 # -----------------------------------------------------------------------------------------
 from taipy.gui import Gui
 
-value = 50
-delta_value = 20
-
-# format & delta_format are used to format the value and delta value respectively.
-# They use the printf syntax.
+speed = 60
+variation = 15
 
 page = """
-<|{value}|metric|delta={delta_value}|format=%d km/h|delta_format=%d km/h|>
+<|{speed}|metric|format=%d km/h|delta={variation}|delta_format=%d %%|>
 """
 
 

+ 6 - 14
doc/gui/examples/controls/metric-layout.py

@@ -15,22 +15,14 @@
 # -----------------------------------------------------------------------------------------
 from taipy.gui import Gui
 
-# Layout reference can be found in the documentation: https://plotly.com/python/reference/layout/
-
-value = 50
+value = 45
+# The layout object reference can be found in Plotly's documentation:
+#         https://plotly.com/python/reference/layout/
 layout = {
-    "width": "1000",
-    "height": "500",
-    "paper_bgcolor": "lightgray",
-    "margin": {
-        "l": 100,
-        "r": 100,
-        "b": 100,
-        "t": 100,
-    },
+    "paper_bgcolor": "lightblue",
     "font": {
-        "size": 20,
-        "color": "black",
+        "size": 30,
+        "color": "blue",
         "family": "Arial",
     },
 }

+ 2 - 4
doc/gui/examples/controls/metric-range.py

@@ -15,12 +15,10 @@
 # -----------------------------------------------------------------------------------------
 from taipy.gui import Gui
 
-value = 50
-min_value = 50
-max_value = 150
+value = 120
 
 page = """
-<|{value}|metric|min={min_value}|max={max_value}|>
+<|{value}|metric|min=50|max=150|>
 """
 
 

+ 4 - 5
doc/gui/examples/controls/metric-simple.py

@@ -15,13 +15,12 @@
 # -----------------------------------------------------------------------------------------
 from taipy.gui import Gui
 
-value = 50
-max_value = 150
-delta_value = 20
-threshold = 100
+value = 72
+delta = 15
+threshold = 60
 
 page = """
-<|{value}|metric|max={max_value}|delta={delta_value}|threshold={threshold}|>
+<|{value}|metric|delta={delta}|threshold={threshold}|>
 """
 
 Gui(page).run()

+ 4 - 3
doc/gui/examples/controls/metric-type.py

@@ -15,11 +15,12 @@
 # -----------------------------------------------------------------------------------------
 from taipy.gui import Gui
 
-value = 50
+value = 72
+delta = 15
+threshold = 60
 
 page = """
-<|{value}|metric|type=linear|>
-<|{value}|metric|type=circular|>
+<|{value}|metric|threshold={threshold}|type=linear|>
 """
 
 Gui(page).run()

+ 0 - 1
doc/gui/examples/controls/number-min-max.py

@@ -22,4 +22,3 @@ page = """
 """
 
 Gui(page).run()
-

+ 0 - 1
doc/gui/examples/controls/number-step.py

@@ -22,4 +22,3 @@ page = """
 """
 
 Gui(page).run()
-

+ 4 - 4
doc/gui/examples/controls/table-formatting.py

@@ -25,10 +25,10 @@ stock = {
 }
 
 columns = {
-    "date" : {"title": "Data", "format": "MMM d"},
-    "price" : {"title": "Price", "format": "$%.02f"},
-    "change" : {"title": "% change", "format": "%.01f"},
-    "volume" : {"title": "Volume"}
+    "date": {"title": "Data", "format": "MMM d"},
+    "price": {"title": "Price", "format": "$%.02f"},
+    "change": {"title": "% change", "format": "%.01f"},
+    "volume": {"title": "Volume"},
 }
 
 page = """

+ 1 - 1
doc/gui/examples/controls/text-md.py

@@ -23,7 +23,7 @@ add style to the text.
 
 If a line ends with two white spaces, such as here
 then you can create line skips.
-""" # noqa W291
+"""  # noqa W291
 
 page = """
 <|{markdown}|text|mode=markdown|>

+ 19 - 15
frontend/taipy-gui/src/components/Taipy/Chat.tsx

@@ -35,7 +35,7 @@ import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { LoVElt, useLovListMemo } from "./lovUtils";
 import { IconAvatar, avatarSx } from "../../utils/icon";
-import { getInitials } from "../../utils";
+import { emptyArray, getInitials } from "../../utils";
 import { RowType, TableValueType } from "./tableUtils";
 
 interface ChatProps extends TaipyActiveProps {
@@ -290,20 +290,24 @@ const Chat = (props: ChatProps) => {
     useEffect(() => {
         if (!refresh && props.messages && page.current.key && props.messages[page.current.key] !== undefined) {
             const newValue = props.messages[page.current.key];
-            const nr = newValue.data as RowType[];
-            if (Array.isArray(nr) && nr.length > newValue.start && nr[newValue.start]) {
-                setRows((old) => {
-                    old.length && nr.length > old.length && setShowMessage(true);
-                    if (nr.length < old.length) {
-                        return nr.concat(old.slice(nr.length))
-                    }
-                    if (old.length > newValue.start) {
-                        return old.slice(0, newValue.start).concat(nr.slice(newValue.start));
-                    }
-                    return nr;
-                });
-                const cols = Object.keys(nr[newValue.start]);
-                setColumns(cols.length > 2 ? cols : cols.length == 2 ? [...cols, ""] : ["", ...cols, "", ""]);
+            if (newValue.rowcount == 0) {
+                setRows(emptyArray)
+            } else {
+                const nr = newValue.data as RowType[];
+                if (Array.isArray(nr) && nr.length > newValue.start && nr[newValue.start]) {
+                    setRows((old) => {
+                        old.length && nr.length > old.length && setShowMessage(true);
+                        if (nr.length < old.length) {
+                            return nr.concat(old.slice(nr.length))
+                        }
+                        if (old.length > newValue.start) {
+                            return old.slice(0, newValue.start).concat(nr.slice(newValue.start));
+                        }
+                        return nr;
+                    });
+                    const cols = Object.keys(nr[newValue.start]);
+                    setColumns(cols.length > 2 ? cols : cols.length == 2 ? [...cols, ""] : ["", ...cols, "", ""]);
+                }
             }
             page.current.key = getChatKey(0, pageSize);
         }

+ 87 - 92
frontend/taipy-gui/src/components/Taipy/Metric.tsx

@@ -11,62 +11,57 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, {CSSProperties, lazy, Suspense, useMemo} from 'react';
-import {Data, Delta, Layout} from "plotly.js";
+import React, { CSSProperties, lazy, Suspense, useMemo } from "react";
+import { Data, Delta, Layout } from "plotly.js";
 import Box from "@mui/material/Box";
 import Skeleton from "@mui/material/Skeleton";
 import Tooltip from "@mui/material/Tooltip";
-import {useTheme} from "@mui/material";
-import {useClassNames, useDynamicJsonProperty, useDynamicProperty} from "../../utils/hooks";
-import {extractPrefix, extractSuffix, sprintfToD3Converter} from "../../utils/formatConversion";
-import {TaipyBaseProps, TaipyHoverProps} from "./utils";
-import {darkThemeTemplate} from "../../themes/darkThemeTemplate";
+import { useTheme } from "@mui/material";
+import { useClassNames, useDynamicJsonProperty, useDynamicProperty } from "../../utils/hooks";
+import { extractPrefix, extractSuffix, sprintfToD3Converter } from "../../utils/formatConversion";
+import { TaipyBaseProps, TaipyHoverProps } from "./utils";
+import { darkThemeTemplate } from "../../themes/darkThemeTemplate";
 
 const Plot = lazy(() => import("react-plotly.js"));
 
 interface MetricProps extends TaipyBaseProps, TaipyHoverProps {
-    title?: string
-    type?: string
-    min?: number
-    max?: number
-    value?: number
-    defaultValue?: number
-    delta?: number
-    defaultDelta?: number
-    deltaColor?: string
-    negativeDeltaColor?: string
-    threshold?: number
-    defaultThreshold?: number
-    testId?: string
-    defaultLayout?: string;
+    value?: number;
+    defaultValue?: number;
+    delta?: number;
+    defaultDelta?: number;
+    type?: string;
+    min?: number;
+    max?: number;
+    deltaColor?: string;
+    negativeDeltaColor?: string;
+    threshold?: number;
+    defaultThreshold?: number;
+    format?: string;
+    deltaFormat?: string;
+    barColor?: string;
+    showValue?: boolean;
+    colorMap?: string;
+    title?: string;
+    testId?: string;
     layout?: string;
-    defaultStyle?: string;
+    defaultLayout?: string;
     style?: string;
+    defaultStyle?: string;
     width?: string | number;
     height?: string | number;
-    showValue?: boolean;
-    format?: string;
-    deltaFormat?: string;
-    colorMap?: string;
     template?: string;
     template_Dark_?: string;
     template_Light_?: string;
 }
 
 const emptyLayout = {} as Partial<Layout>;
-const defaultStyle = {position: "relative", display: "inline-block"};
+const defaultStyle = { position: "relative", display: "inline-block" };
 
 const Metric = (props: MetricProps) => {
-    const {
-        width = "100%",
-        height,
-        showValue = true,
-        deltaColor,
-        negativeDeltaColor
-    } = props;
-    const value = useDynamicProperty(props.value, props.defaultValue, 0)
-    const threshold = useDynamicProperty(props.threshold, props.defaultThreshold, undefined)
-    const delta = useDynamicProperty(props.delta, props.defaultDelta, undefined)
+    const { width = "100%", height, showValue = true, deltaColor, negativeDeltaColor } = props;
+    const value = useDynamicProperty(props.value, props.defaultValue, 0);
+    const threshold = useDynamicProperty(props.threshold, props.defaultThreshold, undefined);
+    const delta = useDynamicProperty(props.delta, props.defaultDelta, undefined);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const baseLayout = useDynamicJsonProperty(props.layout, props.defaultLayout || "", emptyLayout);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
@@ -75,31 +70,42 @@ const Metric = (props: MetricProps) => {
     const colorMap = useMemo(() => {
         try {
             const obj = props.colorMap ? JSON.parse(props.colorMap) : null;
-            if (obj && typeof obj === 'object') {
+            if (obj && typeof obj === "object") {
                 const keys = Object.keys(obj);
-                return keys.sort((a, b) => Number(a) - Number(b)).map((key, index) => {
-                    const nextKey = keys[index + 1] !== undefined ? Number(keys[index + 1]) : props.max || 100;
-                    return {range: [Number(key), nextKey], color: obj[key]};
-                }).filter(item => item.color !== null)
+                return keys
+                    .sort((a, b) => Number(a) - Number(b))
+                    .map((key, index) => {
+                        const nextKey = keys[index + 1] !== undefined ? Number(keys[index + 1]) : props.max || 100;
+                        return { range: [Number(key), nextKey], color: obj[key] };
+                    })
+                    .filter((item) => item.color !== null);
             }
         } catch (e) {
             console.info(`Error parsing color_map value (metric).\n${(e as Error).message || e}`);
         }
         return undefined;
-    }, [props.colorMap, props.max])
+    }, [props.colorMap, props.max]);
 
     const data = useMemo(() => {
-        const mode = (props.type === "none") ? [] : ["gauge"];
+        const mode = props.type === "none" ? [] : ["gauge"];
         showValue && mode.push("number");
-        (delta !== undefined) && mode.push("delta");
-        const deltaIncreasing = deltaColor ? {
-            color: deltaColor == "invert" ? "#FF4136" : deltaColor } : undefined
-        const deltaDecreasing = deltaColor == "invert" ? {
-                color: "#3D9970"
-            } : negativeDeltaColor ? { color: negativeDeltaColor } : undefined;
+        delta !== undefined && mode.push("delta");
+        const deltaIncreasing = deltaColor
+            ? {
+                  color: deltaColor == "invert" ? "#FF4136" : deltaColor,
+              }
+            : undefined;
+        const deltaDecreasing =
+            deltaColor == "invert"
+                ? {
+                      color: "#3D9970",
+                  }
+                : negativeDeltaColor
+                  ? { color: negativeDeltaColor }
+                  : undefined;
         return [
             {
-                domain: {x: [0, 1], y: [0, 1]},
+                domain: { x: [0, 1], y: [0, 1] },
                 value: value,
                 type: "indicator",
                 mode: mode.join("+"),
@@ -109,58 +115,58 @@ const Metric = (props: MetricProps) => {
                     valueformat: sprintfToD3Converter(props.format),
                 },
                 delta: {
-                    reference: typeof value === 'number' && typeof delta === 'number' ? value - delta : undefined,
+                    reference: typeof value === "number" && typeof delta === "number" ? value - delta : undefined,
                     prefix: extractPrefix(props.deltaFormat),
                     suffix: extractSuffix(props.deltaFormat),
                     valueformat: sprintfToD3Converter(props.deltaFormat),
                     increasing: deltaIncreasing,
-                    decreasing: deltaDecreasing
-
+                    decreasing: deltaDecreasing,
                 } as Partial<Delta>,
                 gauge: {
                     axis: {
-                        range: [
-                            props.min || 0,
-                            props.max || 100
-                        ]
+                        range: [props.min || 0, props.max || 100],
+                    },
+                    bar: {
+                        color: props.barColor,
                     },
                     steps: colorMap,
                     shape: props.type === "linear" ? "bullet" : "angular",
                     threshold: {
-                        line: {color: "red", width: 4},
+                        line: { color: "red", width: 4 },
                         thickness: 0.75,
-                        value: threshold
-                    }
+                        value: threshold,
+                    },
                 },
-            }
+            },
         ] as Data[];
     }, [
-        props.format,
-        props.deltaFormat,
+        value,
+        delta,
+        props.type,
         props.min,
         props.max,
-        props.type,
-        value,
-        showValue,
         deltaColor,
         negativeDeltaColor,
-        delta,
         threshold,
-        colorMap
+        props.format,
+        props.deltaFormat,
+        props.barColor,
+        showValue,
+        colorMap,
     ]);
 
     const style = useMemo(
         () =>
             height === undefined
-                ? ({...defaultStyle, width: width} as CSSProperties)
-                : ({...defaultStyle, width: width, height: height} as CSSProperties),
+                ? ({ ...defaultStyle, width: width } as CSSProperties)
+                : ({ ...defaultStyle, width: width, height: height } as CSSProperties),
         [height, width]
     );
 
-    const skelStyle = useMemo(() => ({...style, minHeight: "7em"}), [style]);
+    const skelStyle = useMemo(() => ({ ...style, minHeight: "7em" }), [style]);
 
     const layout = useMemo(() => {
-        const layout = {...baseLayout};
+        const layout = { ...baseLayout };
         let template = undefined;
         try {
             const tpl = props.template && JSON.parse(props.template);
@@ -170,7 +176,7 @@ const Metric = (props: MetricProps) => {
                         ? JSON.parse(props.template_Dark_)
                         : darkTemplate
                     : props.template_Light_ && JSON.parse(props.template_Light_);
-            template = tpl ? (tplTheme ? {...tpl, ...tplTheme} : tpl) : tplTheme ? tplTheme : undefined;
+            template = tpl ? (tplTheme ? { ...tpl, ...tplTheme } : tpl) : tplTheme ? tplTheme : undefined;
         } catch (e) {
             console.info(`Error while parsing Metric.template\n${(e as Error).message || e}`);
         }
@@ -183,34 +189,23 @@ const Metric = (props: MetricProps) => {
         }
 
         return layout as Partial<Layout>;
-    }, [
-        props.title,
-        props.template,
-        props.template_Dark_,
-        props.template_Light_,
-        theme.palette.mode,
-        baseLayout,
-    ])
+    }, [props.title, props.template, props.template_Dark_, props.template_Light_, theme.palette.mode, baseLayout]);
 
+    const plotConfig = {displaylogo: false}
     return (
         <Tooltip title={hover || ""}>
             <Box data-testid={props.testId} className={className}>
-                <Suspense fallback={<Skeleton key="skeleton" sx={skelStyle}/>}>
-                    <Plot
-                        data={data}
-                        layout={layout}
-                        style={style}
-                        useResizeHandler
-                    />
+                <Suspense fallback={<Skeleton key="skeleton" sx={skelStyle} />}>
+                    <Plot data={data} layout={layout} style={style} config={plotConfig} useResizeHandler />
                 </Suspense>
             </Box>
         </Tooltip>
     );
-}
+};
 
 export default Metric;
 
-const {colorscale, colorway, font} = darkThemeTemplate.layout;
+const { colorscale, colorway, font } = darkThemeTemplate.layout;
 const darkTemplate = {
     layout: {
         colorscale,
@@ -218,4 +213,4 @@ const darkTemplate = {
         font,
         paper_bgcolor: "rgb(31,47,68)",
     },
-}
+};

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

@@ -49,7 +49,7 @@ const Progress = (props: ProgressBarProps) => {
     const { linear = false, showValue = false } = props;
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
-    const value = useDynamicProperty(props.value, props.defaultValue, undefined, "number");
+    const value = useDynamicProperty(props.value, props.defaultValue, undefined, "number", true);
     const render = useDynamicProperty(props.render, props.defaultRender, true);
 
     if (!render) {

+ 10 - 4
frontend/taipy-gui/src/utils/hooks.ts

@@ -29,16 +29,22 @@ import { TIMEZONE_CLIENT } from "../utils";
  * @param defaultStatic - The default static value.
  * @returns The latest updated value.
  */
-export const useDynamicProperty = <T>(value: T, defaultValue: T, defaultStatic: T, check_type?: string): T => {
+export const useDynamicProperty = <T>(value: T, defaultValue: T, defaultStatic: T, checkType?: string, nullToDefault?: boolean): T => {
     return useMemo(() => {
-        if (value !== undefined && (!check_type || typeof value === check_type)) {
+        if (nullToDefault && value === null) {
+            return defaultStatic;
+        }
+        if (value !== undefined && (!checkType || typeof value === checkType)) {
             return value;
         }
-        if (defaultValue !== undefined && (!check_type || typeof value === check_type)) {
+        if (nullToDefault && defaultValue === null) {
+            return defaultStatic;
+        }
+        if (defaultValue !== undefined && (!checkType || typeof defaultValue === checkType)) {
             return defaultValue;
         }
         return defaultStatic;
-    }, [value, defaultValue, defaultStatic, check_type]);
+    }, [value, defaultValue, defaultStatic, checkType, nullToDefault]);
 };
 
 /**

+ 10 - 8
frontend/taipy/src/CoreSelector.tsx

@@ -375,14 +375,16 @@ const CoreSelector = (props: CoreSelectorProps) => {
                 return;
             }
             setSelectedItems(() => {
-                const lovVar = getUpdateVar(updateVars, lovPropertyName);
-                const val = multiple ? nodeId : isSelectable ? nodeId : "";
-                setTimeout(
-                    // to avoid set state while render react errors
-                    () => dispatch(createSendUpdateAction(updateVarName, val, module, onChange, propagate, lovVar)),
-                    1
-                );
-                onSelect && isSelectable && onSelect(val);
+                if (isSelectable) {
+                    const lovVar = getUpdateVar(updateVars, lovPropertyName);
+                    const val = nodeId;
+                    setTimeout(
+                        // to avoid set state while render react errors
+                        () => dispatch(createSendUpdateAction(updateVarName, val, module, onChange, propagate, lovVar)),
+                        1
+                    );
+                    onSelect && onSelect(val);
+                }
                 return Array.isArray(nodeId) ? nodeId : nodeId ? [nodeId] : [];
             });
         },

+ 3 - 3
frontend/taipy/src/DataNodeTable.tsx

@@ -58,14 +58,14 @@ interface DataNodeTableProps {
     onLock?: string;
     editInProgress?: boolean;
     editLock: MutableRefObject<boolean>;
-    editable: boolean;
+    notEditableReason: string;
     updateDnVars?: string;
 }
 
 const pushRightSx = { ml: "auto" };
 
 const DataNodeTable = (props: DataNodeTableProps) => {
-    const { uniqid, configId, nodeId, columns = "", onViewTypeChange, editable, updateDnVars = "" } = props;
+    const { uniqid, configId, nodeId, columns = "", onViewTypeChange, notEditableReason, updateDnVars = "" } = props;
 
     const dispatch = useDispatch();
     const module = useModule();
@@ -202,7 +202,7 @@ const DataNodeTable = (props: DataNodeTableProps) => {
                 ) : null}
                 <Grid item sx={tableEdit ? undefined : pushRightSx}>
                     <FormControlLabel
-                        disabled={!props.active || !editable || !!props.editInProgress}
+                        disabled={!props.active || !!notEditableReason || !!props.editInProgress}
                         control={<Switch color="primary" checked={tableEdit} onChange={toggleTableEdit} />}
                         label="Edit data"
                         labelPlacement="start"

+ 13 - 13
frontend/taipy/src/DataNodeViewer.tsx

@@ -114,8 +114,8 @@ type DataNodeFull = [
     DatanodeData, // data
     boolean, // editInProgress
     string, // editorId
-    boolean, // readable
-    boolean // editable
+    string, // notReadableReason
+    string // notEditableReason
 ];
 
 enum DataNodeFullProps {
@@ -131,8 +131,8 @@ enum DataNodeFullProps {
     data,
     editInProgress,
     editorId,
-    readable,
-    editable,
+    notReadableReason,
+    notEditableReason,
 }
 const DataNodeFullLength = Object.keys(DataNodeFullProps).length / 2;
 
@@ -206,8 +206,8 @@ const invalidDatanode: DataNodeFull = [
     [null, null, null, null],
     false,
     "",
-    false,
-    false,
+    "invalid",
+    "invalid",
 ];
 
 enum TabValues {
@@ -253,8 +253,8 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         dnData,
         dnEditInProgress,
         dnEditorId,
-        dnReadable,
-        dnEditable,
+        dnNotReadableReason,
+        dnNotEditableReason,
     ] = datanode;
     const dtType = dnData[DatanodeDataProps.type];
     const dtValue = dnData[DatanodeDataProps.value] ?? (dtType == "float" ? null : undefined);
@@ -454,7 +454,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         [dnId, id, dispatch, module, props.onLock, updateDnVars]
     );
 
-    const active = useDynamicProperty(props.active, props.defaultActive, true) && dnReadable;
+    const active = useDynamicProperty(props.active, props.defaultActive, true) && !dnNotReadableReason;
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
 
     // history & data
@@ -715,7 +715,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                         onClick={onFocus}
                                         sx={hoverSx}
                                     >
-                                        {active && dnEditable && focusName === "label" ? (
+                                        {active && !dnNotEditableReason && focusName === "label" ? (
                                             <TextField
                                                 label="Label"
                                                 variant="outlined"
@@ -859,7 +859,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                     setFocusName={setFocusName}
                                     onFocus={onFocus}
                                     onEdit={props.onEdit}
-                                    editable={dnEditable}
+                                    notEditableReason={dnNotEditableReason}
                                     updatePropVars={updateDnVars}
                                 />
                             </Grid>
@@ -929,7 +929,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                         sx={hoverSx}
                                     >
                                         {active &&
-                                        dnEditable &&
+                                        !dnNotEditableReason &&
                                         dnEditInProgress &&
                                         dnEditorId === editorId &&
                                         focusName === dataValueFocus ? (
@@ -1088,7 +1088,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                                 onLock={props.onLock}
                                                 editInProgress={dnEditInProgress && dnEditorId !== editorId}
                                                 editLock={editLock}
-                                                editable={dnEditable}
+                                                notEditableReason={dnNotEditableReason}
                                                 updateDnVars={updateDnVars}
                                             />
                                         ) : (

+ 4 - 4
frontend/taipy/src/PropertiesEditor.tsx

@@ -50,7 +50,7 @@ interface PropertiesEditorProps {
     setFocusName: (name: string) => void;
     isDefined: boolean;
     onEdit?: string;
-    editable: boolean;
+    notEditableReason: string;
     updatePropVars?: string;
 }
 
@@ -65,7 +65,7 @@ const PropertiesEditor = (props: PropertiesEditorProps) => {
         focusName,
         setFocusName,
         entProperties,
-        editable,
+        notEditableReason,
         updatePropVars = "",
     } = props;
 
@@ -195,7 +195,7 @@ const PropertiesEditor = (props: PropertiesEditorProps) => {
                                   onClick={onFocus}
                                   sx={hoverSx}
                               >
-                                  {active && editable && focusName === propName ? (
+                                  {active && !notEditableReason && focusName === propName ? (
                                       <>
                                           <Grid item xs={4}>
                                               <TextField
@@ -284,7 +284,7 @@ const PropertiesEditor = (props: PropertiesEditorProps) => {
                                           </Grid>
                                           <Grid item xs={5}>
                                               <Typography variant="subtitle2">{property.value}</Typography>
-                                          </Grid>{" "}
+                                          </Grid>
                                           <Grid item xs={3} />
                                       </>
                                   )}

+ 19 - 19
frontend/taipy/src/ScenarioViewer.tsx

@@ -106,7 +106,7 @@ interface SequencesRowProps {
     focusName: string;
     setFocusName: (name: string) => void;
     notSubmittableReason: string;
-    editable: boolean;
+    notEditableReason: string;
     isValid: (sLabel: string, label: string) => boolean;
 }
 
@@ -119,12 +119,12 @@ const tagsAutocompleteSx = {
     maxWidth: "none",
 };
 
-type SequenceFull = [string, string[], string, boolean];
+type SequenceFull = [string, string[], string, string];
 // enum SeFProps {
 //     label,
 //     tasks,
-//     submittable,
-//     editable,
+//     notSubmittableReason,
+//     notEditablereason,
 // }
 
 const SequenceRow = ({
@@ -141,7 +141,7 @@ const SequenceRow = ({
     focusName,
     setFocusName,
     notSubmittableReason,
-    editable,
+    notEditableReason,
     isValid,
 }: SequencesRowProps) => {
     const [label, setLabel] = useState("");
@@ -202,7 +202,7 @@ const SequenceRow = ({
 
     return (
         <Grid item xs={12} container justifyContent="space-between" data-focus={name} onClick={onFocus} sx={hoverSx}>
-            {active && editable && focusName === name ? (
+            {active && !notEditableReason && focusName === name ? (
                 <>
                     <Grid item xs={4}>
                         <TextField
@@ -324,11 +324,11 @@ const invalidScenario: ScenarioFull = [
     [],
     {},
     [],
-    false,
-    false,
     "invalid",
-    false,
-    false,
+    "invalid",
+    "invalid",
+    "invalid",
+    "invalid",
 ];
 
 const ScenarioViewer = (props: ScenarioViewerProps) => {
@@ -390,11 +390,11 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
         scDeletable,
         scPromotable,
         scNotSubmittableReason,
-        scReadable,
-        scEditable,
+        scNotReadableReason,
+        scNotEditableReason,
     ] = scenario || invalidScenario;
 
-    const active = useDynamicProperty(props.active, props.defaultActive, true) && scReadable;
+    const active = useDynamicProperty(props.active, props.defaultActive, true) && !scNotReadableReason;
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
 
     const [deleteDialog, setDeleteDialogOpen] = useState(false);
@@ -595,7 +595,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
         [sequences]
     );
 
-    const addSequenceHandler = useCallback(() => setSequences((seq) => [...seq, ["", [], "", true]]), []);
+    const addSequenceHandler = useCallback(() => setSequences((seq) => [...seq, ["", [], "", ""]]), []);
 
     // on scenario change
     useEffect(() => {
@@ -714,7 +714,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                                     onClick={onFocus}
                                     sx={hoverSx}
                                 >
-                                    {active && scEditable && focusName === "label" ? (
+                                    {active && !scNotEditableReason && focusName === "label" ? (
                                         <TextField
                                             label="Label"
                                             variant="outlined"
@@ -770,7 +770,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                                         onClick={onFocus}
                                         sx={hoverSx}
                                     >
-                                        {active && scEditable && focusName === "tags" ? (
+                                        {active && !scNotEditableReason && focusName === "tags" ? (
                                             <Autocomplete
                                                 multiple
                                                 options={scAuthorizedTags}
@@ -857,7 +857,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                                 setFocusName={setFocusName}
                                 onFocus={onFocus}
                                 onEdit={props.onEdit}
-                                editable={scEditable}
+                                notEditableReason={scNotEditableReason}
                                 updatePropVars={updateScVars}
                             />
                             {showSequences ? (
@@ -874,7 +874,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                                     </Grid>
 
                                     {sequences.map((item, index) => {
-                                        const [label, taskIds, notSubmittableReason, editable] = item;
+                                        const [label, taskIds, notSubmittableReason, notEditableReason] = item;
                                         return (
                                             <SequenceRow
                                                 active={active}
@@ -891,7 +891,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                                                 focusName={focusName}
                                                 setFocusName={setFocusName}
                                                 notSubmittableReason={notSubmittableReason}
-                                                editable={editable}
+                                                notEditableReason={notEditableReason}
                                                 isValid={isValidSequence}
                                             />
                                         );

+ 5 - 5
frontend/taipy/src/utils.ts

@@ -24,14 +24,14 @@ export type ScenarioFull = [
     string,     // label
     string[],   // tags
     Array<[string, string]>,    // properties
-    Array<[string, string[], string, boolean]>,   // sequences (label, task ids, notSubmittableReason, editable)
+    Array<[string, string[], string, string]>,   // sequences (label, task ids, notSubmittableReason, notEditableReason)
     Record<string, string>, // tasks (id: label)
     string[],   // authorized_tags
-    boolean,    // deletable
-    boolean,    // promotable
+    string,    // notDeletableReason
+    string,    // notPromotableReason
     string,     // notSubmittableReason
-    boolean,    // readable
-    boolean     // editable
+    string,     // notReadableReason
+    string      // notEditableReason
 ];
 
 export enum ScFProps {

+ 3 - 0
taipy/core/_orchestrator/_dispatcher/_development_job_dispatcher.py

@@ -9,6 +9,7 @@
 # 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 datetime
 from typing import Optional
 
 from ...job.job import Job
@@ -44,5 +45,7 @@ class _DevelopmentJobDispatcher(_JobDispatcher):
         Parameters:
             job (Job^): The job to submit on an executor with an available worker.
         """
+        job.execution_started_at = datetime.datetime.now()
         rs = _TaskFunctionWrapper(job.id, job.task).execute()
         self._update_job_status(job, rs)
+        job.execution_ended_at = datetime.datetime.now()

+ 4 - 0
taipy/core/_orchestrator/_dispatcher/_standalone_job_dispatcher.py

@@ -9,6 +9,7 @@
 # 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 datetime
 import multiprocessing as mp
 from concurrent.futures import Executor, ProcessPoolExecutor
 from functools import partial
@@ -59,6 +60,8 @@ class _StandaloneJobDispatcher(_JobDispatcher):
             self._nb_available_workers -= 1
             self._logger.debug(f"Setting nb_available_workers to {self._nb_available_workers} in the dispatch method.")
         config_as_string = _TomlSerializer()._serialize(Config._applied_config)  # type: ignore[attr-defined]
+
+        job.execution_started_at = datetime.datetime.now()
         future = self._executor.submit(_TaskFunctionWrapper(job.id, job.task), config_as_string=config_as_string)
         future.add_done_callback(partial(self._update_job_status_from_future, job))
 
@@ -67,3 +70,4 @@ class _StandaloneJobDispatcher(_JobDispatcher):
             self._nb_available_workers += 1
             self._logger.debug(f"Setting nb_available_workers to {self._nb_available_workers} in the callback method.")
         self._update_job_status(job, ft.result())
+        job.execution_ended_at = datetime.datetime.now()

+ 24 - 1
taipy/core/config/checkers/_scenario_config_checker.py

@@ -38,10 +38,33 @@ class _ScenarioConfigChecker(_ConfigChecker):
                 self._check_addition_data_node_configs(scenario_config_id, scenario_config)
                 self._check_additional_dns_not_overlapping_tasks_dns(scenario_config_id, scenario_config)
                 self._check_tasks_in_sequences_exist_in_scenario_tasks(scenario_config_id, scenario_config)
+                self._check_if_children_config_id_is_overlapping_with_properties(scenario_config_id, scenario_config)
                 self._check_comparators(scenario_config_id, scenario_config)
 
         return self._collector
 
+    def _check_if_children_config_id_is_overlapping_with_properties(
+        self, scenario_config_id: str, scenario_config: ScenarioConfig
+    ):
+        if scenario_config.tasks:
+            for task in scenario_config.tasks:
+                if isinstance(task, TaskConfig) and task.id in scenario_config.properties:
+                    self._error(
+                        TaskConfig._ID_KEY,
+                        task.id,
+                        f"The id of the TaskConfig `{task.id}` is overlapping with the "
+                        f"property `{task.id}` of ScenarioConfig `{scenario_config_id}`.",
+                    )
+        if scenario_config.data_nodes:
+            for data_node in scenario_config.data_nodes:
+                if isinstance(data_node, DataNodeConfig) and data_node.id in scenario_config.properties:
+                    self._error(
+                        DataNodeConfig._ID_KEY,
+                        data_node.id,
+                        f"The id of the DataNodeConfig `{data_node.id}` is overlapping with the "
+                        f"property `{data_node.id}` of ScenarioConfig `{scenario_config_id}`.",
+                    )
+
     def _check_task_configs(self, scenario_config_id: str, scenario_config: ScenarioConfig):
         self._check_children(
             ScenarioConfig,
@@ -78,7 +101,7 @@ class _ScenarioConfigChecker(_ConfigChecker):
                 f"{ScenarioConfig._COMPARATOR_KEY} field of ScenarioConfig"
                 f" `{scenario_config_id}` must be populated with a dictionary value.",
             )
-        else:
+        elif scenario_config.comparators is not None:
             for data_node_id, comparator in scenario_config.comparators.items():
                 if data_node_id not in Config.data_nodes:
                     self._error(

+ 11 - 0
taipy/core/config/checkers/_task_config_checker.py

@@ -40,8 +40,19 @@ class _TaskConfigChecker(_ConfigChecker):
                 self._check_existing_function(task_config_id, task_config)
                 self._check_inputs(task_config_id, task_config)
                 self._check_outputs(task_config_id, task_config)
+                self._check_if_children_config_id_is_overlapping_with_properties(task_config_id, task_config)
         return self._collector
 
+    def _check_if_children_config_id_is_overlapping_with_properties(self, task_config_id: str, task_config: TaskConfig):
+        for data_node in task_config.input_configs + task_config.output_configs:
+            if isinstance(data_node, DataNodeConfig) and data_node.id in task_config.properties:
+                self._error(
+                    DataNodeConfig._ID_KEY,
+                    data_node.id,
+                    f"The id of the DataNodeConfig `{data_node.id}` is overlapping with the "
+                    f"property `{data_node.id}` of TaskConfig `{task_config_id}`.",
+                )
+
     def _check_if_config_id_is_overlapping_with_scenario_attributes(
         self, task_config_id: str, task_config: TaskConfig, scenario_attributes: List[str]
     ):

+ 6 - 0
taipy/core/job/_job_converter.py

@@ -31,6 +31,8 @@ class _JobConverter(_AbstractConverter):
             job.submit_id,
             job.submit_entity_id,
             job._creation_date.isoformat(),
+            job._execution_started_at.isoformat() if job._execution_started_at else None,
+            job._execution_ended_at.isoformat() if job._execution_ended_at else None,
             cls.__serialize_subscribers(job._subscribers),
             job._stacktrace,
             version=job._version,
@@ -52,6 +54,10 @@ class _JobConverter(_AbstractConverter):
         job._status = model.status  # type: ignore
         job._force = model.force  # type: ignore
         job._creation_date = datetime.fromisoformat(model.creation_date)  # type: ignore
+        job._execution_started_at = (
+            datetime.fromisoformat(model.execution_started_at) if model.execution_started_at else None
+        )
+        job._execution_ended_at = datetime.fromisoformat(model.execution_ended_at) if model.execution_ended_at else None
         for it in model.subscribers:
             try:
                 fct_module, fct_name = it.get("fct_module"), it.get("fct_name")

+ 7 - 1
taipy/core/job/_job_model.py

@@ -10,7 +10,7 @@
 # specific language governing permissions and limitations under the License.
 
 from dataclasses import dataclass
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
 
 from .._repository._base_taipy_model import _BaseModel
 from .job_id import JobId
@@ -26,6 +26,8 @@ class _JobModel(_BaseModel):
     submit_id: str
     submit_entity_id: str
     creation_date: str
+    execution_started_at: Optional[str]
+    execution_ended_at: Optional[str]
     subscribers: List[Dict]
     stacktrace: List[str]
     version: str
@@ -40,6 +42,8 @@ class _JobModel(_BaseModel):
             submit_id=data["submit_id"],
             submit_entity_id=data["submit_entity_id"],
             creation_date=data["creation_date"],
+            execution_started_at=data["execution_started_at"],
+            execution_ended_at=data["execution_ended_at"],
             subscribers=_BaseModel._deserialize_attribute(data["subscribers"]),
             stacktrace=_BaseModel._deserialize_attribute(data["stacktrace"]),
             version=data["version"],
@@ -54,6 +58,8 @@ class _JobModel(_BaseModel):
             self.submit_id,
             self.submit_entity_id,
             self.creation_date,
+            self.execution_started_at,
+            self.execution_ended_at,
             _BaseModel._serialize_attribute(self.subscribers),
             _BaseModel._serialize_attribute(self.stacktrace),
             self.version,

+ 35 - 0
taipy/core/job/job.py

@@ -78,6 +78,8 @@ class Job(_Entity, _Labeled):
         self._creation_date = datetime.now()
         self._submit_id: str = submit_id
         self._submit_entity_id: str = submit_entity_id
+        self._execution_started_at: Optional[datetime] = None
+        self._execution_ended_at: Optional[datetime] = None
         self._subscribers: List[Callable] = []
         self._stacktrace: List[str] = []
         self.__logger = _TaipyLogger._get_logger()
@@ -144,6 +146,39 @@ class Job(_Entity, _Labeled):
     def creation_date(self, val):
         self._creation_date = val
 
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def execution_started_at(self) -> Optional[datetime]:
+        return self._execution_started_at
+
+    @execution_started_at.setter
+    @_self_setter(_MANAGER_NAME)
+    def execution_started_at(self, val):
+        self._execution_started_at = val
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def execution_ended_at(self) -> Optional[datetime]:
+        return self._execution_ended_at
+
+    @execution_ended_at.setter
+    @_self_setter(_MANAGER_NAME)
+    def execution_ended_at(self, val):
+        self._execution_ended_at = val
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def execution_duration(self) -> Optional[float]:
+        """Get the duration of the job execution in seconds.
+
+        Returns:
+            Optional[float]: The duration of the job execution in seconds. If the job is not
+            completed, None is returned.
+        """
+        if self._execution_started_at and self._execution_ended_at:
+            return (self._execution_ended_at - self._execution_started_at).total_seconds()
+        return None
+
     @property  # type: ignore
     @_self_reload(_MANAGER_NAME)
     def stacktrace(self) -> List[str]:

+ 27 - 0
taipy/core/submission/submission.py

@@ -138,6 +138,33 @@ class Submission(_Entity, _Labeled):
     def creation_date(self):
         return self._creation_date
 
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def execution_started_at(self) -> Optional[datetime]:
+        if all(job.execution_started_at is not None for job in self.jobs):
+            return min(job.execution_started_at for job in self.jobs)
+        return None
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def execution_ended_at(self) -> Optional[datetime]:
+        if all(job.execution_ended_at is not None for job in self.jobs):
+            return max(job.execution_ended_at for job in self.jobs)
+        return None
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def execution_duration(self) -> Optional[float]:
+        """Get the duration of the submission in seconds.
+
+        Returns:
+            Optional[float]: The duration of the submission in seconds. If the job is not
+            completed, None is returned.
+        """
+        if self.execution_started_at and self.execution_ended_at:
+            return (self.execution_ended_at - self.execution_started_at).total_seconds()
+        return None
+
     def get_label(self) -> str:
         """Returns the submission simple label prefixed by its owner label.
 

+ 2 - 1
taipy/gui/_default_config.py

@@ -58,12 +58,14 @@ default_config: Config = {
     "ngrok_token": "",
     "notebook_proxy": True,
     "notification_duration": 3000,
+    "port": 5000,
     "propagate": True,
     "run_browser": True,
     "run_in_thread": False,
     "run_server": True,
     "server_config": None,
     "single_client": False,
+    "state_retention_period": 0,
     "system_notification": False,
     "theme": None,
     "time_zone": None,
@@ -74,5 +76,4 @@ default_config: Config = {
     "use_reloader": False,
     "watermark": "Taipy inside",
     "webapp_path": None,
-    "port": 5000,
 }

+ 1 - 0
taipy/gui/_renderers/factory.py

@@ -361,6 +361,7 @@ class _Factory:
                 ("show_value", PropertyType.boolean, True),
                 ("format", PropertyType.string),
                 ("delta_format", PropertyType.string),
+                ("bar_color", PropertyType.string),
                 ("color_map", PropertyType.dict),
                 ("hover_text", PropertyType.dynamic_string),
                 ("template", PropertyType.dict),

+ 6 - 4
taipy/gui/config.py

@@ -46,6 +46,7 @@ ConfigParameter = t.Literal[
     "ngrok_token",
     "notebook_proxy",
     "notification_duration",
+    "port",
     "propagate",
     "run_browser",
     "run_in_thread",
@@ -56,13 +57,13 @@ ConfigParameter = t.Literal[
     "theme",
     "time_zone",
     "title",
+    "state_retention_period",
     "stylekit",
     "upload_folder",
     "use_arrow",
     "use_reloader",
     "watermark",
     "webapp_path",
-    "port",
 ]
 
 Stylekit = t.TypedDict(
@@ -117,23 +118,24 @@ Config = t.TypedDict(
         "ngrok_token": str,
         "notebook_proxy": bool,
         "notification_duration": int,
+        "port": t.Union[t.Literal["auto"], int],
         "propagate": bool,
         "run_browser": bool,
         "run_in_thread": bool,
         "run_server": bool,
         "server_config": t.Optional[ServerConfig],
         "single_client": bool,
+        "state_retention_period": int,
+        "stylekit": t.Union[bool, Stylekit],
         "system_notification": bool,
         "theme": t.Optional[t.Dict[str, t.Any]],
         "time_zone": t.Optional[str],
         "title": t.Optional[str],
-        "stylekit": t.Union[bool, Stylekit],
         "upload_folder": t.Optional[str],
         "use_arrow": bool,
         "use_reloader": bool,
         "watermark": t.Optional[str],
         "webapp_path": t.Optional[str],
-        "port": t.Union[t.Literal["auto"], int],
     },
     total=False,
 )
@@ -235,7 +237,7 @@ class _Config(object):
                     elif key == "port" and str(value).strip() == "auto":
                         config["port"] = "auto"
                     else:
-                        config[key] = value if config[key] is None else type(config[key])(value)  # type: ignore
+                        config[key] = value if config[key] is None else type(config[key])(value)
                 except Exception as e:
                     _warn(
                         f"Invalid keyword arguments value in Gui.run {key} - {value}. Unable to parse value to the correct type",  # noqa: E501

+ 17 - 0
taipy/gui/gui.py

@@ -26,6 +26,7 @@ import warnings
 from importlib import metadata, util
 from importlib.util import find_spec
 from pathlib import Path
+from threading import Timer
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from urllib.parse import unquote, urlencode, urlparse
 
@@ -611,6 +612,22 @@ class Gui:
 
     def _handle_disconnect(self):
         Hooks()._handle_disconnect(self)
+        if (sid := getattr(request, "sid", None)) and (st_to := self._get_config("state_retention_period", 0)) > 0:
+            for cl_id, sids in self.__client_id_2_sid.items():
+                if sid in sids:
+                    if len(sids) == 1:
+                        Timer(st_to, self._remove_state, [cl_id]).start()
+                    else:
+                        sids.remove(sid)
+                    return
+
+    def _remove_state(self, client_id: str):
+        if (sids := self.__client_id_2_sid.get(client_id, None)) and len(sids) == 1:
+            try:
+                del self.__client_id_2_sid[client_id]
+                self._bindings()._delete_scope(client_id)
+            except Exception as e:
+                _warn(f"Unexpected error removing state {client_id}", e)
 
     def _manage_message(self, msg_type: _WsType, message: dict) -> None:
         try:

+ 1 - 1
taipy/gui/hook.py

@@ -11,7 +11,7 @@ class Hook:
 
 class Hooks(object, metaclass=_Singleton):
     def __init__(self):
-        self.__hooks: t.List[Hook] = []
+        self.__hooks: t.List[Hook] = []  # type: ignore[annotation-unchecked]
 
     def _register_hook(self, hook: Hook):
         # Prevent duplicated hooks

+ 1 - 1
taipy/gui/server.py

@@ -291,7 +291,7 @@ class _Server:
             runtime_manager.add_gui(self._gui, port)
         if debug and not is_running_from_reloader() and _is_port_open(host_value, port):
             raise ConnectionError(
-                "Port {port} is already opened on {host} because another application is running on the same port. Please pick another port number and rerun with the 'port=<new_port>' option. You can also let Taipy choose a port number for you by running with the 'port=\"auto\"' option."  # noqa: E501
+                f"Port {port} is already opened on {host} because another application is running on the same port.\nPlease pick another port number and rerun with the 'port=<new_port>' setting.\nYou can also let Taipy choose a port number for you by running with the 'port=\"auto\"' setting."  # noqa: E501
             )
         if not flask_log:
             log = logging.getLogger("werkzeug")

+ 3 - 0
taipy/gui/utils/_bindings.py

@@ -68,6 +68,9 @@ class _Bindings:
         self.__scopes.create_scope(id)
         return id, create
 
+    def _delete_scope(self, id: str):
+        self.__scopes.delete_scope(id)
+
     def _new_scopes(self):
         self.__scopes = _DataScopes(self.__gui)
 

+ 2 - 0
taipy/gui/utils/types.py

@@ -86,6 +86,8 @@ class _TaipyBool(_TaipyBase):
 
 class _TaipyNumber(_TaipyBase):
     def get(self):
+        if super().get() is None:
+            return None
         try:
             return float(super().get())
         except Exception as e:

+ 82 - 77
taipy/gui/viselements.json

@@ -44,14 +44,14 @@
                     {
                         "name": "label",
                         "default_property": true,
-                        "type": "dynamic(str|Icon)",
+                        "type": "dynamic(Union[str,Icon])",
                         "default_value": "\"\"",
                         "doc": "The label displayed in the button."
                     },
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "The name of a function that is triggered when the button is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (optional[str]): the identifier of the button.</li>\n<li>payload (dict): a dictionary that contains the key \"action\" set to the name of the action that triggered this callback.</li>\n</ul>",
+                        "doc": "The name of a function that is triggered when the button is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button it it has one.</li>\n<li>payload (dict): a dictionary that contains the key \"action\" set to the name of the action that triggered this callback.</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -142,24 +142,24 @@
                     },
                     {
                         "name": "step",
-                        "type": "dynamic(int|float)",
+                        "type": "dynamic(Union[int,float])",
                         "default_value": "1",
                         "doc": "The amount by which the value is incremented or decremented when the user clicks one of the arrow buttons."
                     },
                     {
                         "name": "step_multiplier",
-                        "type": "dynamic(int|float)",
+                        "type": "dynamic(Union[int,float])",
                         "default_value": "10",
                         "doc": "A factor that multiplies <i>step</i> when the user presses the Shift key while clicking one of the arrow buttons."
                     },
                     {
                         "name": "min",
-                        "type": "dynamic(int|float)",
+                        "type": "dynamic(Union[int,float])",
                         "doc": "The minimum value to accept for this input."
                     },
                     {
                         "name": "max",
-                        "type": "dynamic(int|float)",
+                        "type": "dynamic(Union[int,float])",
                         "doc": "The maximum value to accept for this input."
                     }
                 ]
@@ -176,26 +176,26 @@
                     {
                         "name": "value",
                         "default_property": true,
-                        "type": "dynamic(int|float|int[]|float[]|str|str[])",
+                        "type": "dynamic(Union[int,float,str,list[int],list[float],list[str]])",
                         "doc": "The value that is set for this slider.<br/>If this slider is based on a <i>lov</i> then this property can be set to the lov element.<br/>This value can also hold an array of numbers to indicate that the slider reflects a range (within the [<i>min</i>,<i>max</i>] domain) defined by several knobs that the user can set independently.<br/>If this slider is based on a <i>lov</i> then this property can be set to an array of lov elements. The slider is then represented with several knobs, one for each lov value."
                     },
                     {
                         "name": "min",
-                        "type": "int|float",
+                        "type": "Union[int,float]",
                         "default_value": "0",
                         "doc": "The minimum value.<br/>This is ignored when <i>lov</i> is defined."
                     },
                     {
                         "name": "max",
-                        "type": "int|float",
+                        "type": "Union[int,float]",
                         "default_value": "100",
                         "doc": "The maximum value.<br/>This is ignored when <i>lov</i> is defined."
                     },
                     {
                         "name": "step",
-                        "type": "int|float",
+                        "type": "Union[int,float]",
                         "default_value": "1",
-                        "doc": "The step value: the gap between two consecutive values the slider set. It is a good practice to have (<i>max</i>-<i>min</i>) being divisible by <i>step</i>.<br/>This property is ignored when <i>lov</i> is defined."
+                        "doc": "The step value, which is the gap between two consecutive values the slider set. It is a good practice to have (<i>max</i>-<i>min</i>) being divisible by <i>step</i>.<br/>This property is ignored when <i>lov</i> is defined."
                     },
                     {
                         "name": "text_anchor",
@@ -205,7 +205,7 @@
                     },
                     {
                         "name": "labels",
-                        "type": "bool|dict",
+                        "type": "Union[bool,dict[str,str]]",
                         "doc": "The labels for specific points of the slider.<br/>If set to True, this slider uses the labels of the <i>lov</i> if there are any.<br/>If set to a dictionary, the slider uses the dictionary keys as a <i>lov</i> key or index, and the associated value as the label."
                     },
                     {
@@ -501,7 +501,7 @@
                     {
                         "name": "on_range_change",
                         "type": "Callback",
-                        "doc": "The callback function that is invoked when the visible part of the x axis changes.<br/>The function receives three parameters:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (optional[str]): the identifier of the chart control.</li>\n<li>payload (dict[str, any]): the full details on this callback's invocation, as emitted by <a href=\"https://plotly.com/javascript/plotlyjs-events/#update-data\">Plotly</a>.</li>\n</ul>",
+                        "doc": "The callback function that is invoked when the visible part of the x axis changes.<br/>The function receives three parameters:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the chart control if it has one.</li>\n<li>payload (dict[str, any]): the full details on this callback's invocation, as emitted by <a href=\"https://plotly.com/javascript/plotlyjs-events/#update-data\">Plotly</a>.</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -519,7 +519,7 @@
                     },
                     {
                         "name": "columns",
-                        "type": "str|list[str]|dict[str, dict[str, str]]",
+                        "type": "Union[str,list[str],dict[str,dict[str,str]]]",
                         "default_value": "<i>All columns</i>",
                         "doc": "The list of column names\n<ul>\n<li>str: ;-separated list of column names</li>\n<li>list[str]: list of names</li>\n<li>dict: {\"column_name\": {format: \"format\", index: 1}} if index is specified, it represents the display order of the columns.\nIf not, the list order defines the index</li>\n</ul>"
                     },
@@ -535,7 +535,7 @@
                     },
                     {
                         "name": "selected",
-                        "type": "indexed(dynamic(list[int]|str))",
+                        "type": "indexed(dynamic(Union[list[int],str]))",
                         "doc": "The list of the selected point indices  ."
                     },
                     {
@@ -555,7 +555,7 @@
                     },
                     {
                         "name": "line",
-                        "type": "indexed(str|dict[str, any])",
+                        "type": "indexed(Union[str,dict[str,any]])",
                         "doc": "The configuration of the line used for the indicated trace.<br/>See <a href=\"https://plotly.com/javascript/reference/scatter/#scatter-line\">line</a> for details.<br/>If the value is a string, it must be a dash type or pattern (see <a href=\"https://plotly.com/python/reference/scatter/#scatter-line-dash\">dash style of lines</a> for details)."
                     },
                     {
@@ -600,13 +600,13 @@
                     },
                     {
                         "name": "width",
-                        "type": "str|int|float",
+                        "type": "Union[str,int,float]",
                         "default_value": "\"100%\"",
                         "doc": "The width of this chart, in CSS units."
                     },
                     {
                         "name": "height",
-                        "type": "str|int|float",
+                        "type": "Union[str,int,float]",
                         "doc": "The height of this chart, in CSS units."
                     },
                     {
@@ -689,18 +689,18 @@
                     },
                     {
                         "name": "selected",
-                        "type": "dynamic(list[int]|str)",
+                        "type": "dynamic(Union[list[int],str])",
                         "doc": "The list of the indices of the rows to be displayed as selected."
                     },
                     {
                         "name": "page_size_options",
-                        "type": "list[int]|str",
-                        "default_value": "[50, 100, 500]",
+                        "type": "Union[list[int],str]",
+                        "default_value": "(50, 100, 500)",
                         "doc": "The list of available page sizes that users can choose from."
                     },
                     {
                         "name": "columns",
-                        "type": "str|list[str]|dict[str, dict[str, str|int]]",
+                        "type": "Union[str,list[str],dict[str,dict[str,Union[str,int]]]]",
                         "default_value": "<i>shows all columns when empty</i>",
                         "doc": "The list of the column names to display.\n<ul>\n<li>str: Semicolon (';')-separated list of column names.</li>\n<li>list[str]: The list of column names.</li>\n<li>dict: A dictionary with entries matching: {\"col name\": {format: \"format\", index: 1}}.<br/>\nif <i>index</i> is specified, it represents the display order of the columns.\nIf <i>index</i> is not specified, the list order defines the index.<br/>\nIf <i>format</i> is specified, it is used for numbers or dates.</li>\n</ul>"
                     },
@@ -885,12 +885,12 @@
                     },
                     {
                         "name": "lov[<i>column_name</i>]",
-                        "type": "list[str]|str",
+                        "type": "Union[list[str],str]",
                         "doc": "The list of values of the indicated column."
                     },
                     {
                         "name": "downloadable",
-                        "type": "boolean",
+                        "type": "bool",
                         "doc": "If True, a clickable icon is shown so the user can download the data as CSV."
                     },
                     {
@@ -954,13 +954,13 @@
                     },
                     {
                         "name": "width",
-                        "type": "str|int",
+                        "type": "Union[str,int]",
                         "default_value": "\"360px\"",
                         "doc": "The width of this selector, in CSS units."
                     },
                     {
                         "name": "height",
-                        "type": "str|int",
+                        "type": "Union[str,int]",
                         "doc": "The height of this selector, in CSS units."
                     }
                 ]
@@ -977,7 +977,7 @@
                     {
                         "name": "content",
                         "default_property": true,
-                        "type": "dynamic(path|file|URL|ReadableBuffer|None)",
+                        "type": "dynamic(Union[path,file,URL,ReadableBuffer,None])",
                         "doc": "The content to transfer.<br/>If this is a string, a URL, or a file, then the content is read from this source.<br/>If a readable buffer is provided (such as an array of bytes...), and to prevent the bandwidth from being consumed too much, the way the data is transferred depends on the <i>data_url_max_size</i> parameter of the application configuration (which is set to 50kB by default):\n<ul>\n<li>If the buffer size is smaller than this setting, then the raw content is generated as a data URL, encoded using base64 (i.e. <code>\"data:&lt;mimetype&gt;;base64,&lt;data&gt;\"</code>).</li>\n<li>If the buffer size exceeds this setting, then it is transferred through a temporary file.</li>\n</ul>If this property is set to None, that indicates that dynamic content is generated. Please take a look at the examples below for details on dynamic generation."
                     },
                     {
@@ -988,7 +988,7 @@
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "The name of a function that is triggered when the download is terminated (or on user action if <i>content</i> is None).<br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (optional[str]): the identifier of the button.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has two keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: A list of two elements: <i>args[0]</i> reflects the <i>name</i> property and <i>args[1]</i> holds the file URL.</li>\n</ul>\n</li>\n</ul>",
+                        "doc": "The name of a function that is triggered when the download is terminated (or on user action if <i>content</i> is None).<br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button if it has one.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has two keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: A list of two elements: <i>args[0]</i> reflects the <i>name</i> property and <i>args[1]</i> holds the file URL.</li>\n</ul>\n</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -1052,7 +1052,7 @@
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "The name of the function that will be triggered.<br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (optional[str]): the identifier of the button.</li>\n<li>payload (dict): a dictionary that contains the key \"action\" set to the name of the action that triggered this callback.</li>\n</ul>",
+                        "doc": "The name of the function that will be triggered.<br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button if it has one.</li>\n<li>payload (dict): a dictionary that contains the key \"action\" set to the name of the action that triggered this callback.</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -1106,7 +1106,7 @@
                     {
                         "name": "content",
                         "default_property": true,
-                        "type": "dynamic(path|URL|file|ReadableBuffer)",
+                        "type": "dynamic(Union[path,URL,file,ReadableBuffer])",
                         "doc": "The image source.<br/>If a buffer is provided (string, array of bytes...), and in order to prevent the bandwidth to be consumed too much, the way the image data is transferred depends on the <i>data_url_max_size</i> parameter of the application configuration (which is set to 50kB by default):\n<ul>\n<li>If the size of the buffer is smaller than this setting, then the raw content is generated as a\n  data URL, encoded using base64 (i.e. <code>\"data:&lt;mimetype&gt;;base64,&lt;data&gt;\"</code>).</li>\n<li>If the size of the buffer is greater than this setting, then it is transferred through a temporary\n  file.</li>\n</ul>"
                     },
                     {
@@ -1117,7 +1117,7 @@
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "The name of a function that is triggered when the user clicks on the image.<br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (optional[str]): the identifier of the button.</li>\n<li>payload (dict): a dictionary that contains the key \"action\" set to the name of the action that triggered this callback.</li>\n</ul>",
+                        "doc": "The name of a function that is triggered when the user clicks on the image.<br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button if it has one.</li>\n<li>payload (dict): a dictionary that contains the key \"action\" set to the name of the action that triggered this callback.</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -1135,13 +1135,13 @@
                     },
                     {
                         "name": "width",
-                        "type": "str|int|float",
+                        "type": "Union[str,int,float]",
                         "default_value": "\"300px\"",
                         "doc": "The width of this image control, in CSS units."
                     },
                     {
                         "name": "height",
-                        "type": "str|int|float",
+                        "type": "Union[str,int,float]",
                         "doc": "The height of this image control, in CSS units."
                     }
                 ]
@@ -1157,52 +1157,51 @@
                     {
                         "name": "value",
                         "default_property": true,
-                        "type": "dynamic(int|float)",
-                        "doc": "The value to display."
+                        "type": "dynamic(Union[int,float])",
+                        "doc": "The value to represent."
                     },
                     {
                         "name": "type",
                         "default_value": "\"circular\"",
                         "type": "str",
-                        "doc": "The type of the gauge.<br/>Possible values are:\n<ul>\n<li>\"none\"</li>\n<li>\"circular\"</li>\n<li>\"linear\"</li></ul>."
-                    },
-                    {
-                        "name": "title",
-                        "default_value": "None",
-                        "type": "str",
-                        "doc": "The title of the metric."
+                        "doc": "The type of the gauge.<br/>Possible values are:\n<ul>\n<li>\"none\"</li>\n<li>\"circular\"</li>\n<li>\"linear\"</li></ul>Setting this value to \"none\" remove the gauge."
                     },
                     {
                         "name": "min",
-                        "type": "int|float",
+                        "type": "Union[int,float]",
                         "default_value": "0",
-                        "doc": "The minimum value of this metric control."
+                        "doc": "The minimum value of this metric control's gauge."
                     },
                     {
                         "name": "max",
-                        "type": "int|float",
+                        "type": "Union[int,float]",
                         "default_value": "100",
-                        "doc": "The maximum value of this metric control."
+                        "doc": "The maximum value of this metric control's gauge."
                     },
                     {
                         "name": "delta",
-                        "type": "dynamic(int|float)",
+                        "type": "dynamic(Union[int,float])",
                         "doc": "The delta value to display."
                     },
                     {
                         "name": "delta_color",
                         "type": "str",
-                        "doc": "The color that is used to display the value of the <i>delta</i> property. If negative_delta_color is set, then this property applies for positive values of delta only. If this property is set to \"invert\", then delta values are represented with the color used for negative values if delta is positive. The value for delta is also represented with the color used for positive values if delta is negative."
+                        "doc": "The color that is used to display the value of the <i>delta</i> property.<br/>If <i>negative_delta_color</i> is set, then this property applies for positive values of <i>delta</i> only.<br/>If this property is set to \"invert\", then values for <i>delta</i> are represented with the color used for negative values if delta is positive and <i>delta</i> is represented with the color used for positive values if it is negative."
+                    },
+                    {
+                        "name": "title",
+                        "default_value": "None",
+                        "type": "str",
+                        "doc": "The title of the metric."
                     },
                     {
                         "name": "negative_delta_color",
                         "type": "str",
-                        "doc": "If set, this represents the color to be used when the value of <i>delta</i> is negative (or positive if <i>delta_color</i> is set to \"invert\")"
-
+                        "doc": "If set, this represents the color to be used when the value of <i>delta</i> is negative (or positive if <i>delta_color</i> is set to \"invert\")."
                     },
                     {
                         "name": "threshold",
-                        "type": "dynamic(int|float)",
+                        "type": "dynamic(Union[int,float])",
                         "doc": "The threshold value to display."
                     },
                     {
@@ -1221,23 +1220,33 @@
                         "type": "str",
                         "doc": "The format to use when displaying the delta value.<br/>This uses the <code>printf</code> syntax."
                     },
+                    {
+                        "name": "bar_color",
+                        "type": "str",
+                        "doc": "The color of the bar in the gauge."
+                    },
                     {
                         "name": "color_map",
                         "type": "dict",
-                        "doc": "TODO The color_map is used to display different colors for different ranges of the metric. The color_map's keys represent the starting point of each range, which is a number, while the values represent the corresponding color for that range. If the value associated with a key is set to None, it implies that the corresponding range is not assigned any color."
+                        "doc": "Indicates what colors should be used for different ranges of the metric. The <i>color_map</i>'s keys represent the lower bound of each range, which is a number, while the values represent the color for that range.<br/>If the value associated with a key is set to None, the corresponding range is not assigned any color."
                     },
                     {
                         "name": "width",
-                        "type": "str|number",
+                        "type": "Union[str,number]",
                         "default_value": "None",
                         "doc": "The width of the metric control, in CSS units."
                     },
                     {
                         "name": "height",
-                        "type": "str|number",
+                        "type": "Union[str,number]",
                         "default_value": "None",
                         "doc": "The height of the metric control, in CSS units."
                     },
+                    {
+                        "name": "layout",
+                        "type": "dynamic(dict[str, any])",
+                        "doc": "The <i>plotly.js</i> compatible <a href=\"https://plotly.com/javascript/reference/layout/\">layout object</a>."
+                    },
                     {
                         "name": "template",
                         "type": "dict",
@@ -1308,13 +1317,13 @@
                     },
                     {
                         "name": "min",
-                        "type": "int|float",
+                        "type": "Union[int,float]",
                         "default_value": "0",
                         "doc": "The minimum value of the range."
                     },
                     {
                         "name": "max",
-                        "type": "int|float",
+                        "type": "Union[int,float]",
                         "default_value": "100",
                         "doc": "The maximum value of the range."
                     },
@@ -1358,14 +1367,14 @@
                     {
                         "name": "lov",
                         "default_property": true,
-                        "type": "dynamic(str|list[str|Icon|any])",
+                        "type": "dynamic(Union[str,list[Union[str,Icon,any]]])",
                         "doc": "The list of menu option values."
                     },
                     {
                         "name": "adapter",
                         "type": "Function",
                         "default_value": "`\"lambda x: str(x)\"`",
-                        "doc": "The function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:str|Icon)</i>."
+                        "doc": "The function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:Union[str,Icon])</i>."
                     },
                     {
                         "name": "type",
@@ -1380,7 +1389,7 @@
                     },
                     {
                         "name": "inactive_ids",
-                        "type": "dynamic(str|list[str])",
+                        "type": "dynamic(Union[str,list[str]])",
                         "doc": "Semicolon (';')-separated list or a list of menu items identifiers that are disabled."
                     },
                     {
@@ -1398,7 +1407,7 @@
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "The name of the function that is triggered when a menu option is selected.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: List where the first element contains the id of the selected option.</li>\n</ul>\n</li>\n</ul>",
+                        "doc": "The name of the function that is triggered when a menu option is selected.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button if it has one.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: List where the first element contains the id of the selected option.</li>\n</ul>\n</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -1444,7 +1453,7 @@
                     {
                         "name": "value",
                         "default_property": true,
-                        "type": "tuple|dict|list[dict]|list[tuple]",
+                        "type": "Union[tuple,dict,list[dict],list[tuple]]",
                         "doc": "The different status items to represent. See below."
                     },
                     {
@@ -1473,7 +1482,7 @@
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "The name of the function that is triggered when the dialog button is pressed.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: a list with three elements:\n<ul><li>The first element is the username</li><li>The second element is the password</li><li>The third element is the current page name</li></ul></li></li>\n</ul>\n</li>\n</ul><br/>When the button is pressed, and if this property is not set, Taipy will try to find a callback function called <i>on_login()</i> and invoke it with the parameters listed above.",
+                        "doc": "The name of the function that is triggered when the dialog button is pressed.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button if it has one.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: a list with three elements:\n<ul><li>The first element is the username</li><li>The second element is the password</li><li>The third element is the current page name</li></ul></li></li>\n</ul>\n</li>\n</ul><br/>When the button is pressed, and if this property is not set, Taipy will try to find a callback function called <i>on_login()</i> and invoke it with the parameters listed above.",
                         "signature": [
                             [
                                 "state",
@@ -1514,7 +1523,7 @@
                     },
                     {
                         "name": "users",
-                        "type": "dynamic(list[str|Icon])",
+                        "type": "dynamic(list[Union[str,Icon]])",
                         "doc": "The list of users. See the <a href=\"../../binding/#list-of-values\">section on List of Values</a> for details."
                     },
                     {
@@ -1556,7 +1565,7 @@
                     },
                     {
                         "name": "height",
-                        "type": "str|int|float",
+                        "type": "Union[str,int,float]",
                         "doc": "The maximum height of this chat control, in CSS units."
                     }
                 ]
@@ -1571,7 +1580,7 @@
                 "properties": [
                     {
                         "name": "expanded",
-                        "type": "dynamic(bool|str[])",
+                        "type": "dynamic(Union[bool,list[str]])",
                         "default_value": "True",
                         "doc": "If Boolean and False, only one node can be expanded at one given level. Otherwise this should be set to an array of the node identifiers that need to be expanded."
                     },
@@ -1678,7 +1687,7 @@
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "Name of a function triggered when a button is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the dialog.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>This dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: a list where the first element contains the index of the selected label.</li>\n</ul>\n</li>\n</ul>",
+                        "doc": "Name of a function triggered when a button is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the dialog if it has one.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>This dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: a list where the first element contains the index of the selected label.</li>\n</ul>\n</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -1702,17 +1711,17 @@
                     },
                     {
                         "name": "labels",
-                        "type": " str|list[str]",
+                        "type": "Union[str,list[str]]",
                         "doc": "A list of labels to show in a row of buttons at the bottom of the dialog. The index of the button in the list is reported as args in the <tt>on_action</tt> callback (that index is -1 for the <i>close</i> icon)."
                     },
                     {
                         "name": "width",
-                        "type": "str|int|float",
+                        "type": "Union[str,int,float]",
                         "doc": "The width of this dialog, in CSS units."
                     },
                     {
                         "name": "height",
-                        "type": "str|int|float",
+                        "type": "Union[str,int,float]",
                         "doc": "The height of this dialog, in CSS units."
                     }
                 ]
@@ -1767,7 +1776,7 @@
                     {
                         "name": "on_close",
                         "type": "Callback",
-                        "doc": "The name of a function that is triggered when this pane is closed (if the user clicks outside of it or presses the Esc key).<br/>All parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (optional[str]): the identifier of the button.</li>\n</ul><br/>If this property is not set, no function is called when this pane is closed.",
+                        "doc": "The name of a function that is triggered when this pane is closed (if the user clicks outside of it or presses the Esc key).<br/>All parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (optional[str]): the identifier of the close button if it has one.</li>\n</ul><br/>If this property is not set, no function is called when this pane is closed.",
                         "signature": [
                             [
                                 "state",
@@ -1843,7 +1852,7 @@
                         "name": "adapter",
                         "type": "Function",
                         "default_value": "`lambda x: str(x)`",
-                        "doc": "The function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:str|Icon)</i>."
+                        "doc": "The function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:Union[str,Icon])</i>."
                     },
                     {
                         "name": "type",
@@ -1892,7 +1901,7 @@
                 "properties": [
                     {
                         "name": "partial",
-                        "type": "Partial",
+                        "type": "taipy.gui.Partial",
                         "doc": "A Partial object that holds the content of the block.<br/>This should not be defined if <i>page</i> is set."
                     },
                     {
@@ -1933,7 +1942,7 @@
                     {
                         "name": "on_action",
                         "type": "Callback",
-                        "doc": "Name of a function that is triggered when a specific key is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the input.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args (list):\n<ul><li>key name</li><li>variable name</li><li>current value</li></ul>\n</li>\n</ul>\n</li>\n</ul>",
+                        "doc": "Name of a function that is triggered when a specific key is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the control if it has one.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args (list):\n<ul><li>key name</li><li>variable name</li><li>current value</li></ul>\n</li>\n</ul>\n</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -1965,25 +1974,21 @@
                     {
                         "name": "id",
                         "type": "str",
-                        "default_value": "None",
                         "doc": "The identifier that is assigned to the rendered HTML component."
                     },
                     {
                         "name": "properties",
                         "type": "dict[str, any]",
-                        "default_value": "None",
                         "doc": "Bound to a dictionary that contains additional properties for this element."
                     },
                     {
                         "name": "class_name",
                         "type": "dynamic(str)",
-                        "default_value": "None",
                         "doc": "The list of CSS class names that are associated with the generated HTML Element.<br/>These class names are added to the default <code>taipy-&lt;element_type&gt;</code> class name."
                     },
                     {
                         "name": "hover_text",
                         "type": "dynamic(str)",
-                        "default_value": "None",
                         "doc": "The information that is displayed when the user hovers over this element."
                     }
                 ]

+ 13 - 9
taipy/gui_core/_adapters.py

@@ -37,6 +37,7 @@ from taipy.core import (
 from taipy.core import get as core_get
 from taipy.core.config import Config
 from taipy.core.data._tabular_datanode_mixin import _TabularDataNodeMixin
+from taipy.core.reason import ReasonCollection
 from taipy.gui._warnings import _warn
 from taipy.gui.gui import _DoNotUpdate
 from taipy.gui.utils import _is_boolean, _is_true, _TaipyBase
@@ -55,6 +56,9 @@ class _EntityType(Enum):
     DATANODE = 3
 
 
+def _get_reason(rc: ReasonCollection, message: str):
+    return "" if rc else f"{message}: {rc.reasons}"
+
 class _GuiCoreScenarioAdapter(_TaipyBase):
     __INNER_PROPS = ["name"]
 
@@ -84,8 +88,8 @@ class _GuiCoreScenarioAdapter(_TaipyBase):
                             (
                                 s.get_simple_label(),
                                 [t.id for t in s.tasks.values()] if hasattr(s, "tasks") else [],
-                                "" if (reason := is_submittable(s)) else f"Sequence not submittable: {reason.reasons}",
-                                is_editable(s),
+                                _get_reason(is_submittable(s), "Sequence not submittable"),
+                                _get_reason(is_editable(s), "Sequence not editable"),
                             )
                             for s in scenario.sequences.values()
                         ]
@@ -95,11 +99,11 @@ class _GuiCoreScenarioAdapter(_TaipyBase):
                         if hasattr(scenario, "tasks")
                         else {},
                         list(scenario.properties.get("authorized_tags", [])) if scenario.properties else [],
-                        is_deletable(scenario),
-                        is_promotable(scenario),
-                        "" if (reason := is_submittable(scenario)) else f"Scenario not submittable: {reason.reasons}",
-                        is_readable(scenario),
-                        is_editable(scenario),
+                        _get_reason(is_deletable(scenario), "Scenario not deletable"),
+                        _get_reason(is_promotable(scenario), "Scenario not promotable"),
+                        _get_reason(is_submittable(scenario), "Scenario not submittable"),
+                        _get_reason(is_readable(scenario), "Scenario not readable"),
+                        _get_reason(is_editable(scenario), "Scenario not editable"),
                     ]
             except Exception as e:
                 _warn(f"Access to scenario ({data.id if hasattr(data, 'id') else 'No_id'}) failed", e)
@@ -221,8 +225,8 @@ class _GuiCoreDatanodeAdapter(_TaipyBase):
                         self.__get_data(datanode),
                         datanode._edit_in_progress,
                         datanode._editor_id,
-                        is_readable(datanode),
-                        is_editable(datanode),
+                        _get_reason(is_readable(datanode), "Datanode not readable"),
+                        _get_reason(is_editable(datanode), "Datanode not editable"),
                     ]
             except Exception as e:
                 _warn(f"Access to datanode ({data.id if hasattr(data, 'id') else 'No_id'}) failed", e)

+ 5 - 3
taipy/gui_core/_context.py

@@ -414,6 +414,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         data = args[start_idx + 2]
         with_dialog = True if len(args) < start_idx + 4 else bool(args[start_idx + 3])
         scenario = None
+        user_scenario = None
 
         name = data.get(_GuiCoreContext.__PROP_ENTITY_NAME)
         if update:
@@ -468,7 +469,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         )
                         if isinstance(res, Scenario):
                             # everything's fine
-                            scenario_id = res.id
+                            user_scenario = res
+                            scenario_id = user_scenario.id
                             state.assign(error_var, "")
                             return
                         if res:
@@ -502,10 +504,10 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 state.assign(error_var, f"Error creating Scenario. {e}")
             finally:
                 self.scenario_refresh(scenario_id)
-                if scenario and (sel_scenario_var := args[1] if isinstance(args[1], str) else None):
+                if (scenario or user_scenario) and (sel_scenario_var := args[1] if isinstance(args[1], str) else None):
                     try:
                         var_name, _ = gui._get_real_var_name(sel_scenario_var)
-                        state.assign(var_name, scenario)
+                        state.assign(var_name, scenario or user_scenario)
                     except Exception as e:  # pragma: no cover
                         _warn("Can't find value variable name in context", e)
         if scenario:

+ 74 - 1
tests/core/_orchestrator/test_orchestrator__submit.py

@@ -10,6 +10,7 @@
 # specific language governing permissions and limitations under the License.
 
 from datetime import datetime, timedelta
+from time import sleep
 from unittest import mock
 
 import freezegun
@@ -17,7 +18,7 @@ import pytest
 
 from taipy import Scenario, Scope, Task
 from taipy.config import Config
-from taipy.core import taipy
+from taipy.core import Core, taipy
 from taipy.core._orchestrator._orchestrator import _Orchestrator
 from taipy.core._orchestrator._orchestrator_factory import _OrchestratorFactory
 from taipy.core.config import JobConfig
@@ -27,6 +28,7 @@ from taipy.core.scenario._scenario_manager import _ScenarioManager
 from taipy.core.submission._submission_manager_factory import _SubmissionManagerFactory
 from taipy.core.submission.submission_status import SubmissionStatus
 from taipy.core.task._task_manager import _TaskManager
+from tests.core.utils import assert_true_after_time
 
 
 def nothing(*args, **kwargs):
@@ -53,6 +55,7 @@ def test_submit_scenario_development_mode():
     scenario = create_scenario()
     scenario.dn_0.write(0)  # input data is made ready
     orchestrator = _OrchestratorFactory._build_orchestrator()
+    _OrchestratorFactory._build_dispatcher()
 
     submit_time = datetime.now() + timedelta(seconds=1)  # +1 to ensure the edit time of dn_0 is before the submit time
     with freezegun.freeze_time(submit_time):
@@ -505,3 +508,73 @@ def test_submit_submittable_generate_unique_submit_id():
     assert jobs_1[0].submit_id == jobs_1[1].submit_id
     assert jobs_2[0].submit_id == jobs_2[1].submit_id
     assert jobs_1[0].submit_id != jobs_2[0].submit_id
+
+
+def task_sleep_1():
+    sleep(1)
+
+
+def task_sleep_2():
+    sleep(2)
+    return
+
+
+def test_submit_duration_development_mode():
+    core = Core()
+    core.run()
+
+    task_1 = Task("task_config_id_1", {}, task_sleep_1, [], [])
+    task_2 = Task("task_config_id_2", {}, task_sleep_2, [], [])
+
+    _TaskManager._set(task_1)
+    _TaskManager._set(task_2)
+
+    scenario = Scenario("scenario", {task_1, task_2}, {})
+    _ScenarioManager._set(scenario)
+    submission = taipy.submit(scenario)
+    jobs = submission.jobs
+    core.stop()
+
+    assert all(isinstance(job.execution_started_at, datetime) for job in jobs)
+    assert all(isinstance(job.execution_ended_at, datetime) for job in jobs)
+    jobs_1s = jobs[0] if jobs[0].task.config_id == "task_config_id_1" else jobs[1]
+    jobs_2s = jobs[0] if jobs[0].task.config_id == "task_config_id_2" else jobs[1]
+    assert jobs_1s.execution_duration >= 1
+    assert jobs_2s.execution_duration >= 2
+
+    assert submission.execution_duration >= 3
+    assert submission.execution_started_at == min(jobs_1s.execution_started_at, jobs_2s.execution_started_at)
+    assert submission.execution_ended_at == max(jobs_1s.execution_ended_at, jobs_2s.execution_ended_at)
+
+
+@pytest.mark.standalone
+def test_submit_duration_standalone_mode():
+    Config.configure_job_executions(mode=JobConfig._STANDALONE_MODE)
+    core = Core()
+    core.run()
+
+    task_1 = Task("task_config_id_1", {}, task_sleep_1, [], [])
+    task_2 = Task("task_config_id_2", {}, task_sleep_2, [], [])
+
+    _TaskManager._set(task_1)
+    _TaskManager._set(task_2)
+
+    scenario = Scenario("scenario", {task_1, task_2}, {})
+    _ScenarioManager._set(scenario)
+    submission = taipy.submit(scenario)
+    jobs = submission.jobs
+
+    assert_true_after_time(jobs[1].is_completed)
+
+    core.stop()
+
+    assert all(isinstance(job.execution_started_at, datetime) for job in jobs)
+    assert all(isinstance(job.execution_ended_at, datetime) for job in jobs)
+    jobs_1s = jobs[0] if jobs[0].task.config_id == "task_config_id_1" else jobs[1]
+    jobs_2s = jobs[0] if jobs[0].task.config_id == "task_config_id_2" else jobs[1]
+    assert jobs_1s.execution_duration >= 1
+    assert jobs_2s.execution_duration >= 2
+
+    assert submission.execution_duration >= 2  # Both tasks are executed in parallel so the duration may smaller than 3
+    assert submission.execution_started_at == min(jobs_1s.execution_started_at, jobs_2s.execution_started_at)
+    assert submission.execution_ended_at == max(jobs_1s.execution_ended_at, jobs_2s.execution_ended_at)

+ 41 - 0
tests/core/config/checkers/test_scenario_config_checker.py

@@ -83,6 +83,47 @@ class TestScenarioConfigChecker:
         )
         assert expected_error_message in caplog.text
 
+    def test_check_if_children_id_is_used_in_properties(self, caplog):
+        config = Config._applied_config
+        Config._compile_configs()
+        input_dn_config = DataNodeConfig("input_dn")
+        output_dn_config = DataNodeConfig("output_dn")
+        test_dn_config = DataNodeConfig("test")
+        task_config = TaskConfig("bar", print, [input_dn_config], [output_dn_config])
+        test_task_config = TaskConfig("test", print, [test_dn_config], [output_dn_config])
+
+        config._sections[ScenarioConfig.name]["new"] = copy(config._sections[ScenarioConfig.name]["default"])
+        config._sections[ScenarioConfig.name]["new"]._properties["test"] = "test"
+        config._sections[ScenarioConfig.name]["new"]._tasks = [task_config]
+        Config._collector = IssueCollector()
+        Config.check()
+        assert len(Config._collector.errors) == 0
+
+        config._sections[ScenarioConfig.name]["new"]._tasks = [test_task_config]
+        with pytest.raises(SystemExit):
+            Config._collector = IssueCollector()
+            Config.check()
+        assert len(Config._collector.errors) == 2
+        assert (
+            "The id of the TaskConfig `test` is overlapping with the property `test` of ScenarioConfig `new`."
+            in caplog.text
+        )
+        assert (
+            "The id of the DataNodeConfig `test` is overlapping with the property `test` of ScenarioConfig `new`."
+            in caplog.text
+        )
+
+        config._sections[ScenarioConfig.name]["new"]._tasks = [task_config]
+        config._sections[ScenarioConfig.name]["new"]._additional_data_nodes = [test_dn_config]
+        with pytest.raises(SystemExit):
+            Config._collector = IssueCollector()
+            Config.check()
+        assert len(Config._collector.errors) == 1
+        assert (
+            "The id of the DataNodeConfig `test` is overlapping with the property `test` of ScenarioConfig `new`."
+            in caplog.text
+        )
+
     def test_check_task_configs(self, caplog):
         Config._collector = IssueCollector()
         config = Config._applied_config

+ 34 - 0
tests/core/config/checkers/test_task_config_checker.py

@@ -49,6 +49,40 @@ class TestTaskConfigChecker:
         assert len(Config._collector.errors) == 1
         assert len(Config._collector.warnings) == 2
 
+    def test_check_if_input_output_id_is_used_in_properties(self, caplog):
+        config = Config._applied_config
+        Config._compile_configs()
+        input_dn_config = DataNodeConfig("input_dn")
+        test_dn_config = DataNodeConfig("test")
+
+        config._sections[TaskConfig.name]["new"] = copy(config._sections[TaskConfig.name]["default"])
+        config._sections[TaskConfig.name]["new"].function = print
+        config._sections[TaskConfig.name]["new"]._properties["test"] = "test"
+        config._sections[TaskConfig.name]["new"]._inputs = [input_dn_config]
+        Config._collector = IssueCollector()
+        Config.check()
+        assert len(Config._collector.errors) == 0
+
+        config._sections[TaskConfig.name]["new"]._inputs = [test_dn_config]
+        with pytest.raises(SystemExit):
+            Config._collector = IssueCollector()
+            Config.check()
+        assert len(Config._collector.errors) == 1
+        assert (
+            "The id of the DataNodeConfig `test` is overlapping with the property `test` of TaskConfig `new`."
+            in caplog.text
+        )
+
+        config._sections[TaskConfig.name]["new"]._outputs = [test_dn_config]
+        with pytest.raises(SystemExit):
+            Config._collector = IssueCollector()
+            Config.check()
+        assert len(Config._collector.errors) == 2
+        assert (
+            "The id of the DataNodeConfig `test` is overlapping with the property `test` of TaskConfig `new`."
+            in caplog.text
+        )
+
     def test_check_config_id_is_different_from_all_task_properties(self, caplog):
         Config._collector = IssueCollector()
         config = Config._applied_config

+ 3 - 3
tests/core/notification/test_events_published.py

@@ -178,16 +178,16 @@ def test_events_published_for_scenario_submission():
     # 1 submission update event for is_completed
     scenario.submit()
     snapshot = all_evts.capture()
-    assert len(snapshot.collected_events) == 17
+    assert len(snapshot.collected_events) == 19
     assert snapshot.entity_type_collected.get(EventEntityType.CYCLE, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.DATA_NODE, 0) == 7
     assert snapshot.entity_type_collected.get(EventEntityType.TASK, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.SEQUENCE, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.SCENARIO, 0) == 1
-    assert snapshot.entity_type_collected.get(EventEntityType.JOB, 0) == 4
+    assert snapshot.entity_type_collected.get(EventEntityType.JOB, 0) == 6
     assert snapshot.entity_type_collected.get(EventEntityType.SUBMISSION, 0) == 5
     assert snapshot.operation_collected.get(EventOperation.CREATION, 0) == 2
-    assert snapshot.operation_collected.get(EventOperation.UPDATE, 0) == 14
+    assert snapshot.operation_collected.get(EventOperation.UPDATE, 0) == 16
     assert snapshot.operation_collected.get(EventOperation.SUBMISSION, 0) == 1
 
     assert snapshot.attr_name_collected["last_edit_date"] == 1

+ 28 - 0
tests/core/submission/test_submission.py

@@ -903,3 +903,31 @@ def test_is_finished():
     submission.submission_status = SubmissionStatus.COMPLETED
     assert submission.submission_status == SubmissionStatus.COMPLETED
     assert submission.is_finished()
+
+
+def test_execution_duration():
+    task = Task(config_id="task_1", properties={}, function=print, id=TaskId("task_1"))
+    submission = Submission(task.id, task._ID_PREFIX, task.config_id, properties={})
+    job_1 = Job("job_1", task, submission.id, submission.entity_id)
+    job_2 = Job("job_2", task, submission.id, submission.entity_id)
+
+    _TaskManagerFactory._build_manager()._set(task)
+    _SubmissionManagerFactory._build_manager()._set(submission)
+    _JobManagerFactory._build_manager()._set(job_1)
+    _JobManagerFactory._build_manager()._set(job_2)
+
+    submission.jobs = [job_1, job_2]
+    _SubmissionManagerFactory._build_manager()._set(submission)
+
+    job_1.execution_started_at = datetime(2024, 1, 1, 0, 0, 0)
+    job_1.execution_ended_at = datetime(2024, 1, 1, 0, 0, 10)
+    job_2.execution_started_at = datetime(2024, 1, 1, 0, 1, 0)
+    job_2.execution_ended_at = datetime(2024, 1, 1, 0, 2, 30)
+    assert submission.execution_started_at == job_1.execution_started_at
+    assert submission.execution_ended_at == job_2.execution_ended_at
+    assert submission.execution_duration == 150
+
+    job_2.execution_ended_at = None  # job_2 is still running
+    assert submission.execution_started_at == job_1.execution_started_at
+    assert submission.execution_ended_at is None
+    assert submission.execution_duration is None

+ 3 - 3
tests/gui/builder/control/test_chat.py

@@ -35,7 +35,7 @@ def test_chat_builder_1(gui: Gui, test_client, helpers):
         "<Chat",
         'defaultUsers="[[&quot;Fred&quot;, &#x7B;&quot;path&quot;: &quot;/images/favicon.png&quot;, &quot;text&quot;: &quot;Fred.png&quot;&#x7D;], [&quot;Fredi&quot;, &#x7B;&quot;path&quot;: &quot;/images/fred.png&quot;, &quot;text&quot;: &quot;Fred.png&quot;&#x7D;]]"',  # noqa: E501
         "messages={_TpD_tpec_TpExPr_messages_TPMDL_0}",
-        "updateVarName=\"_TpD_tpec_TpExPr_messages_TPMDL_0\""
+        'updateVarName="_TpD_tpec_TpExPr_messages_TPMDL_0"',
     ]
     helpers.test_control_builder(gui, page, expected_list)
 
@@ -61,8 +61,8 @@ def test_chat_builder_2(gui: Gui, test_client, helpers):
         "<Chat",
         'defaultUsers="[[&quot;Fred&quot;, &#x7B;&quot;path&quot;: &quot;/images/favicon.png&quot;, &quot;text&quot;: &quot;Fred.png&quot;&#x7D;], [&quot;Fredi&quot;, &#x7B;&quot;path&quot;: &quot;/images/fred.png&quot;, &quot;text&quot;: &quot;Fred.png&quot;&#x7D;]]"',  # noqa: E501
         "messages={_TpD_tpec_TpExPr_messages_TPMDL_0}",
-        "updateVarName=\"_TpD_tpec_TpExPr_messages_TPMDL_0\"",
+        'updateVarName="_TpD_tpec_TpExPr_messages_TPMDL_0"',
         "users={_TpL_tp_TpExPr_gui_get_adapted_lov_users_list_TPMDL_0_0}",
-        "updateVars=\"users=_TpL_tp_TpExPr_gui_get_adapted_lov_users_list_TPMDL_0_0\""
+        'updateVars="users=_TpL_tp_TpExPr_gui_get_adapted_lov_users_list_TPMDL_0_0"',
     ]
     helpers.test_control_builder(gui, page, expected_list)

+ 1 - 1
tests/gui/builder/control/test_progress.py

@@ -16,5 +16,5 @@ from taipy.gui import Gui
 def test_progress_builder(gui: Gui, helpers):
     with tgb.Page(frame=None) as page:
         tgb.progress(linear="true", show_value="true", value={50})  # type: ignore[attr-defined]
-    expected_list = ["<Progress", 'linear={true}', 'showValue={true}', 'value="{50}"']
+    expected_list = ["<Progress", "linear={true}", "showValue={true}", 'value="{50}"']
     helpers.test_control_builder(gui, page, expected_list)

+ 2 - 1
tests/gui/config/test_cli.py

@@ -82,11 +82,13 @@ def test_gui_service_arguments_hierarchy():
     assert service_config["margin"] is None
     assert service_config["ngrok_token"] == ""
     assert service_config["notification_duration"] == 3000
+    assert service_config["port"] == 5000
     assert service_config["propagate"]
     assert service_config["run_browser"]
     assert not service_config["run_in_thread"]
     assert not service_config["run_server"]
     assert not service_config["single_client"]
+    assert service_config["state_retention_period"] == 0
     assert not service_config["system_notification"]
     assert service_config["theme"] is None
     assert service_config["time_zone"] is None
@@ -96,7 +98,6 @@ def test_gui_service_arguments_hierarchy():
     assert not service_config["use_reloader"]
     assert service_config["watermark"] == "Taipy inside"
     assert service_config["webapp_path"] is None
-    assert service_config["port"] == 5000
     gui.stop()
 
     # Override default configuration by explicit defined arguments in Gui.run()

+ 0 - 8
tools/gui/builder/block.txt

@@ -1,8 +0,0 @@
-
-class {{name}}(_Block):
-    _ELEMENT_NAME: str
-    def __init__(self, {{properties}}) -> None:
-        """### Arguments:
-{{doc_arguments}}
-        """
-        ...

+ 0 - 8
tools/gui/builder/control.txt

@@ -1,8 +0,0 @@
-
-class {{name}}(_Control):
-    _ELEMENT_NAME: str
-    def __init__(self, {{properties}}) -> None:
-        """### Arguments:
-{{doc_arguments}}
-        """
-        ...

+ 136 - 96
tools/gui/generate_pyi.py

@@ -12,28 +12,32 @@
 import json
 import os
 import re
-import typing as t
+import sys
+from typing import Any, Dict, List
 
 from markdownify import markdownify
 
-# ############################################################
-# Generate Python interface definition files
-# ############################################################
-from taipy.gui.config import Config
+# Make sure we can import the mandatory packages
+script_dir = os.path.dirname(os.path.realpath(__file__))
+if not os.path.isdir(os.path.abspath(os.path.join(script_dir, "taipy"))):
+    sys.path.append(os.path.abspath(os.path.join(script_dir, os.pardir, os.pardir)))
 
-# ############################################################
+# ##################################################################################################
 # Generate gui pyi file (gui/gui.pyi)
-# ############################################################
+# ##################################################################################################
 gui_py_file = "./taipy/gui/gui.py"
-gui_pyi_file = gui_py_file + "i"
+gui_pyi_file = f"{gui_py_file}i"
+from taipy.config import Config  # noqa: E402
 
+# Generate Python interface definition files
 os.system(f"pipenv run stubgen {gui_py_file} --no-import --parse-only --export-less -o ./")
 
-
 gui_config = "".join(
-    f", {k}: {v.__name__} = ..."
-    if "<class" in str(v)
-    else f", {k}: {str(v).replace('typing', 't').replace('taipy.gui.config.', '')} = ..."
+    (
+        f", {k}: {v.__name__} = ..."
+        if "<class" in str(v)
+        else f", {k}: {str(v).replace('typing', 't').replace('taipy.gui.config.', '')} = ..."
+    )
     for k, v in Config.__annotations__.items()
 )
 
@@ -44,14 +48,15 @@ with open(gui_pyi_file, "r") as file:
             replace_str = line[line.index(", run_server") : (line.index("**kwargs") + len("**kwargs"))]
             # ", run_server: bool = ..., run_in_thread: bool = ..., async_mode: str = ..., **kwargs"
             line = line.replace(replace_str, gui_config)
-        replaced_content = replaced_content + line
+        replaced_content += line
 
 with open(gui_pyi_file, "w") as write_file:
     write_file.write(replaced_content)
 
-# ################
+# ##################################################################################################
+# Generate Page Builder pyi file (gui/builder/__init__.pyi)
+# ##################################################################################################
 # Read the version
-# ################
 current_version = "latest"
 with open("./taipy/gui/version.json", "r") as vfile:
     version = json.load(vfile)
@@ -59,108 +64,143 @@ with open("./taipy/gui/version.json", "r") as vfile:
         current_version = "develop"
     else:
         current_version = f'release-{version.get("major", 0)}.{version.get("minor", 0)}'
-taipy_doc_url = f"https://docs.taipy.io/en/{current_version}/manuals/userman/gui/viselements/generic/"
 
+taipy_doc_url = f"https://docs.taipy.io/en/{current_version}/manuals/userman/gui/viselements/generic/"
 
-# ############################################################
-# Generate Page Builder pyi file (gui/builder/__init__.pyi)
-# ############################################################
 builder_py_file = "./taipy/gui/builder/__init__.py"
-builder_pyi_file = builder_py_file + "i"
+builder_pyi_file = f"{builder_py_file}i"
 with open("./taipy/gui/viselements.json", "r") as file:
     viselements = json.load(file)
-with open("./tools/gui/builder/block.txt", "r") as file:
-    block_template = file.read()
-with open("./tools/gui/builder/control.txt", "r") as file:
-    control_template = file.read()
-
 os.system(f"pipenv run stubgen {builder_py_file} --no-import --parse-only --export-less -o ./")
 
 with open(builder_pyi_file, "a") as file:
-    file.write("from ._element import _Element, _Block, _Control\n")
+    file.write("from typing import Union\n")
+    file.write("\n")
+    file.write("from ._element import _Block, _Control, _Element\n")
 
 
-def get_properties(element, viselements) -> t.List[t.Dict[str, t.Any]]:
-    properties = element["properties"]
-    if "inherits" not in element:
+def resolve_inherit(name: str, properties, inherits, viselements) -> List[Dict[str, Any]]:
+    if not inherits:
         return properties
-    for inherit in element["inherits"]:
-        inherit_element = next((e for e in viselements["undocumented"] if e[0] == inherit), None)
-        if inherit_element is None:
-            inherit_element = next((e for e in viselements["blocks"] if e[0] == inherit), None)
-        if inherit_element is None:
-            inherit_element = next((e for e in viselements["controls"] if e[0] == inherit), None)
-        if inherit_element is None:
-            raise RuntimeError(f"Can't find element with name {inherit}")
-        properties += get_properties(inherit_element[1], viselements)
+    for inherit_name in inherits:
+        inherited_desc = next((e for e in viselements["undocumented"] if e[0] == inherit_name), None)
+        if inherited_desc is None:
+            inherited_desc = next((e for e in viselements["blocks"] if e[0] == inherit_name), None)
+        if inherited_desc is None:
+            inherited_desc = next((e for e in viselements["controls"] if e[0] == inherit_name), None)
+        if inherited_desc is None:
+            raise RuntimeError(f"Element type '{name}' inherits from unknown element type '{inherit_name}'")
+        inherited_desc = inherited_desc[1]
+        for inherit_prop in inherited_desc["properties"]:
+            prop_desc = next((p for p in properties if p["name"] == inherit_prop["name"]), None)
+            if prop_desc:  # Property exists
+
+                def override(current, inherits, p: str):
+                    if p not in current and (inherited := inherits.get(p, None)):
+                        current[p] = inherited
+
+                override(prop_desc, inherit_prop, "type")
+                override(prop_desc, inherit_prop, "default_value")
+                override(prop_desc, inherit_prop, "doc")
+                override(prop_desc, inherit_prop, "signature")
+            else:
+                properties.append(inherit_prop)
+            properties = resolve_inherit(inherit_name, properties, inherited_desc.get("inherits", None), viselements)
     return properties
 
 
-def build_doc(name: str, element: t.Dict[str, t.Any]):
-    if "doc" not in element:
+def format_as_parameter(property):
+    type = property["type"]
+    if m := re.match(r"indexed\((.*)\)", type):
+        type = m[1]
+        property["indexed"] = " (indexed)"
+    else:
+        property["indexed"] = ""
+    if m := re.match(r"dynamic\((.*)\)", type):
+        type = m[1]
+        property["dynamic"] = " (dynamic)"
+    else:
+        property["dynamic"] = ""
+    if type == "Callback" or type == "Function":
+        type = ""
+    else:
+        type = f": {type}"
+    default_value = property.get("default_value", None)
+    if default_value is not None:
+        try:
+            eval(default_value)
+            default_value = f" = {default_value}"
+        except Exception:
+            default_value = ""
+    else:
+        default_value = ""
+    return f"{property['name']}{type}{default_value}"
+
+
+def build_doc(name: str, desc: Dict[str, Any]):
+    if "doc" not in desc:
         return ""
-    doc = str(element["doc"]).replace("\n", f'\n{16*" "}')
-    doc = re.sub(
-        r"^(.*\..*\shref=\")([^h].*)(\".*\..*)$",
-        r"\1" + taipy_doc_url + name + r"/\2\3",
-        doc,
-    )
-    doc = re.sub(
-        r"^(.*\.)(<br/>|\s)(See below((?!href=).)*\.)(.*)$",
-        r"\1\3",
-        doc,
-    )
-    doc = markdownify(doc, strip=["br"])
-    return f"{element['name']} ({element['type']}): {doc} {'(default: '+markdownify(element['default_value']) + ')' if 'default_value' in element else ''}"  # noqa: E501
-
-
-for control_element in viselements["controls"]:
-    name = control_element[0]
-    property_list: t.List[t.Dict[str, t.Any]] = []
-    property_names: t.List[str] = []
-    hidden_properties: t.List[str] = []
-    for property in get_properties(control_element[1], viselements):
-        if "hide" in property and property["hide"] is True:
-            hidden_properties.append(property["name"])
-            continue
-        if (
-            property["name"] not in property_names
-            and "[" not in property["name"]
-            and property["name"] not in hidden_properties
-        ):
+    doc = str(desc["doc"])
+    if desc["name"] == "class_name":
+        doc = doc.replace("<element_type>", name)
+    # This won't work for Scenartio Management and Block elements
+    doc = re.sub(r"(href=\")\.\.((?:.*?)\")", r"\1" + taipy_doc_url + name + r"/../..\2", doc)
+    doc = "\n  ".join(markdownify(doc).split("\n"))
+    doc = doc.replace("  \n", "  \\n")
+    doc = re.sub(r"(?:\s+\\n)?\s+See below(?:[^\.]*)?\.", "", doc).replace("\n", "\\n")
+    return f"{desc['name']}{desc['dynamic']}{desc['indexed']}\\n  {doc}\\n\\n"
+
+
+element_template = """
+
+class {{name}}(_{{base_class}}):
+    _ELEMENT_NAME: str
+    def __init__(self, {{properties_decl}}) -> None:
+        \"\"\"Creates a{{n}} {{name}} element.\\n\\nParameters\\n----------\\n\\n{{properties_doc}}\"\"\"  # noqa: E501
+        ...
+"""
+
+
+def generate_elements(category: str, base_class: str):
+    for element in viselements[category]:
+        name = element[0]
+        desc = element[1]
+        properties_doc = ""
+        property_list: List[Dict[str, Any]] = []
+        property_names: List[str] = []
+        properties = resolve_inherit(name, desc["properties"], desc.get("inherits", None), viselements)
+        # Remove hidden properties and indexed properties (TODO?)
+        properties = [p for p in properties if not p.get("hide", False) and "[" not in p["name"]]
+        # Generate function parameters
+        properties_decl = [format_as_parameter(p) for p in properties]
+        # Generate properties doc
+        for property in properties:
             if "default_property" in property and property["default_property"] is True:
                 property_list.insert(0, property)
                 property_names.insert(0, property["name"])
                 continue
             property_list.append(property)
             property_names.append(property["name"])
-    properties = ", ".join([f"{p} = ..." for p in property_names if p not in hidden_properties])
-    doc_arguments = "\n".join([build_doc(name, p) for p in property_list if p["name"] not in hidden_properties])
-    # append properties to __init__.pyi
-    with open(builder_pyi_file, "a") as file:
-        file.write(
-            control_template.replace("{{name}}", name)
-            .replace("{{properties}}", properties)
-            .replace("{{doc_arguments}}", doc_arguments)
-        )
-
-for block_element in viselements["blocks"]:
-    name = block_element[0]
-    property_list = []
-    property_names = []
-    for property in get_properties(block_element[1], viselements):
-        if property["name"] not in property_names and "[" not in property["name"]:
-            property_list.append(property)
-            property_names.append(property["name"])
-    properties = ", ".join([f"{p} = ..." for p in property_names])
-    doc_arguments = "\n".join([build_doc(name, p) for p in property_list])
-    # append properties to __init__.pyi
-    with open(builder_pyi_file, "a") as file:
-        file.write(
-            block_template.replace("{{name}}", name)
-            .replace("{{properties}}", properties)
-            .replace("{{doc_arguments}}", doc_arguments)
-        )
+        # Append properties doc to element doc (once ordered)
+        for property in property_list:
+            property_doc = build_doc(name, property)
+            properties_doc += property_doc
+        if len(properties_decl) > 1:
+            properties_decl.insert(1, "*")
+        # Append element to __init__.pyi
+        with open(builder_pyi_file, "a") as file:
+            n = "n" if name[0] in ["a", "e", "i", "o"] else ""
+            file.write(
+                element_template.replace("{{name}}", name)
+                .replace("{{n}}", n)
+                .replace("{{base_class}}", base_class)
+                .replace("{{properties_decl}}", ", ".join(properties_decl))
+                .replace("{{properties_doc}}", properties_doc)
+            )
+
+
+generate_elements("controls", "Control")
+generate_elements("blocks", "Block")
 
 os.system(f"pipenv run isort {gui_pyi_file}")
 os.system(f"pipenv run black {gui_pyi_file}")