Browse Source

Merge branch 'develop' into feature/#1196-datanode-download-upload-api

trgiangdo 10 tháng trước cách đây
mục cha
commit
fcf632fbd1
84 tập tin đã thay đổi với 3042 bổ sung942 xóa
  1. 11 2
      .github/workflows/build-and-release-single-package.yml
  2. 3 0
      .github/workflows/build-and-release.yml
  3. 1 1
      .github/workflows/codeql-analysis.yml
  4. 1 1
      .github/workflows/codespell.yml
  5. 6 0
      .github/workflows/frontend.yml
  6. 6 0
      .github/workflows/manage-stale-issue-pr.yml
  7. 6 0
      .github/workflows/overall-tests.yml
  8. 9 3
      .github/workflows/packaging.yml
  9. 1 1
      .github/workflows/publish-single-package.yml
  10. 4 4
      .github/workflows/publish.yml
  11. 2 2
      README.md
  12. 9 15
      doc/gui/examples/broadcast.py
  13. 57 0
      doc/gui/examples/broadcast_callback.py
  14. 48 0
      doc/gui/examples/broadcast_change.py
  15. 9 9
      doc/gui/examples/charts/advanced-python-lib.py
  16. 16 3
      doc/gui/examples/charts/heatmap-drawing-on-top.py
  17. 28 0
      doc/gui/examples/controls/date-min-max.py
  18. 1 7
      doc/gui/examples/controls/metric-color-map.py
  19. 0 1
      doc/gui/examples/controls/metric-hide-value.py
  20. 0 1
      doc/gui/examples/controls/metric-layout.py
  21. 0 1
      doc/gui/examples/controls/metric-range.py
  22. 0 1
      doc/gui/examples/controls/metric-type.py
  23. 0 1
      doc/gui/examples/controls/metric-value-format.py
  24. 25 0
      doc/gui/examples/controls/number-min-max.py
  25. 25 0
      doc/gui/examples/controls/number-step.py
  26. 38 8
      frontend/taipy-gui/base/src/app.ts
  27. 0 2
      frontend/taipy-gui/base/src/exports.ts
  28. 28 30
      frontend/taipy-gui/base/src/packaging/taipy-gui-base.d.ts
  29. 2 2
      frontend/taipy-gui/base/src/socket.ts
  30. 7 3
      frontend/taipy-gui/base/src/wsAdapter.ts
  31. 254 233
      frontend/taipy-gui/package-lock.json
  32. 7 7
      frontend/taipy-gui/package.json
  33. 1 27
      frontend/taipy-gui/src/components/Taipy/DateRange.tsx
  34. 33 9
      frontend/taipy-gui/src/components/Taipy/DateSelector.tsx
  35. 74 11
      frontend/taipy-gui/src/components/Taipy/Input.spec.tsx
  36. 186 5
      frontend/taipy-gui/src/components/Taipy/Input.tsx
  37. 43 34
      frontend/taipy-gui/src/components/Taipy/Login.tsx
  38. 1 1
      frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
  39. 34 0
      frontend/taipy-gui/src/components/Taipy/utils.ts
  40. 2 12
      frontend/taipy-gui/src/utils/index.ts
  41. 150 134
      frontend/taipy/package-lock.json
  42. 1 1
      frontend/taipy/package.json
  43. 26 11
      frontend/taipy/src/ScenarioDag.tsx
  44. 6 6
      taipy/core/_entity/_ready_to_run_property.py
  45. 6 7
      taipy/core/_entity/submittable.py
  46. 6 7
      taipy/core/data/_data_manager.py
  47. 6 5
      taipy/core/data/data_node.py
  48. 9 1
      taipy/core/reason/__init__.py
  49. 0 37
      taipy/core/reason/_reason_factory.py
  50. 106 22
      taipy/core/reason/reason.py
  51. 60 0
      taipy/core/reason/reason_collection.py
  52. 40 14
      taipy/core/scenario/_scenario_manager.py
  53. 6 3
      taipy/core/scenario/scenario.py
  54. 5 6
      taipy/core/sequence/_sequence_manager.py
  55. 17 5
      taipy/core/taipy.py
  56. 8 13
      taipy/core/task/_task_manager.py
  57. 14 3
      taipy/gui/_renderers/builder.py
  58. 6 0
      taipy/gui/_renderers/factory.py
  59. 0 1
      taipy/gui/builder/_api_generator.py
  60. 69 6
      taipy/gui/builder/_element.py
  61. 65 0
      taipy/gui/builder/_utils.py
  62. 129 45
      taipy/gui/gui.py
  63. 14 18
      taipy/gui/gui_actions.py
  64. 1 1
      taipy/gui/server.py
  65. 12 5
      taipy/gui/types.py
  66. 731 0
      taipy/gui/utils/unparse.py
  67. 97 59
      taipy/gui/viselements.json
  68. 2 1
      taipy/gui_core/_context.py
  69. 18 18
      tests/core/_entity/test_ready_to_run_property.py
  70. 45 43
      tests/core/common/test_reason.py
  71. 8 2
      tests/core/data/test_data_manager.py
  72. 59 2
      tests/core/scenario/test_scenario_manager.py
  73. 53 0
      tests/core/scenario/test_scenario_manager_with_sql_repo.py
  74. 6 0
      tests/core/test_taipy.py
  75. 46 5
      tests/gui/actions/test_invoke_callback.py
  76. 10 0
      tests/gui/builder/control/test_dialog.py
  77. 33 0
      tests/gui/builder/test_lambda.py
  78. 30 0
      tests/gui/builder/test_on_action.py
  79. 138 0
      tests/gui/gui_specific/test_broadcast.py
  80. 0 12
      tests/gui/gui_specific/test_gui.py
  81. 6 2
      tests/gui/gui_specific/test_shared.py
  82. 6 6
      tests/gui_core/test_context_is_submitable.py
  83. 12 4
      tools/gui/generate_pyi.py
  84. 2 0
      tools/release/fetch_latest_versions.py

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

@@ -18,6 +18,9 @@ on:
         description: "The package to be released (gui, config, core, rest, templates, taipy)"
         required: true
 
+env:
+  NODE_OPTIONS: --max-old-space-size=4096
+
 jobs:
   fetch-versions:
     runs-on: ubuntu-latest
@@ -27,8 +30,7 @@ jobs:
         gui_VERSION: ${{ steps.version-setup.outputs.gui_VERSION }}
         rest_VERSION: ${{ steps.version-setup.outputs.rest_VERSION }}
         templates_VERSION: ${{ steps.version-setup.outputs.templates_VERSION }}
-        VERSION: ${{ steps.version-setup.outputs.VERSION }}
-        NEW_VERSION: ${{ steps.version-setup.outputs.NEW_VERSION }}
+        taipy_VERSION: ${{ steps.version-setup.outputs.taipy_VERSION }}
     steps:
       - uses: actions/checkout@v4
       - name: Extract branch name
@@ -165,3 +167,10 @@ jobs:
         shell: bash
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Ensure Taipy release is marked as latest
+        run: |
+           gh release edit ${{needs.fetch-versions.outputs.taipy_VERSION}} --latest
+        shell: bash
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 3 - 0
.github/workflows/build-and-release.yml

@@ -15,6 +15,9 @@ on:
         description: "The version of the package to be released"
         required: true
 
+env:
+  NODE_OPTIONS: --max-old-space-size=4096
+
 jobs:
   fetch-versions:
     runs-on: ubuntu-latest

+ 1 - 1
.github/workflows/codeql-analysis.yml

@@ -24,7 +24,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Initialize CodeQL
       uses: github/codeql-action/init@v2

+ 1 - 1
.github/workflows/codespell.yml

@@ -17,6 +17,6 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
       - name: Codespell
         uses: codespell-project/actions-codespell@v2

+ 6 - 0
.github/workflows/frontend.yml

@@ -9,6 +9,12 @@ on:
         - 'frontend/taipy-gui/**'
   workflow_dispatch:
 
+permissions:
+  pull-requests: write
+
+env:
+  NODE_OPTIONS: --max-old-space-size=4096
+
 jobs:
   frontend-jest:
     timeout-minutes: 20

+ 6 - 0
.github/workflows/manage-stale-issue-pr.yml

@@ -4,11 +4,15 @@ on:
   schedule:
     # Run once every day at 9 AM UTC
     - cron: 00 9 * * *
+  workflow_dispatch:
 
 jobs:
   stale-issues-and-prs:
     name: Comment on possible stable issues and PRs, and close stale PRs
     runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
     steps:
       - uses: actions/stale@v9
         with:
@@ -30,6 +34,8 @@ jobs:
   unassign-issues-labeled-waiting-for-contributor-after-14-days-of-inactivity:
     name: Unassign issues labeled \"🥶Waiting for contributor\" after 14 days of inactivity.
     runs-on: ubuntu-latest
+    permissions:
+      issues: write
     steps:
       - uses: boundfoxstudios/action-unassign-contributor-after-days-of-inactivity@v1
         with:

+ 6 - 0
.github/workflows/overall-tests.yml

@@ -7,6 +7,12 @@ on:
     branches: [ develop, dev/*, release/* ]
   workflow_dispatch:
 
+permissions:
+  pull-requests: write
+
+env:
+  NODE_OPTIONS: --max-old-space-size=4096
+
 jobs:
   partial-tests:
     uses: ./.github/workflows/partial-tests.yml

+ 9 - 3
.github/workflows/packaging.yml

@@ -12,13 +12,16 @@ on:
         required: false
         default: ""
 
+env:
+  NODE_OPTIONS: --max-old-space-size=4096
+
 jobs:
   standard-packages:
     timeout-minutes: 30
     strategy:
       matrix:
-        python-versions: [ '3.8', '3.9', '3.10', '3.11', '3.12']
-        os: [ubuntu-latest, macos-13] #, windows-latest]
+        python-versions: [ '3.8', '3.9', '3.10', '3.11', '3.12' ]
+        os: [ubuntu-latest, macos-13, windows-latest]
 
     runs-on: ${{ matrix.os }}
 
@@ -35,7 +38,10 @@ jobs:
       - name: Install Taipy without dependencies
         run: |
           pip install .
-          rm -rf taipy
+
+      - name: Remove local folder
+        run: rm -r taipy
+
       - name: Check Taipy Installation
         run: |
           python tools/validate_taipy_install.py

+ 1 - 1
.github/workflows/publish-single-package.yml

@@ -19,7 +19,7 @@ jobs:
     environment: publish
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
         with:
           sparse-checkout: taipy/${{ matrix.package }}
           sparse-checkout-cone-mode: false

+ 4 - 4
.github/workflows/publish.yml

@@ -12,7 +12,7 @@ jobs:
     timeout-minutes: 20
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: actions/setup-python@v5
         with:
           python-version: 3.8
@@ -43,7 +43,7 @@ jobs:
     environment: publish
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
         with:
           sparse-checkout: taipy/${{ matrix.package }}
           sparse-checkout-cone-mode: false
@@ -76,7 +76,7 @@ jobs:
     environment: publish
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Checks if package is already on on Pypi
         id: check-version
@@ -108,7 +108,7 @@ jobs:
         os: [ubuntu-latest,windows-latest,macos-13]
     runs-on: ${{ matrix.os }}
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-versions }}

+ 2 - 2
README.md

@@ -1,5 +1,5 @@
-[![Taipy Designer](https://github.com/nevo-david/taipy/assets/100117126/e787ba7b-ec7a-4d3f-a7e4-0f195daadce7)
-](https://taipy.io/enterprise)
+[![Taipy Designer banner](https://github.com/Avaiga/taipy/assets/31435778/6378ffd4-438a-498f-9385-10394f7d53fb)](https://links.taipy.io/306TwUH)
+
 
 <div align="center">
   <a href="https://taipy.io?utm_source=github" target="_blank">

+ 9 - 15
doc/gui/examples/broadcast.py

@@ -15,14 +15,15 @@
 # -----------------------------------------------------------------------------------------
 # Demonstrate how to share variable values across multiple clients.
 # This application creates a thread that increments a value every few seconds.
-# The value is updated for every client using the state.broadcast() method.
-# The text of the button that starts or stops the thread is updated on every client's browser
-# using a direct assignment of the state property because the variable is declared 'shared'.
+# The value is updated for every client in a function invoked by Gui.broadcast_change().
+# The text of the button that starts or stops the thread is updated using the
+# State.assign() method, and udateds on every client's browser because the variable was
+# declared 'shared' by calling Gui.add_shared_variable().
 # -----------------------------------------------------------------------------------------
 from threading import Event, Thread
 from time import sleep
 
-from taipy.gui import Gui, State, broadcast_callback
+from taipy.gui import Gui, State
 
 counter = 0
 
@@ -35,22 +36,16 @@ button_texts = ["Start", "Stop"]
 button_text = button_texts[0]
 
 
-# Invoked by the timer
-def update_counter(state: State, c):
-    # Update all clients
-    state.broadcast("counter", c)
-
-
 def count(event, gui):
     while not event.is_set():
         global counter
         counter = counter + 1
-        broadcast_callback(gui, update_counter, [counter])
+        gui.broadcast_change("counter", counter)
         sleep(2)
 
 
 # Start or stop the timer when the button is pressed
-def start_or_stop(state):
+def start_or_stop(state: State):
     global thread
     if thread:  # Timer is running
         thread_event.set()
@@ -59,9 +54,8 @@ def start_or_stop(state):
         thread_event.clear()
         thread = Thread(target=count, args=[thread_event, state.get_gui()])
         thread.start()
-    # Update button status.
-    # Because "button_text" is shared, all clients are updated
-    state.button_text = button_texts[1 if thread else 0]
+    # Update button status for all states.
+    state.assign("button_text", button_texts[1 if thread else 0])
 
 
 page = """# Broadcasting values

+ 57 - 0
doc/gui/examples/broadcast_callback.py

@@ -0,0 +1,57 @@
+# 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>
+# -----------------------------------------------------------------------------------------
+# Demonstrate how to update the value of a variable across multiple clients.
+# This application creates a thread that sets a variable to the current time.
+# The value is updated for every client when Gui.broadcast_change() is invoked.
+# -----------------------------------------------------------------------------------------
+from datetime import datetime
+from threading import Thread
+from time import sleep
+
+from taipy.gui import Gui
+
+current_time = datetime.now()
+update = False
+
+
+# Update the 'current_time' state variable if 'update' is True
+def update_state(state, updated_time):
+    if state.update:
+        state.current_time = updated_time
+
+
+# The function that executes in its own thread.
+# Call 'update_state()` every second.
+def update_time(gui):
+    while True:
+        gui.broadcast_callback(update_state, [datetime.now()])
+        sleep(1)
+
+
+page = """
+Current time is: <|{current_time}|format=HH:mm:ss|>
+
+Update: <|{update}|toggle|>
+"""
+
+gui = Gui(page)
+
+# Run thread that regularly updates the current time
+thread = Thread(target=update_time, args=[gui], name="clock")
+thread.daemon = True
+thread.start()
+
+gui.run()

+ 48 - 0
doc/gui/examples/broadcast_change.py

@@ -0,0 +1,48 @@
+# 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>
+# -----------------------------------------------------------------------------------------
+# Demonstrate how to invoke a callback for different clients.
+# This application creates a thread that, every second, invokes a callback for every client
+# so the current time may be updated, under a state-dependant condition.
+# -----------------------------------------------------------------------------------------
+from datetime import datetime
+from threading import Thread
+from time import sleep
+
+from taipy.gui import Gui
+
+current_time = datetime.now()
+
+
+# The function that executes in its own thread.
+# Update the current time every second.
+def update_time(gui):
+    while True:
+        gui.broadcast_change("current_time", datetime.now())
+        sleep(1)
+
+
+page = """
+Current time is: <|{current_time}|format=HH:mm:ss|>
+"""
+
+gui = Gui(page)
+
+# Run thread that regularly updates the current time
+thread = Thread(target=update_time, args=[gui], name="clock")
+thread.daemon = True
+thread.start()
+
+gui.run()

+ 9 - 9
doc/gui/examples/charts/advanced-python-lib.py

@@ -25,19 +25,19 @@ from taipy.gui import Gui
 figure = go.Figure()
 
 # Add trace for Normal Distribution
-figure.add_trace(go.Violin(name="Normal",
-                           y=np.random.normal(loc=0, scale=1, size=1000),
-                           box_visible=True, meanline_visible=True))
+figure.add_trace(
+    go.Violin(name="Normal", y=np.random.normal(loc=0, scale=1, size=1000), box_visible=True, meanline_visible=True)
+)
 
 # Add trace for Exponential Distribution
-figure.add_trace(go.Violin(name="Exponential",
-                           y=np.random.exponential(scale=1, size=1000),
-                           box_visible=True, meanline_visible=True))
+figure.add_trace(
+    go.Violin(name="Exponential", y=np.random.exponential(scale=1, size=1000), box_visible=True, meanline_visible=True)
+)
 
 # Add trace for Uniform Distribution
-figure.add_trace(go.Violin(name="Uniform",
-                           y=np.random.uniform(low=0, high=1, size=1000),
-                           box_visible=True, meanline_visible=True))
+figure.add_trace(
+    go.Violin(name="Uniform", y=np.random.uniform(low=0, high=1, size=1000), box_visible=True, meanline_visible=True)
+)
 
 # Updating layout for better visualization
 figure.update_layout(title="Different Probability Distributions")

+ 16 - 3
doc/gui/examples/charts/heatmap-drawing-on-top.py

@@ -31,8 +31,14 @@ def spiral(th):
 
 # Prepare the heatmap x and y cell sizes along the axes
 golden_ratio = (1 + numpy.sqrt(5)) / 2.0  # Golden ratio
-grid_x = [0, 1, 1 + (1 / (golden_ratio ** 4)), 1 + (1 / (golden_ratio ** 3)), golden_ratio]
-grid_y = [0, 1 / (golden_ratio ** 3), 1 / golden_ratio ** 3 + 1 / golden_ratio ** 4, 1 / (golden_ratio ** 2), 1]
+grid_x = [0, 1, 1 + (1 / (golden_ratio**4)), 1 + (1 / (golden_ratio**3)), golden_ratio]
+grid_y = [
+    0,
+    1 / (golden_ratio**3),
+    1 / golden_ratio**3 + 1 / golden_ratio**4,
+    1 / (golden_ratio**2),
+    1,
+]
 
 # Main value is based on the Fibonacci sequence
 z = [[13, 3, 3, 5], [13, 2, 1, 5], [13, 10, 11, 12], [13, 8, 8, 8]]
@@ -50,7 +56,14 @@ data = [
 ]
 
 # Axis template: hide all ticks, lines and labels
-axis = {"range": [0, 2.0], "showgrid": False, "zeroline": False, "showticklabels": False, "ticks": "", "title": ""}
+axis = {
+    "range": [0, 2.0],
+    "showgrid": False,
+    "zeroline": False,
+    "showticklabels": False,
+    "ticks": "",
+    "title": "",
+}
 
 layout = {
     # Use the axis template for both x and y axes

+ 28 - 0
doc/gui/examples/controls/date-min-max.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>
+# -----------------------------------------------------------------------------------------
+import datetime
+
+from taipy.gui import Gui
+
+date = datetime.date(2024, 6, 15)
+start = datetime.date(2024, 5, 15)
+end = datetime.date(2024, 7, 15)
+
+page = """
+<|{date}|date|min={start}|max={end}|>
+"""
+
+Gui(page).run()

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

@@ -28,16 +28,10 @@ from taipy.gui import Gui
 # }
 
 value = 50
-color_map = {
-    20: "red",
-    40: None,
-    60: "blue",
-    80: None
-}
+color_map = {20: "red", 40: None, 60: "blue", 80: None}
 
 page = """
 <|{value}|metric|color_map={color_map}|>
 """
 
 Gui(page).run()
-

+ 0 - 1
doc/gui/examples/controls/metric-hide-value.py

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

+ 0 - 1
doc/gui/examples/controls/metric-layout.py

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

+ 0 - 1
doc/gui/examples/controls/metric-range.py

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

+ 0 - 1
doc/gui/examples/controls/metric-type.py

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

+ 0 - 1
doc/gui/examples/controls/metric-value-format.py

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

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

@@ -0,0 +1,25 @@
+# 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
+
+value = 50
+
+page = """
+<|{value}|number|min=10|max=60|>
+"""
+
+Gui(page).run()
+

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

@@ -0,0 +1,25 @@
+# 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
+
+value = 50
+
+page = """
+<|{value}|number|step=2|>
+"""
+
+Gui(page).run()
+

+ 38 - 8
frontend/taipy-gui/base/src/app.ts

@@ -6,11 +6,13 @@ import { Socket, io } from "socket.io-client";
 import { DataManager, ModuleData } from "./dataManager";
 import { initSocket } from "./socket";
 import { TaipyWsAdapter, WsAdapter } from "./wsAdapter";
+import { WsMessageType } from "../../src/context/wsUtils";
 
 export type OnInitHandler = (taipyApp: TaipyApp) => void;
 export type OnChangeHandler = (taipyApp: TaipyApp, encodedName: string, value: unknown) => void;
 export type OnNotifyHandler = (taipyApp: TaipyApp, type: string, message: string) => void;
 export type OnReloadHandler = (taipyApp: TaipyApp, removedChanges: ModuleData) => void;
+export type OnWsStatusUpdate = (taipyApp: TaipyApp, messageQueue: string[]) => void;
 type Route = [string, string];
 
 export class TaipyApp {
@@ -19,6 +21,8 @@ export class TaipyApp {
     _onChange: OnChangeHandler | undefined;
     _onNotify: OnNotifyHandler | undefined;
     _onReload: OnReloadHandler | undefined;
+    _onWsStatusUpdate: OnWsStatusUpdate | undefined;
+    _ackList: string[];
     variableData: DataManager | undefined;
     functionData: DataManager | undefined;
     appId: string;
@@ -33,7 +37,7 @@ export class TaipyApp {
         onInit: OnInitHandler | undefined = undefined,
         onChange: OnChangeHandler | undefined = undefined,
         path: string | undefined = undefined,
-        socket: Socket | undefined = undefined,
+        socket: Socket | undefined = undefined
     ) {
         socket = socket || io("/", { autoConnect: false });
         this.onInit = onInit;
@@ -48,6 +52,7 @@ export class TaipyApp {
         this.path = path;
         this.socket = socket;
         this.wsAdapters = [new TaipyWsAdapter()];
+        this._ackList = [];
         // Init socket io connection
         initSocket(socket, this);
     }
@@ -91,11 +96,21 @@ export class TaipyApp {
     }
     set onReload(handler: OnReloadHandler | undefined) {
         if (handler !== undefined && handler?.length !== 2) {
-            throw new Error("_onReload() requires two parameters");
+            throw new Error("onReload() requires two parameters");
         }
         this._onReload = handler;
     }
 
+    get onWsStatusUpdate() {
+        return this._onWsStatusUpdate;
+    }
+    set onWsStatusUpdate(handler: OnWsStatusUpdate | undefined) {
+        if (handler !== undefined && handler?.length !== 2) {
+            throw new Error("onWsStatusUpdate() requires two parameters");
+        }
+        this._onWsStatusUpdate = handler;
+    }
+
     // Utility methods
     init() {
         this.clientId = "";
@@ -103,15 +118,26 @@ export class TaipyApp {
         this.appId = "";
         this.routes = undefined;
         const id = getLocalStorageValue(TAIPY_CLIENT_ID, "");
-        sendWsMessage(this.socket, "ID", TAIPY_CLIENT_ID, id, id, undefined, false);
-        sendWsMessage(this.socket, "AID", "connect", "", id, undefined, false);
-        sendWsMessage(this.socket, "GR", "", "", id, undefined, false);
+        this.sendWsMessage("ID", TAIPY_CLIENT_ID, id);
+        this.sendWsMessage("AID", "connect", "");
+        this.sendWsMessage("GR", "", "");
         if (id !== "") {
             this.clientId = id;
             this.updateContext(this.path);
         }
     }
 
+    sendWsMessage(type: WsMessageType, id: string, payload: unknown, context: string | undefined = undefined) {
+        if (context === undefined) {
+            context = this.context;
+        }
+        const ackId = sendWsMessage(this.socket, type, id, payload, this.clientId, context);
+        if (ackId) {
+            this._ackList.push(ackId);
+            this.onWsStatusUpdate && this.onWsStatusUpdate(this, this._ackList);
+        }
+    }
+
     // Public methods
     registerWsAdapter(wsAdapter: WsAdapter) {
         this.wsAdapters.unshift(wsAdapter);
@@ -153,7 +179,7 @@ export class TaipyApp {
     // This update will only send the request to Taipy Gui backend
     // the actual update will be handled when the backend responds
     update(encodedName: string, value: unknown) {
-        sendWsMessage(this.socket, "U", encodedName, { value: value }, this.clientId, this.context);
+        this.sendWsMessage("U", encodedName, { value: value });
     }
 
     getContext() {
@@ -164,12 +190,12 @@ export class TaipyApp {
         if (!path || path === "") {
             path = window.location.pathname.slice(1);
         }
-        sendWsMessage(this.socket, "GMC", "get_module_context", { path: path || "/" }, this.clientId);
+        this.sendWsMessage("GMC", "get_module_context", { path: path || "/" });
     }
 
     trigger(actionName: string, triggerId: string, payload: Record<string, unknown> = {}) {
         payload["action"] = actionName;
-        sendWsMessage(this.socket, "A", triggerId, payload, this.clientId, this.context);
+        this.sendWsMessage("A", triggerId, payload);
     }
 
     upload(encodedName: string, files: FileList, progressCallback: (val: number) => void) {
@@ -179,6 +205,10 @@ export class TaipyApp {
     getPageMetadata() {
         return this.metadata;
     }
+
+    getWsStatus() {
+        return this._ackList;
+    }
 }
 
 export const createApp = (onInit?: OnInitHandler, onChange?: OnChangeHandler, path?: string, socket?: Socket) => {

+ 0 - 2
frontend/taipy-gui/base/src/exports.ts

@@ -1,9 +1,7 @@
 import { WsAdapter } from "./wsAdapter";
-import { sendWsMessage } from "../../src/context/wsUtils";
 // import { TaipyApp } from "./app";
 
 export {
     WsAdapter,
-    sendWsMessage,
     // TaipyApp,
 };

+ 28 - 30
frontend/taipy-gui/base/src/packaging/taipy-gui-base.d.ts

@@ -20,10 +20,29 @@ declare class DataManager {
     getAllData(): Record<string, unknown>;
     update(encodedName: string, value: unknown): void;
 }
+export type WsMessageType =
+    | "A"
+    | "U"
+    | "DU"
+    | "MU"
+    | "RU"
+    | "AL"
+    | "BL"
+    | "NA"
+    | "ID"
+    | "MS"
+    | "DF"
+    | "PR"
+    | "ACK"
+    | "GMC"
+    | "GDT"
+    | "AID"
+    | "GR";
 export type OnInitHandler = (taipyApp: TaipyApp) => void;
 export type OnChangeHandler = (taipyApp: TaipyApp, encodedName: string, value: unknown) => void;
 export type OnNotifyHandler = (taipyApp: TaipyApp, type: string, message: string) => void;
 export type OnReloadHandler = (taipyApp: TaipyApp, removedChanges: ModuleData) => void;
+export type OnWsStatusUpdate = (taipyApp: TaipyApp, messageQueue: string[]) => void;
 export type Route = [string, string];
 export declare class TaipyApp {
     socket: Socket;
@@ -31,11 +50,14 @@ export declare class TaipyApp {
     _onChange: OnChangeHandler | undefined;
     _onNotify: OnNotifyHandler | undefined;
     _onReload: OnReloadHandler | undefined;
+    _onWsStatusUpdate: OnWsStatusUpdate | undefined;
+    _ackList: string[];
     variableData: DataManager | undefined;
     functionData: DataManager | undefined;
     appId: string;
     clientId: string;
     context: string;
+    metadata: Record<string, unknown>;
     path: string | undefined;
     routes: Route[] | undefined;
     wsAdapters: WsAdapter[];
@@ -53,7 +75,10 @@ export declare class TaipyApp {
     set onNotify(handler: OnNotifyHandler | undefined);
     get onReload(): OnReloadHandler | undefined;
     set onReload(handler: OnReloadHandler | undefined);
+    get onWsStatusUpdate(): OnWsStatusUpdate | undefined;
+    set onWsStatusUpdate(handler: OnWsStatusUpdate | undefined);
     init(): void;
+    sendWsMessage(type: WsMessageType | string, id: string, payload: unknown, context?: string | undefined): void;
     registerWsAdapter(wsAdapter: WsAdapter): void;
     getEncodedName(varName: string, module: string): string | undefined;
     getName(encodedName: string): [string, string] | undefined;
@@ -68,28 +93,11 @@ export declare class TaipyApp {
     updateContext(path?: string | undefined): void;
     trigger(actionName: string, triggerId: string, payload?: Record<string, unknown>): void;
     upload(encodedName: string, files: FileList, progressCallback: (val: number) => void): Promise<string>;
-    getPageMetadata(): any;
+    getPageMetadata(): Record<string, unknown>;
+    getWsStatus(): string[];
 }
-export type WsMessageType =
-    | "A"
-    | "U"
-    | "DU"
-    | "MU"
-    | "RU"
-    | "AL"
-    | "BL"
-    | "NA"
-    | "ID"
-    | "MS"
-    | "DF"
-    | "PR"
-    | "ACK"
-    | "GMC"
-    | "GDT"
-    | "AID"
-    | "GR";
 export interface WsMessage {
-    type: WsMessageType | str;
+    type: WsMessageType | string;
     name: string;
     payload: Record<string, unknown> | unknown;
     propagate: boolean;
@@ -97,16 +105,6 @@ export interface WsMessage {
     module_context: string;
     ack_id?: string;
 }
-export declare const sendWsMessage: (
-    socket: Socket | undefined,
-    type: WsMessageType | str,
-    name: string,
-    payload: Record<string, unknown> | unknown,
-    id: string,
-    moduleContext?: string,
-    propagate?: boolean,
-    serverAck?: (val: unknown) => void
-) => string;
 export declare abstract class WsAdapter {
     abstract supportedMessageTypes: string[];
     abstract handleWsMessage(message: WsMessage, app: TaipyApp): boolean;

+ 2 - 2
frontend/taipy-gui/base/src/socket.ts

@@ -1,5 +1,5 @@
 import { Socket } from "socket.io-client";
-import { WsMessage, sendWsMessage } from "../../src/context/wsUtils";
+import { WsMessage } from "../../src/context/wsUtils";
 import { TaipyApp } from "./app";
 
 export const initSocket = (socket: Socket, taipyApp: TaipyApp) => {
@@ -11,7 +11,7 @@ export const initSocket = (socket: Socket, taipyApp: TaipyApp) => {
     // Send a request to get App ID to verify that the app has not been reloaded
     socket.io.on("reconnect", () => {
         console.log("WebSocket reconnected");
-        sendWsMessage(socket, "AID", "reconnect", taipyApp.appId, taipyApp.clientId, taipyApp.context);
+        taipyApp.sendWsMessage("AID", "reconnect", taipyApp.appId);
     });
     // try to reconnect on connect_error
     socket.on("connect_error", (err) => {

+ 7 - 3
frontend/taipy-gui/base/src/wsAdapter.ts

@@ -1,7 +1,7 @@
 import merge from "lodash/merge";
 import { TaipyApp } from "./app";
 import { IdMessage, storeClientId } from "../../src/context/utils";
-import { WsMessage, sendWsMessage } from "../../src/context/wsUtils";
+import { WsMessage } from "../../src/context/wsUtils";
 import { DataManager, ModuleData } from "./dataManager";
 
 export abstract class WsAdapter {
@@ -25,7 +25,7 @@ export class TaipyWsAdapter extends WsAdapter {
     initWsMessageTypes: string[];
     constructor() {
         super();
-        this.supportedMessageTypes = ["MU", "ID", "GMC", "GDT", "AID", "GR", "AL"];
+        this.supportedMessageTypes = ["MU", "ID", "GMC", "GDT", "AID", "GR", "AL", "ACK"];
         this.initWsMessageTypes = ["ID", "AID", "GMC"];
     }
     handleWsMessage(message: WsMessage, taipyApp: TaipyApp): boolean {
@@ -81,6 +81,10 @@ export class TaipyWsAdapter extends WsAdapter {
             } else if (message.type === "AL" && taipyApp.onNotify) {
                 const payload = message as AlertMessage;
                 taipyApp.onNotify(taipyApp, payload.atype, payload.message);
+            } else if (message.type === "ACK") {
+                const {id} = message as unknown as Record<string, string>;
+                taipyApp._ackList = taipyApp._ackList.filter((v) => v !== id);
+                taipyApp.onWsStatusUpdate && taipyApp.onWsStatusUpdate(taipyApp, taipyApp._ackList);
             }
             this.postWsMessageProcessing(message, taipyApp);
             return true;
@@ -96,7 +100,7 @@ export class TaipyWsAdapter extends WsAdapter {
             taipyApp.context !== "" &&
             taipyApp.routes !== undefined
         ) {
-            sendWsMessage(taipyApp.socket, "GDT", "get_data_tree", {}, taipyApp.clientId, taipyApp.context);
+            taipyApp.sendWsMessage("GDT", "get_data_tree", {});
         }
     }
 }

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 254 - 233
frontend/taipy-gui/package-lock.json


+ 7 - 7
frontend/taipy-gui/package.json

@@ -29,7 +29,7 @@
     "react-window-infinite-loader": "^1.0.7",
     "socket.io-client": "^4.3.2",
     "sprintf-js": "^1.1.2",
-    "uuid": "^9.0.0"
+    "uuid": "^10.0.0"
   },
   "overrides": {
     "react": "$react",
@@ -75,7 +75,7 @@
   },
   "devDependencies": {
     "@testing-library/jest-dom": "^6.1.3",
-    "@testing-library/react": "^15.0.7",
+    "@testing-library/react": "^16.0.0",
     "@testing-library/user-event": "^14.2.1",
     "@types/css-mediaquery": "^0.1.1",
     "@types/jest": "^29.0.1",
@@ -88,7 +88,7 @@
     "@types/react-window": "^1.8.5",
     "@types/react-window-infinite-loader": "^1.0.5",
     "@types/sprintf-js": "^1.1.2",
-    "@types/uuid": "^9.0.0",
+    "@types/uuid": "^10.0.0",
     "@typescript-eslint/eslint-plugin": "^7.0.1",
     "@typescript-eslint/parser": "^7.0.1",
     "add-asset-html-webpack-plugin": "^6.0.0",
@@ -102,7 +102,7 @@
     "eslint": "^8.57.0",
     "eslint-plugin-react": "^7.26.1",
     "eslint-plugin-react-hooks": "^4.2.0",
-    "eslint-plugin-tsdoc": "^0.2.16",
+    "eslint-plugin-tsdoc": "^0.3.0",
     "eslint-webpack-plugin": "^4.0.0",
     "generate-json-webpack-plugin": "^2.0.0",
     "html-webpack-plugin": "^5.5.0",
@@ -115,9 +115,9 @@
     "mock-xmlhttprequest": "^8.2.0",
     "ts-jest": "^29.0.0",
     "ts-loader": "^9.2.6",
-    "typedoc": "^0.25.1",
-    "typedoc-plugin-markdown": "^3.13.4",
-    "typescript": "^5.2.2",
+    "typedoc": "^0.26.3",
+    "typedoc-plugin-markdown": "^4.1.1",
+    "typescript": "^5.5.3",
     "webpack": "^5.61.0",
     "webpack-cli": "^5.0.0"
   }

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

@@ -21,7 +21,7 @@ import { isValid } from "date-fns";
 import { ErrorBoundary } from "react-error-boundary";
 
 import { createSendUpdateAction } from "../../context/taipyReducers";
-import { getSuffixedClassNames, TaipyActiveProps, TaipyChangeProps } from "./utils";
+import { getSuffixedClassNames, TaipyActiveProps, TaipyChangeProps, DateProps, getProps } from "./utils";
 import { dateToString, getDateTime, getTimeZonedDate } from "../../utils";
 import { useClassNames, useDispatch, useDynamicProperty, useFormatConfig, useModule } from "../../utils/hooks";
 import Field from "./Field";
@@ -60,32 +60,6 @@ const getRangeDateTime = (
     return [null, null];
 };
 
-interface DateProps {
-    maxDate?: unknown;
-    maxDateTime?: unknown;
-    maxTime?: unknown;
-    minDate?: unknown;
-    minDateTime?: unknown;
-    minTime?: unknown;
-}
-
-const getProps = (p: DateProps, start: boolean, val: Date | null, withTime: boolean): DateProps => {
-    if (!val) {
-        return {};
-    }
-    const propName: keyof DateProps = withTime
-        ? start
-            ? "minDateTime"
-            : "maxDateTime"
-        : start
-        ? "minDate"
-        : "maxDate";
-    if (p[propName] == val) {
-        return p;
-    }
-    return { ...p, [propName]: val };
-};
-
 const DateRange = (props: DateRangeProps) => {
     const { updateVarName, withTime = false, id, propagate = true } = props;
     const dispatch = useDispatch();

+ 33 - 9
frontend/taipy-gui/src/components/Taipy/DateSelector.tsx

@@ -14,14 +14,14 @@
 import React, { useState, useEffect, useCallback } from "react";
 import Box from "@mui/material/Box";
 import Tooltip from "@mui/material/Tooltip";
-import { DatePicker } from "@mui/x-date-pickers/DatePicker";
+import { DatePicker, DatePickerProps } from "@mui/x-date-pickers/DatePicker";
 import { BaseDateTimePickerSlotProps } from "@mui/x-date-pickers/DateTimePicker/shared";
-import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
+import { DateTimePicker, DateTimePickerProps } from "@mui/x-date-pickers/DateTimePicker";
 import { isValid } from "date-fns";
 import { ErrorBoundary } from "react-error-boundary";
 
 import { createSendUpdateAction } from "../../context/taipyReducers";
-import { getSuffixedClassNames, TaipyActiveProps, TaipyChangeProps } from "./utils";
+import { getSuffixedClassNames, TaipyActiveProps, TaipyChangeProps, DateProps, getProps } from "./utils";
 import { dateToString, getDateTime, getTimeZonedDate } from "../../utils";
 import { useClassNames, useDispatch, useDynamicProperty, useFormatConfig, useModule } from "../../utils/hooks";
 import Field from "./Field";
@@ -31,6 +31,10 @@ interface DateSelectorProps extends TaipyActiveProps, TaipyChangeProps {
     withTime?: boolean;
     format?: string;
     date: string;
+    min?: string;
+    defaultMin?: string;
+    max?: string;
+    defaultMax?: string;
     defaultDate?: string;
     defaultEditable?: boolean;
     editable?: boolean;
@@ -46,12 +50,16 @@ const DateSelector = (props: DateSelectorProps) => {
     const formatConfig = useFormatConfig();
     const tz = formatConfig.timeZone;
     const [value, setValue] = useState(() => getDateTime(props.defaultDate, tz, withTime));
+    const [startProps, setStartProps] = useState<DateProps>({});
+    const [endProps, setEndProps] = useState<DateProps>({});
     const module = useModule();
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const editable = useDynamicProperty(props.editable, props.defaultEditable, true);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
+    const min = useDynamicProperty(props.min, props.defaultMin, undefined);
+    const max = useDynamicProperty(props.max, props.defaultMax, undefined);
 
     const handleChange = useCallback(
         (v: Date | null) => {
@@ -64,20 +72,32 @@ const DateSelector = (props: DateSelectorProps) => {
                         dateToString(newDate, withTime),
                         module,
                         props.onChange,
-                        propagate
-                    )
+                        propagate,
+                    ),
                 );
             }
         },
-        [updateVarName, dispatch, withTime, propagate, tz, props.onChange, module]
+        [updateVarName, dispatch, withTime, propagate, tz, props.onChange, module],
     );
 
     // Run every time props.value get updated
     useEffect(() => {
-        if (props.date !== undefined) {
-            setValue(getDateTime(props.date, tz, withTime));
+        try {
+            if (props.date !== undefined) {
+                setValue(getDateTime(props.date, tz, withTime));
+            }
+
+            if (min !== undefined) {
+                setStartProps((p) => getProps(p, true, getDateTime(min, tz, withTime), withTime));
+            }
+
+            if (max !== undefined) {
+                setEndProps((p) => getProps(p, false, getDateTime(max, tz, withTime), withTime));
+            }
+        } catch (error) {
+            console.error(error);
         }
-    }, [props.date, tz, withTime]);
+    }, [props.date, tz, withTime, max, min]);
 
     return (
         <ErrorBoundary FallbackComponent={ErrorFallback}>
@@ -86,6 +106,8 @@ const DateSelector = (props: DateSelectorProps) => {
                     {editable ? (
                         withTime ? (
                             <DateTimePicker
+                                {...(startProps as DateTimePickerProps<Date>)}
+                                {...(endProps as DateTimePickerProps<Date>)}
                                 value={value}
                                 onChange={handleChange}
                                 className={getSuffixedClassNames(className, "-picker")}
@@ -96,6 +118,8 @@ const DateSelector = (props: DateSelectorProps) => {
                             />
                         ) : (
                             <DatePicker
+                                {...(startProps as DatePickerProps<Date>)}
+                                {...(endProps as DatePickerProps<Date>)}
                                 value={value}
                                 onChange={handleChange}
                                 className={getSuffixedClassNames(className, "-picker")}

+ 74 - 11
frontend/taipy-gui/src/components/Taipy/Input.spec.tsx

@@ -28,14 +28,14 @@ describe("Input Component", () => {
     });
     it("displays the right info for string", async () => {
         const { getByDisplayValue } = render(
-            <Input value="toto" type="text" defaultValue="titi" className="taipy-input" />
+            <Input value="toto" type="text" defaultValue="titi" className="taipy-input" />,
         );
         const elt = getByDisplayValue("toto");
         expect(elt.parentElement?.parentElement).toHaveClass("taipy-input");
     });
     it("displays the default value", async () => {
         const { getByDisplayValue } = render(
-            <Input defaultValue="titi" value={undefined as unknown as string} type="text" />
+            <Input defaultValue="titi" value={undefined as unknown as string} type="text" />,
         );
         getByDisplayValue("titi");
     });
@@ -60,7 +60,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.clear(elt);
@@ -78,7 +78,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" onAction="on_action" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.click(elt);
@@ -96,7 +96,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" changeDelay={-1} />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.click(elt);
@@ -115,7 +115,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" onAction="on_action" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.click(elt);
@@ -138,14 +138,14 @@ describe("Number Component", () => {
     });
     it("displays the right info for string", async () => {
         const { getByDisplayValue } = render(
-            <Input value="12" type="number" defaultValue="1" className="taipy-number" />
+            <Input value="12" type="number" defaultValue="1" className="taipy-number" />,
         );
         const elt = getByDisplayValue(12);
         expect(elt.parentElement?.parentElement).toHaveClass("taipy-number");
     });
     it("displays the default value", async () => {
         const { getByDisplayValue } = render(
-            <Input defaultValue="1" value={undefined as unknown as string} type="number" />
+            <Input defaultValue="1" value={undefined as unknown as string} type="number" />,
         );
         getByDisplayValue("1");
     });
@@ -170,7 +170,7 @@ describe("Number Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value={"33"} type="number" updateVarName="varname" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("33");
         await userEvent.clear(elt);
@@ -184,8 +184,8 @@ describe("Number Component", () => {
         });
     });
     xit("shows 0", async () => {
-    //not working cf. https://github.com/testing-library/user-event/issues/1066
-    const { getByDisplayValue, rerender } = render(<Input value={"0"} type="number" />);
+        //not working cf. https://github.com/testing-library/user-event/issues/1066
+        const { getByDisplayValue, rerender } = render(<Input value={"0"} type="number" />);
         const elt = getByDisplayValue("0") as HTMLInputElement;
         expect(elt).toBeInTheDocument();
         await userEvent.type(elt, "{ArrowUp}");
@@ -193,4 +193,67 @@ describe("Number Component", () => {
         await userEvent.type(elt, "{ArrowDown}");
         expect(elt.value).toBe("0");
     });
+    it("Validates increment by step value on up click", async () => {
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const upSpinner = getByTestId("stepper-up-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await userEvent.click(upSpinner);
+        expect(elt.value).toBe("2");
+    });
+    it("Validates decrement by step value on down click", async () => {
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const downSpinner = getByTestId("stepper-down-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await userEvent.click(downSpinner);
+        expect(elt.value).toBe("-2");
+    });
+    it("Validates increment when holding shift key and clicking up", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const upSpinner = getByTestId("stepper-up-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.keyboard("[ShiftLeft>]");
+        await user.click(upSpinner);
+        expect(elt.value).toBe("20");
+    });
+    it("Validates decrement when holding shift key and clicking down", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const downSpinner = getByTestId("stepper-down-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.keyboard("[ShiftLeft>]");
+        await user.click(downSpinner);
+        expect(elt.value).toBe("-20");
+    });
+    it("Validate increment when holding shift key and arrow up", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue } = render(<Input value={"0"} type="number" step={2} />);
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.click(elt);
+        await user.keyboard("[ShiftLeft>]");
+        await user.keyboard("[ArrowUp]");
+        expect(elt.value).toBe("20");
+    });
+    it("Validate value when reaching max value", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue } = render(<Input value={"0"} type="number" step={2} max={20} />);
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.click(elt);
+        await user.keyboard("[ShiftLeft>]");
+        // Press the arrow up twice to validate that the value will not exceed the maximum value when reached
+        await user.keyboard("[ArrowUp]");
+        await user.keyboard("[ArrowUp]");
+        expect(elt.value).toBe("20");
+    });
+    it("Validate value when reaching min value", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue } = render(<Input value={"20"} type="number" step={2} min={0} />);
+        const elt = getByDisplayValue("20") as HTMLInputElement;
+        await user.click(elt);
+        await user.keyboard("[ShiftLeft>]");
+        // Press the arrow down twice to validate that the value will not exceed the minimum value when reached
+        await user.keyboard("[ArrowDown]");
+        await user.keyboard("[ArrowDown]");
+        expect(elt.value).toBe("0");
+    });
 });

+ 186 - 5
frontend/taipy-gui/src/components/Taipy/Input.tsx

@@ -11,9 +11,15 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useState, useEffect, useCallback, useRef, KeyboardEvent } from "react";
+import React, { useState, useEffect, useCallback, useRef, KeyboardEvent, useMemo, CSSProperties } from "react";
+import IconButton from "@mui/material/IconButton";
+import InputAdornment from "@mui/material/InputAdornment";
 import TextField from "@mui/material/TextField";
 import Tooltip from "@mui/material/Tooltip";
+import Visibility from "@mui/icons-material/Visibility";
+import VisibilityOff from "@mui/icons-material/VisibilityOff";
+import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp";
+import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
 
 import { createSendActionNameAction, createSendUpdateAction } from "../../context/taipyReducers";
 import { TaipyInputProps } from "./utils";
@@ -33,6 +39,20 @@ const getActionKeys = (keys?: string): string[] => {
     return ak.length > 0 ? ak : [AUTHORIZED_KEYS[0]];
 };
 
+const numberSx = {
+    "& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button": {
+        display: "none",
+    },
+    "& input[type=number]": {
+        MozAppearance: "textfield",
+    },
+};
+const verticalDivStyle: CSSProperties = {
+    display: "flex",
+    flexDirection: "column",
+    gap: 0,
+};
+
 const Input = (props: TaipyInputProps) => {
     const {
         type,
@@ -45,6 +65,7 @@ const Input = (props: TaipyInputProps) => {
         multiline = false,
         linesShown = 5,
     } = props;
+
     const [value, setValue] = useState(defaultValue);
     const dispatch = useDispatch();
     const delayCall = useRef(-1);
@@ -55,6 +76,10 @@ const Input = (props: TaipyInputProps) => {
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
+    const step = useDynamicProperty(props.step, props.defaultStep, 1);
+    const stepMultiplier = useDynamicProperty(props.stepMultiplier, props.defaultStepMultiplier, 10);
+    const min = useDynamicProperty(props.min, props.defaultMin, undefined);
+    const max = useDynamicProperty(props.max, props.defaultMax, undefined);
 
     const handleInput = useCallback(
         (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -79,7 +104,27 @@ const Input = (props: TaipyInputProps) => {
 
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
-            if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && actionKeys.includes(evt.key)) {
+            if (evt.shiftKey && type === "number") {
+                if (evt.key === "ArrowUp") {
+                    let val =
+                        Number(evt.currentTarget.querySelector("input")?.value || 0) +
+                        (step || 1) * (stepMultiplier || 10);
+                    if (max !== undefined && val > max) {
+                        val = max;
+                    }
+                    setValue(val.toString());
+                    evt.preventDefault();
+                } else if (evt.key === "ArrowDown") {
+                    let val =
+                        Number(evt.currentTarget.querySelector("input")?.value || 0) -
+                        (step || 1) * (stepMultiplier || 10);
+                    if (min !== undefined && val < min) {
+                        val = min;
+                    }
+                    setValue(val.toString());
+                    evt.preventDefault();
+                }
+            } else if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && actionKeys.includes(evt.key)) {
                 const val = evt.currentTarget.querySelector("input")?.value;
                 if (changeDelay > 0 && delayCall.current > 0) {
                     clearTimeout(delayCall.current);
@@ -92,7 +137,141 @@ const Input = (props: TaipyInputProps) => {
                 evt.preventDefault();
             }
         },
-        [actionKeys, updateVarName, onAction, id, dispatch, onChange, changeDelay, propagate, module]
+        [
+            type,
+            actionKeys,
+            step,
+            stepMultiplier,
+            max,
+            min,
+            changeDelay,
+            onAction,
+            dispatch,
+            id,
+            module,
+            updateVarName,
+            onChange,
+            propagate,
+        ]
+    );
+
+    const roundBasedOnStep = useMemo(() => {
+        const stepString = (step || 1).toString();
+        const decimalPlaces = stepString.includes(".") ? stepString.split(".")[1].length : 0;
+        const multiplier = Math.pow(10, decimalPlaces);
+        return (value: number) => Math.round(value * multiplier) / multiplier;
+    }, [step]);
+
+    const calculateNewValue = useMemo(() => {
+        return (prevValue: string, step: number, stepMultiplier: number, shiftKey: boolean, increment: boolean) => {
+            const multiplier = shiftKey ? stepMultiplier : 1;
+            const change = step * multiplier * (increment ? 1 : -1);
+            return roundBasedOnStep(Number(prevValue) + change).toString();
+        };
+    }, [roundBasedOnStep]);
+
+    const handleStepperMouseDown = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>, increment: boolean) => {
+            setValue((prevValue) => {
+                const newValue = calculateNewValue(
+                    prevValue,
+                    step || 1,
+                    stepMultiplier || 10,
+                    event.shiftKey,
+                    increment
+                );
+                if (min !== undefined && Number(newValue) < min) {
+                    return min.toString();
+                }
+                if (max !== undefined && Number(newValue) > max) {
+                    return max.toString();
+                }
+                return newValue;
+            });
+        },
+        [min, max, step, stepMultiplier, calculateNewValue]
+    );
+
+    const handleUpStepperMouseDown = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>) => {
+            handleStepperMouseDown(event, true);
+        },
+        [handleStepperMouseDown]
+    );
+
+    const handleDownStepperMouseDown = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>) => {
+            handleStepperMouseDown(event, false);
+        },
+        [handleStepperMouseDown]
+    );
+
+    // password
+    const [showPassword, setShowPassword] = useState(false);
+    const handleClickShowPassword = useCallback(() => setShowPassword((show) => !show), []);
+    const handleMouseDownPassword = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>) => event.preventDefault(),
+        []
+    );
+    const muiInputProps = useMemo(
+        () =>
+            type == "password"
+                ? {
+                      endAdornment: (
+                          <InputAdornment position="end">
+                              <IconButton
+                                  aria-label="toggle password visibility"
+                                  onClick={handleClickShowPassword}
+                                  onMouseDown={handleMouseDownPassword}
+                                  edge="end"
+                              >
+                                  {showPassword ? <VisibilityOff /> : <Visibility />}
+                              </IconButton>
+                          </InputAdornment>
+                      ),
+                  }
+                : type == "number"
+                ? {
+                      endAdornment: (
+                          <div style={verticalDivStyle}>
+                              <IconButton
+                                  data-testid="stepper-up-spinner"
+                                  size="small"
+                                  onMouseDown={handleUpStepperMouseDown}
+                              >
+                                  <ArrowDropUpIcon fontSize="inherit" />
+                              </IconButton>
+                              <IconButton
+                                  data-testid="stepper-down-spinner"
+                                  size="small"
+                                  onMouseDown={handleDownStepperMouseDown}
+                              >
+                                  <ArrowDropDownIcon fontSize="inherit" />
+                              </IconButton>
+                          </div>
+                      ),
+                  }
+                : undefined,
+        [
+            type,
+            showPassword,
+            handleClickShowPassword,
+            handleMouseDownPassword,
+            handleUpStepperMouseDown,
+            handleDownStepperMouseDown,
+        ]
+    );
+
+    const inputProps = useMemo(
+        () =>
+            type == "number"
+                ? {
+                      step: step ? step : 1,
+                      min: min,
+                      max: max,
+                  }
+                : undefined,
+        [type, step, min, max]
     );
 
     useEffect(() => {
@@ -104,12 +283,15 @@ const Input = (props: TaipyInputProps) => {
     return (
         <Tooltip title={hover || ""}>
             <TextField
+                sx={numberSx}
                 margin="dense"
                 hiddenLabel
                 value={value ?? ""}
                 className={className}
-                type={type}
+                type={showPassword && type == "password" ? "text" : type}
                 id={id}
+                inputProps={inputProps}
+                InputProps={muiInputProps}
                 label={props.label}
                 onChange={handleInput}
                 disabled={!active}
@@ -120,5 +302,4 @@ const Input = (props: TaipyInputProps) => {
         </Tooltip>
     );
 };
-
 export default Input;

+ 43 - 34
frontend/taipy-gui/src/components/Taipy/Login.tsx

@@ -11,7 +11,7 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { ChangeEvent, KeyboardEvent, MouseEvent, useCallback, useEffect, useState } from "react";
+import React, { ChangeEvent, KeyboardEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
 import Button from "@mui/material/Button";
 import CircularProgress from "@mui/material/CircularProgress";
 import DialogTitle from "@mui/material/DialogTitle";
@@ -19,10 +19,7 @@ import Dialog from "@mui/material/Dialog";
 import DialogActions from "@mui/material/DialogActions";
 import DialogContent from "@mui/material/DialogContent";
 import DialogContentText from "@mui/material/DialogContentText";
-import FormControl from "@mui/material/FormControl";
 import InputAdornment from "@mui/material/InputAdornment";
-import InputLabel from "@mui/material/InputLabel";
-import OutlinedInput from "@mui/material/OutlinedInput";
 import TextField from "@mui/material/TextField";
 import IconButton from "@mui/material/IconButton";
 import CloseIcon from "@mui/icons-material/Close";
@@ -51,6 +48,8 @@ const closeSx: SxProps<Theme> = {
     alignSelf: "start",
 };
 const titleSx = { m: 0, p: 2, display: "flex", paddingRight: "0.1em" };
+const userProps = { autocomplete: "username" };
+const pwdProps = { autocomplete: "current-password" };
 
 const Login = (props: LoginProps) => {
     const { id, title = "Log-in", onAction = "on_login", message, defaultMessage } = props;
@@ -81,13 +80,6 @@ const Login = (props: LoginProps) => {
         input == "user" ? setUser(evt.currentTarget.value) : setPassword(evt.currentTarget.value);
     }, []);
 
-    const handleClickShowPassword = useCallback(() => setShowPassword((show) => !show), []);
-
-    const handleMouseDownPassword = useCallback(
-        (event: React.MouseEvent<HTMLButtonElement>) => event.preventDefault(),
-        []
-    );
-
     const handleEnter = useCallback(
         (evt: KeyboardEvent<HTMLInputElement>) => {
             if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && evt.key == "Enter") {
@@ -98,6 +90,30 @@ const Login = (props: LoginProps) => {
         [handleAction]
     );
 
+    // password
+    const handleClickShowPassword = useCallback(() => setShowPassword((show) => !show), []);
+    const handleMouseDownPassword = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>) => event.preventDefault(),
+        []
+    );
+    const passwordProps = useMemo(
+        () => ({
+            endAdornment: (
+                <InputAdornment position="end">
+                    <IconButton
+                        aria-label="toggle password visibility"
+                        onClick={handleClickShowPassword}
+                        onMouseDown={handleMouseDownPassword}
+                        edge="end"
+                    >
+                        {showPassword ? <VisibilityOff /> : <Visibility />}
+                    </IconButton>
+                </InputAdornment>
+            ),
+        }),
+        [showPassword, handleClickShowPassword, handleMouseDownPassword]
+    );
+
     useEffect(() => {
         nbLogins++;
         if (nbLogins === 1) {
@@ -129,30 +145,23 @@ const Login = (props: LoginProps) => {
                     onChange={changeInput}
                     data-input="user"
                     onKeyDown={handleEnter}
+                    inputProps={userProps}
                 ></TextField>
-                <FormControl variant="outlined" data-input="password" required>
-                    <InputLabel htmlFor="taipy-login-password">Password</InputLabel>
-                    <OutlinedInput
-                        id="taipy-login-password"
-                        type={showPassword ? "text" : "password"}
-                        value={password}
-                        onChange={changeInput}
-                        endAdornment={
-                            <InputAdornment position="end">
-                                <IconButton
-                                    aria-label="toggle password visibility"
-                                    onClick={handleClickShowPassword}
-                                    onMouseDown={handleMouseDownPassword}
-                                    edge="end"
-                                >
-                                    {showPassword ? <VisibilityOff /> : <Visibility />}
-                                </IconButton>
-                            </InputAdornment>
-                        }
-                        label="Password"
-                        onKeyDown={handleEnter}
-                    />
-                </FormControl>
+                <TextField
+                    variant="outlined"
+                    label="Password"
+                    required
+                    fullWidth
+                    margin="dense"
+                    className={getSuffixedClassNames(className, "-password")}
+                    type={showPassword ? "text" : "password"}
+                    value={password}
+                    onChange={changeInput}
+                    data-input="password"
+                    onKeyDown={handleEnter}
+                    inputProps={pwdProps}
+                    InputProps={passwordProps}
+                />
                 <DialogContentText>{message || defaultMessage}</DialogContentText>
             </DialogContent>
             <DialogActions>

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

@@ -173,7 +173,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                 }
                 return [colsOrder, baseColumns, styTt.styles, styTt.tooltips, hNan, filter];
             } catch (e) {
-                console.info("PTable.columns: " + ((e as Error).message || e));
+                console.info("PaginatedTable.columns: ", (e as Error).message || e);
             }
         }
         return [

+ 34 - 0
frontend/taipy-gui/src/components/Taipy/utils.ts

@@ -48,6 +48,14 @@ export interface TaipyInputProps extends TaipyActiveProps, TaipyChangeProps, Tai
     type: string;
     value: string;
     defaultValue?: string;
+    step?: number;
+    defaultStep?: number;
+    stepMultiplier?: number;
+    defaultStepMultiplier?: number;
+    min?: number;
+    defaultMin?: number;
+    max?: number;
+    defaultMax?: number;
     changeDelay?: number;
     onAction?: string;
     actionKeys?: string;
@@ -59,6 +67,15 @@ export interface TaipyLabelProps {
     label?: string;
 }
 
+export interface DateProps {
+    maxDate?: unknown;
+    maxDateTime?: unknown;
+    maxTime?: unknown;
+    minDate?: unknown;
+    minDateTime?: unknown;
+    minTime?: unknown;
+}
+
 export const getArrayValue = <T>(arr: T[], idx: number, defVal?: T): T | undefined => {
     const val = Array.isArray(arr) && idx < arr.length ? arr[idx] : undefined;
     return val ?? defVal;
@@ -113,3 +130,20 @@ export const getSuffixedClassNames = (names: string | undefined, suffix: string)
 export const emptyStyle = {} as CSSProperties;
 
 export const disableColor = <T>(color: T, disabled: boolean) => (disabled ? ("disabled" as T) : color);
+
+export const getProps = (p: DateProps, start: boolean, val: Date | null, withTime: boolean): DateProps => {
+    if (!val) {
+        return {};
+    }
+    const propName: keyof DateProps = withTime
+        ? start
+            ? "minDateTime"
+            : "maxDateTime"
+        : start
+            ? "minDate"
+            : "maxDate";
+    if (p[propName] == val) {
+        return p;
+    }
+    return {...p, [propName]: val};
+};

+ 2 - 12
frontend/taipy-gui/src/utils/index.ts

@@ -170,23 +170,13 @@ export const formatWSValue = (
             if (value == "") {
                 return "";
             }
-            try {
-                return getDateTimeString(value.toString(), dataFormat, formatConf);
-            } catch (e) {
-                console.error(`wrong dateformat "${dataFormat || formatConf.dateTime}"`, e);
-            }
-            return getDateTimeString(value.toString(), undefined, formatConf);
+            return getDateTimeString(value.toString(), dataFormat, formatConf);
         case "datetime.date":
         case "date":
             if (value == "") {
                 return "";
             }
-            try {
-                return getDateTimeString(value.toString(), dataFormat, formatConf, undefined, false);
-            } catch (e) {
-                console.error(`wrong dateformat "${dataFormat || formatConf.date}"`, e);
-            }
-            return getDateTimeString(value.toString(), undefined, formatConf, undefined, false);
+            return getDateTimeString(value.toString(), dataFormat, formatConf, undefined, false);
         case "int":
         case "float":
         case "number":

+ 150 - 134
frontend/taipy/package-lock.json

@@ -32,7 +32,7 @@
         "eslint": "^8.20.0",
         "eslint-plugin-react": "^7.30.1",
         "eslint-plugin-react-hooks": "^4.6.0",
-        "eslint-plugin-tsdoc": "^0.2.17",
+        "eslint-plugin-tsdoc": "^0.3.0",
         "eslint-webpack-plugin": "^4.0.0",
         "ts-loader": "^9.3.1",
         "typescript": "^5.0.2",
@@ -41,6 +41,7 @@
       }
     },
     "../../taipy/gui/webapp": {
+      "name": "taipy-gui",
       "version": "3.2.0"
     },
     "node_modules/@babel/code-frame": {
@@ -382,9 +383,9 @@
       }
     },
     "node_modules/@eslint-community/regexpp": {
-      "version": "4.10.1",
-      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.1.tgz",
-      "integrity": "sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==",
+      "version": "4.11.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
+      "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
       "dev": true,
       "engines": {
         "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
@@ -460,26 +461,26 @@
       }
     },
     "node_modules/@floating-ui/core": {
-      "version": "1.6.2",
-      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz",
-      "integrity": "sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==",
+      "version": "1.6.4",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz",
+      "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==",
       "dependencies": {
-        "@floating-ui/utils": "^0.2.0"
+        "@floating-ui/utils": "^0.2.4"
       }
     },
     "node_modules/@floating-ui/dom": {
-      "version": "1.6.5",
-      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz",
-      "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==",
+      "version": "1.6.7",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz",
+      "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==",
       "dependencies": {
-        "@floating-ui/core": "^1.0.0",
-        "@floating-ui/utils": "^0.2.0"
+        "@floating-ui/core": "^1.6.0",
+        "@floating-ui/utils": "^0.2.4"
       }
     },
     "node_modules/@floating-ui/react-dom": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.0.tgz",
-      "integrity": "sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==",
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz",
+      "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==",
       "dependencies": {
         "@floating-ui/dom": "^1.0.0"
       },
@@ -489,9 +490,9 @@
       }
     },
     "node_modules/@floating-ui/utils": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz",
-      "integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw=="
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz",
+      "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA=="
     },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.11.14",
@@ -703,36 +704,45 @@
       }
     },
     "node_modules/@microsoft/tsdoc": {
-      "version": "0.14.2",
-      "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz",
-      "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==",
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz",
+      "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==",
       "dev": true
     },
     "node_modules/@microsoft/tsdoc-config": {
-      "version": "0.16.2",
-      "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz",
-      "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==",
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.0.tgz",
+      "integrity": "sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg==",
       "dev": true,
       "dependencies": {
-        "@microsoft/tsdoc": "0.14.2",
-        "ajv": "~6.12.6",
+        "@microsoft/tsdoc": "0.15.0",
+        "ajv": "~8.12.0",
         "jju": "~1.4.0",
-        "resolve": "~1.19.0"
+        "resolve": "~1.22.2"
       }
     },
-    "node_modules/@microsoft/tsdoc-config/node_modules/resolve": {
-      "version": "1.19.0",
-      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz",
-      "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==",
+    "node_modules/@microsoft/tsdoc-config/node_modules/ajv": {
+      "version": "8.12.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
+      "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
       "dev": true,
       "dependencies": {
-        "is-core-module": "^2.1.0",
-        "path-parse": "^1.0.6"
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
       },
       "funding": {
-        "url": "https://github.com/sponsors/ljharb"
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
       }
     },
+    "node_modules/@microsoft/tsdoc-config/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true
+    },
     "node_modules/@mui/base": {
       "version": "5.0.0-beta.40",
       "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz",
@@ -765,18 +775,18 @@
       }
     },
     "node_modules/@mui/core-downloads-tracker": {
-      "version": "5.15.20",
-      "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.20.tgz",
-      "integrity": "sha512-DoL2ppgldL16utL8nNyj/P12f8mCNdx/Hb/AJnX9rLY4b52hCMIx1kH83pbXQ6uMy6n54M3StmEbvSGoj2OFuA==",
+      "version": "5.15.21",
+      "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.21.tgz",
+      "integrity": "sha512-dp9lXBaJZzJYeJfQY3Ow4Rb49QaCEdkl2KKYscdQHQm6bMJ+l4XPY3Cd9PCeeJTsHPIDJ60lzXbeRgs6sx/rpw==",
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/mui-org"
       }
     },
     "node_modules/@mui/icons-material": {
-      "version": "5.15.20",
-      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.20.tgz",
-      "integrity": "sha512-oGcKmCuHaYbAAoLN67WKSXtHmEgyWcJToT1uRtmPyxMj9N5uqwc/mRtEnst4Wj/eGr+zYH2FiZQ79v9k7kSk1Q==",
+      "version": "5.15.21",
+      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.21.tgz",
+      "integrity": "sha512-yqkq1MbdkmX5ZHyvZTBuAaA6RkvoqkoAgwBSx9Oh0L0jAfj9T/Ih/NhMNjkl8PWVSonjfDUkKroBnjRyo/1M9Q==",
       "dependencies": {
         "@babel/runtime": "^7.23.9"
       },
@@ -799,13 +809,13 @@
       }
     },
     "node_modules/@mui/material": {
-      "version": "5.15.20",
-      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.20.tgz",
-      "integrity": "sha512-tVq3l4qoXx/NxUgIx/x3lZiPn/5xDbdTE8VrLczNpfblLYZzlrbxA7kb9mI8NoBF6+w9WE9IrxWnKK5KlPI2bg==",
+      "version": "5.15.21",
+      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.21.tgz",
+      "integrity": "sha512-nTyCcgduKwHqiuQ/B03EQUa+utSMzn2sQp0QAibsnYe4tvc3zkMbO0amKpl48vhABIY3IvT6w9615BFIgMt0YA==",
       "dependencies": {
         "@babel/runtime": "^7.23.9",
         "@mui/base": "5.0.0-beta.40",
-        "@mui/core-downloads-tracker": "^5.15.20",
+        "@mui/core-downloads-tracker": "^5.15.21",
         "@mui/system": "^5.15.20",
         "@mui/types": "^7.2.14",
         "@mui/utils": "^5.15.20",
@@ -979,14 +989,14 @@
       }
     },
     "node_modules/@mui/x-date-pickers": {
-      "version": "7.7.0",
-      "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.7.0.tgz",
-      "integrity": "sha512-huyoA22Vi8iCkee6ro0sX7CcFIcPV/Fl7ZGWwaQC8PTAheXhz823DjMYAiwRU/imF+UFYfUInWQ4XZCIkM+2Dw==",
+      "version": "7.8.0",
+      "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.8.0.tgz",
+      "integrity": "sha512-SkolW0vZ4XiUeD5FBevG9NQ3pAgfNYlJA5XFhSLFD/swNQRO4EYOUXw38O/ccOh1lkAcwVR+rrGPCoT4/0YGEg==",
       "dependencies": {
         "@babel/runtime": "^7.24.7",
         "@mui/base": "^5.0.0-beta.40",
-        "@mui/system": "^5.15.15",
-        "@mui/utils": "^5.15.14",
+        "@mui/system": "^5.15.20",
+        "@mui/utils": "^5.15.20",
         "@types/react-transition-group": "^4.4.10",
         "clsx": "^2.1.1",
         "prop-types": "^15.8.1",
@@ -1044,14 +1054,14 @@
       }
     },
     "node_modules/@mui/x-tree-view": {
-      "version": "7.7.0",
-      "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.7.0.tgz",
-      "integrity": "sha512-kUTMS77EcNjp1iXZlm4GGFzZHnQdZJfn2L9gvxAaHtNTDSRMS61jpsCcXknIyC797dmRPdALPewNzSOfkThF+Q==",
+      "version": "7.8.0",
+      "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.8.0.tgz",
+      "integrity": "sha512-+Kc4SSWNFe53ozCXprizNcRIUiYc/iBceFMJZJJcOQGqsQVnH3Y7uUx2dUgO4AMp4EQR3zUW+bjE8fqhBQcK9Q==",
       "dependencies": {
         "@babel/runtime": "^7.24.7",
         "@mui/base": "^5.0.0-beta.40",
-        "@mui/system": "^5.15.15",
-        "@mui/utils": "^5.15.14",
+        "@mui/system": "^5.15.20",
+        "@mui/utils": "^5.15.20",
         "@types/react-transition-group": "^4.4.10",
         "clsx": "^2.1.1",
         "prop-types": "^15.8.1",
@@ -1262,9 +1272,9 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "20.14.5",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.5.tgz",
-      "integrity": "sha512-aoRR+fJkZT2l0aGOJhuA8frnCSoNX6W7U2mpNq63+BxBIj5BQFt8rHy627kijCmm63ijdSdwvGgpUsU6MBsZZA==",
+      "version": "20.14.9",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
+      "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
       "dev": true,
       "dependencies": {
         "undici-types": "~5.26.4"
@@ -1313,16 +1323,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.13.1.tgz",
-      "integrity": "sha512-kZqi+WZQaZfPKnsflLJQCz6Ze9FFSMfXrrIOcyargekQxG37ES7DJNpJUE9Q/X5n3yTIP/WPutVNzgknQ7biLg==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.15.0.tgz",
+      "integrity": "sha512-uiNHpyjZtFrLwLDpHnzaDlP3Tt6sGMqTCiqmxaN4n4RP0EfYZDODJyddiFDF44Hjwxr5xAcaYxVKm9QKQFJFLA==",
       "dev": true,
       "dependencies": {
         "@eslint-community/regexpp": "^4.10.0",
-        "@typescript-eslint/scope-manager": "7.13.1",
-        "@typescript-eslint/type-utils": "7.13.1",
-        "@typescript-eslint/utils": "7.13.1",
-        "@typescript-eslint/visitor-keys": "7.13.1",
+        "@typescript-eslint/scope-manager": "7.15.0",
+        "@typescript-eslint/type-utils": "7.15.0",
+        "@typescript-eslint/utils": "7.15.0",
+        "@typescript-eslint/visitor-keys": "7.15.0",
         "graphemer": "^1.4.0",
         "ignore": "^5.3.1",
         "natural-compare": "^1.4.0",
@@ -1346,15 +1356,15 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.13.1.tgz",
-      "integrity": "sha512-1ELDPlnLvDQ5ybTSrMhRTFDfOQEOXNM+eP+3HT/Yq7ruWpciQw+Avi73pdEbA4SooCawEWo3dtYbF68gN7Ed1A==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.15.0.tgz",
+      "integrity": "sha512-k9fYuQNnypLFcqORNClRykkGOMOj+pV6V91R4GO/l1FDGwpqmSwoOQrOHo3cGaH63e+D3ZiCAOsuS/D2c99j/A==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "7.13.1",
-        "@typescript-eslint/types": "7.13.1",
-        "@typescript-eslint/typescript-estree": "7.13.1",
-        "@typescript-eslint/visitor-keys": "7.13.1",
+        "@typescript-eslint/scope-manager": "7.15.0",
+        "@typescript-eslint/types": "7.15.0",
+        "@typescript-eslint/typescript-estree": "7.15.0",
+        "@typescript-eslint/visitor-keys": "7.15.0",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -1374,13 +1384,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.1.tgz",
-      "integrity": "sha512-adbXNVEs6GmbzaCpymHQ0MB6E4TqoiVbC0iqG3uijR8ZYfpAXMGttouQzF4Oat3P2GxDVIrg7bMI/P65LiQZdg==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.15.0.tgz",
+      "integrity": "sha512-Q/1yrF/XbxOTvttNVPihxh1b9fxamjEoz2Os/Pe38OHwxC24CyCqXxGTOdpb4lt6HYtqw9HetA/Rf6gDGaMPlw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "7.13.1",
-        "@typescript-eslint/visitor-keys": "7.13.1"
+        "@typescript-eslint/types": "7.15.0",
+        "@typescript-eslint/visitor-keys": "7.15.0"
       },
       "engines": {
         "node": "^18.18.0 || >=20.0.0"
@@ -1391,13 +1401,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.13.1.tgz",
-      "integrity": "sha512-aWDbLu1s9bmgPGXSzNCxELu+0+HQOapV/y+60gPXafR8e2g1Bifxzevaa+4L2ytCWm+CHqpELq4CSoN9ELiwCg==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.15.0.tgz",
+      "integrity": "sha512-SkgriaeV6PDvpA6253PDVep0qCqgbO1IOBiycjnXsszNTVQe5flN5wR5jiczoEoDEnAqYFSFFc9al9BSGVltkg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "7.13.1",
-        "@typescript-eslint/utils": "7.13.1",
+        "@typescript-eslint/typescript-estree": "7.15.0",
+        "@typescript-eslint/utils": "7.15.0",
         "debug": "^4.3.4",
         "ts-api-utils": "^1.3.0"
       },
@@ -1418,9 +1428,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.1.tgz",
-      "integrity": "sha512-7K7HMcSQIAND6RBL4kDl24sG/xKM13cA85dc7JnmQXw2cBDngg7c19B++JzvJHRG3zG36n9j1i451GBzRuHchw==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.15.0.tgz",
+      "integrity": "sha512-aV1+B1+ySXbQH0pLK0rx66I3IkiZNidYobyfn0WFsdGhSXw+P3YOqeTq5GED458SfB24tg+ux3S+9g118hjlTw==",
       "dev": true,
       "engines": {
         "node": "^18.18.0 || >=20.0.0"
@@ -1431,13 +1441,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.1.tgz",
-      "integrity": "sha512-uxNr51CMV7npU1BxZzYjoVz9iyjckBduFBP0S5sLlh1tXYzHzgZ3BR9SVsNed+LmwKrmnqN3Kdl5t7eZ5TS1Yw==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.15.0.tgz",
+      "integrity": "sha512-gjyB/rHAopL/XxfmYThQbXbzRMGhZzGw6KpcMbfe8Q3nNQKStpxnUKeXb0KiN/fFDR42Z43szs6rY7eHk0zdGQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "7.13.1",
-        "@typescript-eslint/visitor-keys": "7.13.1",
+        "@typescript-eslint/types": "7.15.0",
+        "@typescript-eslint/visitor-keys": "7.15.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -1459,15 +1469,15 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.1.tgz",
-      "integrity": "sha512-h5MzFBD5a/Gh/fvNdp9pTfqJAbuQC4sCN2WzuXme71lqFJsZtLbjxfSk4r3p02WIArOF9N94pdsLiGutpDbrXQ==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.15.0.tgz",
+      "integrity": "sha512-hfDMDqaqOqsUVGiEPSMLR/AjTSCsmJwjpKkYQRo1FNbmW4tBwBspYDwO9eh7sKSTwMQgBw9/T4DHudPaqshRWA==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.4.0",
-        "@typescript-eslint/scope-manager": "7.13.1",
-        "@typescript-eslint/types": "7.13.1",
-        "@typescript-eslint/typescript-estree": "7.13.1"
+        "@typescript-eslint/scope-manager": "7.15.0",
+        "@typescript-eslint/types": "7.15.0",
+        "@typescript-eslint/typescript-estree": "7.15.0"
       },
       "engines": {
         "node": "^18.18.0 || >=20.0.0"
@@ -1481,12 +1491,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "7.13.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.1.tgz",
-      "integrity": "sha512-k/Bfne7lrP7hcb7m9zSsgcBmo+8eicqqfNAJ7uUY+jkTFpKeH2FSkWpFRtimBxgkyvqfu9jTPRbYOvud6isdXA==",
+      "version": "7.15.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.15.0.tgz",
+      "integrity": "sha512-Hqgy/ETgpt2L5xueA/zHHIl4fJI2O4XUE9l4+OIfbJIRSnTJb/QscncdqqZzofQegIJugRIF57OJea1khw2SDw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "7.13.1",
+        "@typescript-eslint/types": "7.15.0",
         "eslint-visitor-keys": "^3.4.3"
       },
       "engines": {
@@ -2098,9 +2108,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001636",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz",
-      "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==",
+      "version": "1.0.30001639",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001639.tgz",
+      "integrity": "sha512-eFHflNTBIlFwP2AIKaYuBQN/apnUoKNhBdza8ZnW/h2di4LCZ4xFqYlxUxo+LQ76KFI1PGcC1QDxMbxTZpSCAg==",
       "dev": true,
       "funding": [
         {
@@ -2439,9 +2449,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.805",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.805.tgz",
-      "integrity": "sha512-8W4UJwX/w9T0QSzINJckTKG6CYpAUTqsaWcWIsdud3I1FYJcMgW9QqT1/4CBff/pP/TihWh13OmiyY8neto6vw==",
+      "version": "1.4.816",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.816.tgz",
+      "integrity": "sha512-EKH5X5oqC6hLmiS7/vYtZHZFTNdhsYG5NVPRN6Yn0kQHNBlT59+xSM8HBy66P5fxWpKgZbPqb+diC64ng295Jw==",
       "dev": true
     },
     "node_modules/enhanced-resolve": {
@@ -2584,9 +2594,9 @@
       }
     },
     "node_modules/es-module-lexer": {
-      "version": "1.5.3",
-      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.3.tgz",
-      "integrity": "sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg==",
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
+      "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
       "dev": true
     },
     "node_modules/es-object-atoms": {
@@ -2717,16 +2727,16 @@
       }
     },
     "node_modules/eslint-plugin-react": {
-      "version": "7.34.2",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.2.tgz",
-      "integrity": "sha512-2HCmrU+/JNigDN6tg55cRDKCQWicYAPB38JGSFDQt95jDm8rrvSUo7YPkOIm5l6ts1j1zCvysNcasvfTMQzUOw==",
+      "version": "7.34.3",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.3.tgz",
+      "integrity": "sha512-aoW4MV891jkUulwDApQbPYTVZmeuSyFrudpbTAQuj5Fv8VL+o6df2xIGpw8B0hPjAaih1/Fb0om9grCdyFYemA==",
       "dev": true,
       "dependencies": {
         "array-includes": "^3.1.8",
         "array.prototype.findlast": "^1.2.5",
         "array.prototype.flatmap": "^1.3.2",
         "array.prototype.toreversed": "^1.1.2",
-        "array.prototype.tosorted": "^1.1.3",
+        "array.prototype.tosorted": "^1.1.4",
         "doctrine": "^2.1.0",
         "es-iterator-helpers": "^1.0.19",
         "estraverse": "^5.3.0",
@@ -2821,13 +2831,13 @@
       }
     },
     "node_modules/eslint-plugin-tsdoc": {
-      "version": "0.2.17",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.2.17.tgz",
-      "integrity": "sha512-xRmVi7Zx44lOBuYqG8vzTXuL6IdGOeF9nHX17bjJ8+VE6fsxpdGem0/SBTmAwgYMKYB1WBkqRJVQ+n8GK041pA==",
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.3.0.tgz",
+      "integrity": "sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A==",
       "dev": true,
       "dependencies": {
-        "@microsoft/tsdoc": "0.14.2",
-        "@microsoft/tsdoc-config": "0.16.2"
+        "@microsoft/tsdoc": "0.15.0",
+        "@microsoft/tsdoc-config": "0.17.0"
       }
     },
     "node_modules/eslint-scope": {
@@ -3707,11 +3717,14 @@
       }
     },
     "node_modules/is-core-module": {
-      "version": "2.13.1",
-      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
-      "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz",
+      "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==",
       "dependencies": {
-        "hasown": "^2.0.0"
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
       },
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
@@ -4361,9 +4374,9 @@
       }
     },
     "node_modules/minimatch": {
-      "version": "9.0.4",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
-      "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
       "dev": true,
       "dependencies": {
         "brace-expansion": "^2.0.1"
@@ -4416,10 +4429,13 @@
       }
     },
     "node_modules/object-inspect": {
-      "version": "1.13.1",
-      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz",
-      "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==",
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
+      "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==",
       "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
       }
@@ -5778,9 +5794,9 @@
       }
     },
     "node_modules/typescript": {
-      "version": "5.4.5",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
-      "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+      "version": "5.5.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
+      "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
       "dev": true,
       "bin": {
         "tsc": "bin/tsc",
@@ -5864,9 +5880,9 @@
       }
     },
     "node_modules/webpack": {
-      "version": "5.92.0",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.0.tgz",
-      "integrity": "sha512-Bsw2X39MYIgxouNATyVpCNVWBCuUwDgWtN78g6lSdPJRLaQ/PUVm/oXcaRAyY/sMFoKFQrsPeqvTizWtq7QPCA==",
+      "version": "5.92.1",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz",
+      "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==",
       "dev": true,
       "dependencies": {
         "@types/eslint-scope": "^3.7.3",

+ 1 - 1
frontend/taipy/package.json

@@ -11,7 +11,7 @@
     "eslint": "^8.20.0",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
-    "eslint-plugin-tsdoc": "^0.2.17",
+    "eslint-plugin-tsdoc": "^0.3.0",
     "eslint-webpack-plugin": "^4.0.0",
     "ts-loader": "^9.3.1",
     "typescript": "^5.0.2",

+ 26 - 11
frontend/taipy/src/ScenarioDag.tsx

@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { Point } from '@projectstorm/geometry';
+import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
+import { Point } from "@projectstorm/geometry";
 import { CanvasWidget } from "@projectstorm/react-canvas-core";
 import Box from "@mui/material/Box";
 import AppBar from "@mui/material/AppBar";
@@ -76,6 +76,8 @@ const getValidScenario = (scenar: DisplayModel | DisplayModel[]) =>
         ? (scenar[0] as DisplayModel)
         : undefined;
 
+const preventWheel = (e: Event) => e.preventDefault();
+
 const ScenarioDag = (props: ScenarioDagProps) => {
     const { showToolbar = true, onSelect, onAction } = props;
     const [scenarioId, setScenarioId] = useState("");
@@ -85,6 +87,7 @@ const ScenarioDag = (props: ScenarioDagProps) => {
     const [taskStatuses, setTaskStatuses] = useState<TaskStatuses>();
     const dispatch = useDispatch();
     const module = useModule();
+    const canvasRef = useRef<CanvasWidget>(null);
 
     const render = useDynamicProperty(props.render, props.defaultRender, true);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
@@ -144,16 +147,18 @@ const ScenarioDag = (props: ScenarioDagProps) => {
             // populate model
             doLayout = populateModel(addStatusToDisplayModel(displayModel, taskStatuses), model);
         }
-        const rects = engine.getModel() && engine
-            .getModel()
-            .getNodes()
-            .reduce((pv, nm) => {
-                pv[nm.getID()] = nm.getPosition();
-                return pv;
-            }, {} as Record<string, Point>);
+        const rects =
+            engine.getModel() &&
+            engine
+                .getModel()
+                .getNodes()
+                .reduce((pv, nm) => {
+                    pv[nm.getID()] = nm.getPosition();
+                    return pv;
+                }, {} as Record<string, Point>);
         const hasPos = rects && Object.keys(rects).length;
         if (hasPos) {
-            model.getNodes().forEach(nm => rects[nm.getID()] && nm.setPosition(rects[nm.getID()]));
+            model.getNodes().forEach((nm) => rects[nm.getID()] && nm.setPosition(rects[nm.getID()]));
         }
         engine.setModel(model);
         model.setLocked(true);
@@ -165,10 +170,20 @@ const ScenarioDag = (props: ScenarioDagProps) => {
         showVar && dispatch(createSendUpdateAction(showVar, render, module));
     }, [render, props.updateVars, dispatch, module]);
 
+    useEffect(() => {
+        setTimeout(() => {
+            // wait for div to be referenced and then set a non passive listener on wheel event
+            canvasRef.current?.ref.current?.addEventListener("wheel", preventWheel, { passive: false });
+        }, 300);
+        // remove the listener
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+        return () => canvasRef.current?.ref.current?.removeEventListener("wheel", preventWheel);
+    }, []);
+
     return render && scenarioId ? (
         <Paper sx={sizeSx} id={props.id} className={className}>
             {showToolbar ? <DagTitle zoomToFit={zoomToFit} /> : null}
-            <CanvasWidget engine={engine} />
+            <CanvasWidget engine={engine} ref={canvasRef} />
         </Paper>
     ) : null;
 };

+ 6 - 6
taipy/core/_entity/_ready_to_run_property.py

@@ -12,7 +12,7 @@
 from typing import TYPE_CHECKING, Dict, Set, Union
 
 from ..notification import EventOperation, Notifier, _make_event
-from ..reason.reason import Reasons
+from ..reason import Reason, ReasonCollection
 
 if TYPE_CHECKING:
     from ..data.data_node import DataNode, DataNodeId
@@ -29,10 +29,10 @@ class _ReadyToRunProperty:
 
     # A nested dictionary of the submittable entities (Scenario, Sequence, Task) and
     # the data nodes that make it not ready_to_run with the reason(s)
-    _submittable_id_datanodes: Dict[Union["ScenarioId", "SequenceId", "TaskId"], Reasons] = {}
+    _submittable_id_datanodes: Dict[Union["ScenarioId", "SequenceId", "TaskId"], ReasonCollection] = {}
 
     @classmethod
-    def _add(cls, dn: "DataNode", reason: str) -> None:
+    def _add(cls, dn: "DataNode", reason: Reason) -> None:
         from ..scenario.scenario import Scenario
         from ..sequence.sequence import Sequence
         from ..task.task import Task
@@ -50,7 +50,7 @@ class _ReadyToRunProperty:
                 cls.__add(task_parent, dn, reason)
 
     @classmethod
-    def _remove(cls, datanode: "DataNode", reason: str) -> None:
+    def _remove(cls, datanode: "DataNode", reason: Reason) -> None:
         from ..taipy import get as tp_get
 
         # check the data node status to determine the reason to be removed
@@ -72,7 +72,7 @@ class _ReadyToRunProperty:
             cls._datanode_id_submittables.pop(datanode.id)
 
     @classmethod
-    def __add(cls, submittable: Union["Scenario", "Sequence", "Task"], datanode: "DataNode", reason: str) -> None:
+    def __add(cls, submittable: Union["Scenario", "Sequence", "Task"], datanode: "DataNode", reason: Reason) -> None:
         if datanode.id not in cls._datanode_id_submittables:
             cls._datanode_id_submittables[datanode.id] = set()
         cls._datanode_id_submittables[datanode.id].add(submittable.id)
@@ -81,7 +81,7 @@ class _ReadyToRunProperty:
             cls.__publish_submittable_property_event(submittable, False)
 
         if submittable.id not in cls._submittable_id_datanodes:
-            cls._submittable_id_datanodes[submittable.id] = Reasons(submittable.id)
+            cls._submittable_id_datanodes[submittable.id] = ReasonCollection()
         cls._submittable_id_datanodes[submittable.id]._add_reason(datanode.id, reason)
 
     @staticmethod

+ 6 - 7
taipy/core/_entity/submittable.py

@@ -19,8 +19,7 @@ from ..common._listattributes import _ListAttributes
 from ..common._utils import _Subscriber
 from ..data.data_node import DataNode
 from ..job.job import Job
-from ..reason._reason_factory import _build_data_node_is_being_edited_reason, _build_data_node_is_not_written
-from ..reason.reason import Reasons
+from ..reason import DataNodeEditInProgress, DataNodeIsNotWritten, ReasonCollection
 from ..submission.submission import Submission
 from ..task.task import Task
 from ._dag import _DAG
@@ -83,22 +82,22 @@ class Submittable:
         all_data_nodes_in_dag = {node for node in dag.nodes if isinstance(node, DataNode)}
         return all_data_nodes_in_dag - self.__get_inputs(dag) - self.__get_outputs(dag)
 
-    def is_ready_to_run(self) -> Reasons:
+    def is_ready_to_run(self) -> ReasonCollection:
         """Indicate if the entity is ready to be run.
 
         Returns:
             A Reason object that can function as a Boolean value.
             which is True if the given entity is ready to be run or there is no reason to be blocked, False otherwise.
         """
-        reason = Reasons(self._submittable_id)
+        reason_collection = ReasonCollection()
 
         for node in self.get_inputs():
             if node._edit_in_progress:
-                reason._add_reason(node.id, _build_data_node_is_being_edited_reason(node.id))
+                reason_collection._add_reason(node.id, DataNodeEditInProgress(node.id))
             if not node._last_edit_date:
-                reason._add_reason(node.id, _build_data_node_is_not_written(node.id))
+                reason_collection._add_reason(node.id, DataNodeIsNotWritten(node.id))
 
-        return reason
+        return reason_collection
 
     def data_nodes_being_edited(self) -> Set[DataNode]:
         """Return the set of data nodes of the submittable entity that are being edited.

+ 6 - 7
taipy/core/data/_data_manager.py

@@ -22,8 +22,7 @@ from ..config.data_node_config import DataNodeConfig
 from ..cycle.cycle_id import CycleId
 from ..exceptions.exceptions import InvalidDataNodeType
 from ..notification import Event, EventEntityType, EventOperation, Notifier, _make_event
-from ..reason._reason_factory import _build_not_global_scope_reason, _build_wrong_config_type_reason
-from ..reason.reason import Reasons
+from ..reason import NotGlobalScope, ReasonCollection, WrongConfigType
 from ..scenario.scenario_id import ScenarioId
 from ..sequence.sequence_id import SequenceId
 from ._data_fs_repository import _DataFSRepository
@@ -69,17 +68,17 @@ class _DataManager(_Manager[DataNode], _VersionMixin):
         }
 
     @classmethod
-    def _can_create(cls, config: Optional[DataNodeConfig] = None) -> Reasons:
+    def _can_create(cls, config: Optional[DataNodeConfig] = None) -> ReasonCollection:
         config_id = getattr(config, "id", None) or str(config)
-        reason = Reasons(config_id)
+        reason_collection = ReasonCollection()
 
         if config is not None:
             if not isinstance(config, DataNodeConfig):
-                reason._add_reason(config_id, _build_wrong_config_type_reason(config_id, "DataNodeConfig"))
+                reason_collection._add_reason(config_id, WrongConfigType(config_id, DataNodeConfig.__name__))
             elif config.scope is not Scope.GLOBAL:
-                reason._add_reason(config_id, _build_not_global_scope_reason(config_id))
+                reason_collection._add_reason(config_id, NotGlobalScope(config_id))
 
-        return reason
+        return reason_collection
 
     @classmethod
     def _create_and_set(

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

@@ -32,6 +32,7 @@ from ..common._warnings import _warn_deprecated
 from ..exceptions.exceptions import DataNodeIsBeingEdited, NoData
 from ..job.job_id import JobId
 from ..notification.event import Event, EventEntityType, EventOperation, _make_event
+from ..reason import DataNodeEditInProgress, DataNodeIsNotWritten
 from ._filter import _FilterDataNode
 from .data_node_id import DataNodeId, Edit
 from .operator import JoinOperator
@@ -43,13 +44,13 @@ def _update_ready_for_reading(fct):
     def _recompute_is_ready_for_reading(dn: "DataNode", *args, **kwargs):
         fct(dn, *args, **kwargs)
         if dn._edit_in_progress:
-            _ReadyToRunProperty._add(dn, f"DataNode {dn.id} is being edited")
+            _ReadyToRunProperty._add(dn, DataNodeEditInProgress(dn.id))
         else:
-            _ReadyToRunProperty._remove(dn, f"DataNode {dn.id} is being edited")
+            _ReadyToRunProperty._remove(dn, DataNodeEditInProgress(dn.id))
         if not dn._last_edit_date:
-            _ReadyToRunProperty._add(dn, f"DataNode {dn.id} is not written")
+            _ReadyToRunProperty._add(dn, DataNodeIsNotWritten(dn.id))
         else:
-            _ReadyToRunProperty._remove(dn, f"DataNode {dn.id} is not written")
+            _ReadyToRunProperty._remove(dn, DataNodeIsNotWritten(dn.id))
 
     return _recompute_is_ready_for_reading
 
@@ -396,7 +397,7 @@ class DataNode(_Entity, _Labeled):
             return self.read_or_raise()
         except NoData:
             self.__logger.warning(
-                f"Data node {self.id} from config {self.config_id} is being read but has never been " f"written."
+                f"Data node {self.id} from config {self.config_id} is being read but has never been written."
             )
             return None
 

+ 9 - 1
taipy/core/reason/__init__.py

@@ -9,4 +9,12 @@
 # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 # specific language governing permissions and limitations under the License.
 
-from .reason import Reasons
+from .reason import (
+    DataNodeEditInProgress,
+    DataNodeIsNotWritten,
+    EntityIsNotSubmittableEntity,
+    NotGlobalScope,
+    Reason,
+    WrongConfigType,
+)
+from .reason_collection import ReasonCollection

+ 0 - 37
taipy/core/reason/_reason_factory.py

@@ -1,37 +0,0 @@
-# Copyright 2021-2024 Avaiga Private Limited
-#
-# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
-# the License. You may obtain a copy of the License at
-#
-#        http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
-# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations under the License.
-
-from typing import Optional
-
-from ..data.data_node import DataNodeId
-
-
-def _build_data_node_is_being_edited_reason(dn_id: DataNodeId) -> str:
-    return f"DataNode {dn_id} is being edited"
-
-
-def _build_data_node_is_not_written(dn_id: DataNodeId) -> str:
-    return f"DataNode {dn_id} is not written"
-
-
-def _build_not_submittable_entity_reason(entity_id: str) -> str:
-    return f"Entity {entity_id} is not a submittable entity"
-
-
-def _build_wrong_config_type_reason(config_id: str, config_type: Optional[str]) -> str:
-    if config_type:
-        return f'Object "{config_id}" must be a valid {config_type}'
-
-    return f'Object "{config_id}" is not a valid config to be created'
-
-
-def _build_not_global_scope_reason(config_id: str) -> str:
-    return f'Data node config "{config_id}" does not have GLOBAL scope'

+ 106 - 22
taipy/core/reason/reason.py

@@ -9,33 +9,117 @@
 # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 # specific language governing permissions and limitations under the License.
 
-from typing import Dict, Set
+from typing import Any, Optional
 
 
-class Reasons:
-    def __init__(self, entity_id: str) -> None:
-        self.entity_id: str = entity_id
-        self._reasons: Dict[str, Set[str]] = {}
+class Reason:
+    """
+    A reason explains why a specific action cannot be performed.
 
-    def _add_reason(self, entity_id: str, reason: str) -> "Reasons":
-        if entity_id not in self._reasons:
-            self._reasons[entity_id] = set()
-        self._reasons[entity_id].add(reason)
-        return self
+    This is a parent class aiming at being implemented by specific sub-classes.
 
-    def _remove_reason(self, entity_id: str, reason: str) -> "Reasons":
-        if entity_id in self._reasons and reason in self._reasons[entity_id]:
-            self._reasons[entity_id].remove(reason)
-            if len(self._reasons[entity_id]) == 0:
-                del self._reasons[entity_id]
-        return self
+    Because Taipy applications are natively multiuser, asynchronous, and dynamic,
+    some functions might not be called in some specific contexts. You can protect
+    such calls by calling other methods that return a Reasons object. It acts like a
+    boolean: True if the operation can be performed and False otherwise.
+    If the action cannot be performed, the Reasons object holds all the `reasons as a list
+    of `Reason` objects. Each `Reason` holds an explanation of why the operation cannot be
+    performed.
 
-    def _entity_id_exists_in_reason(self, entity_id: str) -> bool:
-        return entity_id in self._reasons
+    Attributes:
+        reason (str): The English representation of the reason why the action cannot be performed.
+    """
 
-    def __bool__(self) -> bool:
-        return len(self._reasons) == 0
+    def __init__(self, reason: str):
+        self._reason = reason
+
+    def __str__(self) -> str:
+        return self._reason
+
+    def __repr__(self) -> str:
+        return self._reason
+
+    def __hash__(self) -> int:
+        return hash(self._reason)
+
+    def __eq__(self, value: Any) -> bool:
+        return isinstance(value, Reason) and value._reason == self._reason
+
+
+class _DataNodeReasonMixin:
+    def __init__(self, datanode_id: str):
+        self.datanode_id = datanode_id
 
     @property
-    def reasons(self) -> str:
-        return "; ".join("; ".join(reason) for reason in self._reasons.values()) + "." if self._reasons else ""
+    def datanode(self):
+        from ..data._data_manager_factory import _DataManagerFactory
+
+        return _DataManagerFactory._build_manager()._get(self.datanode_id)
+
+
+class DataNodeEditInProgress(Reason, _DataNodeReasonMixin):
+    """
+    A `DataNode^` is being edited, which prevents specific actions from being performed.
+
+    Attributes:
+        datanode_id (str): The identifier of the `DataNode^`.
+    """
+
+    def __init__(self, datanode_id: str):
+        Reason.__init__(self, f"DataNode {datanode_id} is being edited")
+        _DataNodeReasonMixin.__init__(self, datanode_id)
+
+
+class DataNodeIsNotWritten(Reason, _DataNodeReasonMixin):
+    """
+    A `DataNode^` has never been written, which prevents specific actions from being performed.
+
+    Attributes:
+        datanode_id (str): The identifier of the `DataNode^`.
+    """
+
+    def __init__(self, datanode_id: str):
+        Reason.__init__(self, f"DataNode {datanode_id} is not written")
+        _DataNodeReasonMixin.__init__(self, datanode_id)
+
+
+class EntityIsNotSubmittableEntity(Reason):
+    """
+    An entity is not a submittable entity, which prevents specific actions from being performed.
+
+    Attributes:
+        entity_id (str): The identifier of the `Entity^`.
+    """
+
+    def __init__(self, entity_id: str):
+        Reason.__init__(self, f"Entity {entity_id} is not a submittable entity")
+
+
+class WrongConfigType(Reason):
+    """
+    A config id is not a valid expected config, which prevents specific actions from being performed.
+
+    Attributes:
+        config_id (str): The identifier of the config.
+        config_type (str): The expected config type.
+    """
+
+    def __init__(self, config_id: str, config_type: Optional[str]):
+        if config_type:
+            reason = f'Object "{config_id}" must be a valid {config_type}'
+        else:
+            reason = f'Object "{config_id}" is not a valid config to be created'
+
+        Reason.__init__(self, reason)
+
+
+class NotGlobalScope(Reason):
+    """
+    A data node config does not have a GLOBAL scope, which prevents specific actions from being performed.
+
+    Attributes:
+        config_id (str): The identifier of the config.
+    """
+
+    def __init__(self, config_id: str):
+        Reason.__init__(self, f'Data node config "{config_id}" does not have GLOBAL scope')

+ 60 - 0
taipy/core/reason/reason_collection.py

@@ -0,0 +1,60 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+from typing import Dict, Set
+
+from .reason import Reason
+
+
+class ReasonCollection:
+    """
+    This class is used to store all the reasons to explain why some Taipy operations are not allowed.
+
+    Because Taipy applications are natively multiuser, asynchronous, and dynamic,
+    some functions might not be called in some specific contexts. You can protect
+    such calls by calling other methods that return a `ReasonCollection`. It acts like a
+    boolean: True if the operation can be performed and False otherwise.
+    If the action cannot be performed, the ReasonCollection holds all the individual reasons as a list
+    of `Reason` objects. Each `Reason` explains why the operation cannot be performed.
+    """
+
+    def __init__(self) -> None:
+        self._reasons: Dict[str, Set[Reason]] = {}
+
+    def _add_reason(self, entity_id: str, reason: Reason) -> "ReasonCollection":
+        if entity_id not in self._reasons:
+            self._reasons[entity_id] = set()
+        self._reasons[entity_id].add(reason)
+        return self
+
+    def _remove_reason(self, entity_id: str, reason: Reason) -> "ReasonCollection":
+        if entity_id in self._reasons and reason in self._reasons[entity_id]:
+            self._reasons[entity_id].remove(reason)
+            if len(self._reasons[entity_id]) == 0:
+                del self._reasons[entity_id]
+        return self
+
+    def _entity_id_exists_in_reason(self, entity_id: str) -> bool:
+        return entity_id in self._reasons
+
+    def __bool__(self) -> bool:
+        return len(self._reasons) == 0
+
+    @property
+    def reasons(self) -> str:
+        """Retrieves a collection of reasons as a string that explains why the action cannot be performed.
+
+        Returns:
+            A string that contains all the reasons why the action cannot be performed.
+        """
+        if self._reasons:
+            return "; ".join("; ".join([str(reason) for reason in reasons]) for reasons in self._reasons.values()) + "."
+        return ""

+ 40 - 14
taipy/core/scenario/_scenario_manager.py

@@ -9,7 +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 datetime import datetime
 from functools import partial
 from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
@@ -39,8 +39,7 @@ from ..exceptions.exceptions import (
 from ..job._job_manager_factory import _JobManagerFactory
 from ..job.job import Job
 from ..notification import EventEntityType, EventOperation, Notifier, _make_event
-from ..reason._reason_factory import _build_not_submittable_entity_reason, _build_wrong_config_type_reason
-from ..reason.reason import Reasons
+from ..reason import EntityIsNotSubmittableEntity, ReasonCollection, WrongConfigType
 from ..submission._submission_manager_factory import _SubmissionManagerFactory
 from ..submission.submission import Submission
 from ..task._task_manager_factory import _TaskManagerFactory
@@ -108,21 +107,21 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
         )
 
     @classmethod
-    def _can_create(cls, config: Optional[ScenarioConfig] = None) -> Reasons:
+    def _can_create(cls, config: Optional[ScenarioConfig] = None) -> ReasonCollection:
         config_id = getattr(config, "id", None) or str(config)
-        reason = Reasons(config_id)
+        reason_collector = ReasonCollection()
 
         if config is not None:
             if not isinstance(config, ScenarioConfig):
-                reason._add_reason(config_id, _build_wrong_config_type_reason(config_id, "ScenarioConfig"))
+                reason_collector._add_reason(config_id, WrongConfigType(config_id, ScenarioConfig.__name__))
 
-        return reason
+        return reason_collector
 
     @classmethod
     def _create(
         cls,
         config: ScenarioConfig,
-        creation_date: Optional[datetime.datetime] = None,
+        creation_date: Optional[datetime] = None,
         name: Optional[str] = None,
     ) -> Scenario:
         _task_manager = _TaskManagerFactory._build_manager()
@@ -202,15 +201,15 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
         return scenario
 
     @classmethod
-    def _is_submittable(cls, scenario: Union[Scenario, ScenarioId]) -> Reasons:
+    def _is_submittable(cls, scenario: Union[Scenario, ScenarioId]) -> ReasonCollection:
         if isinstance(scenario, str):
             scenario = cls._get(scenario)
 
         if not isinstance(scenario, Scenario):
             scenario = str(scenario)
-            reason = Reasons((scenario))
-            reason._add_reason(scenario, _build_not_submittable_entity_reason(scenario))
-            return reason
+            reason_collector = ReasonCollection()
+            reason_collector._add_reason(scenario, EntityIsNotSubmittableEntity(scenario))
+            return reason_collector
 
         return scenario.is_ready_to_run()
 
@@ -288,9 +287,8 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
     def _get_primary_scenarios(cls) -> List[Scenario]:
         return [scenario for scenario in cls._get_all() if scenario.is_primary]
 
-    @classmethod
+    @staticmethod
     def _sort_scenarios(
-        cls,
         scenarios: List[Scenario],
         descending: bool = False,
         sort_key: Literal["name", "id", "config_id", "creation_date", "tags"] = "name",
@@ -306,6 +304,34 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
             scenarios.sort(key=lambda x: (x.name, x.id), reverse=descending)
         return scenarios
 
+    @staticmethod
+    def _filter_by_creation_time(
+        scenarios: List[Scenario],
+        created_start_time: Optional[datetime] = None,
+        created_end_time: Optional[datetime] = None,
+    ) -> List[Scenario]:
+        """
+        Filter a list of scenarios by a given creation time period.
+        The time period is inclusive.
+
+        Parameters:
+            created_start_time (Optional[datetime]): Start time of the period.
+            created_end_time (Optional[datetime]): End time of the period.
+
+        Returns:
+            List[Scenario]: List of scenarios created in the given time period.
+        """
+        if not created_start_time and not created_end_time:
+            return scenarios
+
+        if not created_start_time:
+            return [scenario for scenario in scenarios if scenario.creation_date <= created_end_time]
+
+        if not created_end_time:
+            return [scenario for scenario in scenarios if created_start_time <= scenario.creation_date]
+
+        return [scenario for scenario in scenarios if created_start_time <= scenario.creation_date <= created_end_time]
+
     @classmethod
     def _is_promotable_to_primary(cls, scenario: Union[Scenario, ScenarioId]) -> bool:
         if isinstance(scenario, str):

+ 6 - 3
taipy/core/scenario/scenario.py

@@ -50,9 +50,12 @@ from .scenario_id import ScenarioId
 class Scenario(_Entity, Submittable, _Labeled):
     """Instance of a Business case to solve.
 
-    A scenario holds a set of tasks (instances of `Task^` class) to submit for execution in order to
-    solve the Business case. It also holds a set of additional data nodes (instances of `DataNode` class)
-    for extra data related to the scenario.
+    A scenario holds a set of tasks (instances of `Task^` class) to submit for execution in order
+    to solve the Business case. Each task can be connected to some data nodes as input or output
+    forming an execution graph. The scenario can be submitted for execution, and the tasks will
+    be orchestrated to solve the Business case.<br>
+    A scenario also holds a set of additional data nodes (instances of `DataNode^` class) for
+    extra data related to the scenario.
 
     !!! note
 

+ 5 - 6
taipy/core/sequence/_sequence_manager.py

@@ -29,8 +29,7 @@ from ..job._job_manager_factory import _JobManagerFactory
 from ..job.job import Job
 from ..notification import Event, EventEntityType, EventOperation, Notifier
 from ..notification.event import _make_event
-from ..reason._reason_factory import _build_not_submittable_entity_reason
-from ..reason.reason import Reasons
+from ..reason import EntityIsNotSubmittableEntity, ReasonCollection
 from ..scenario._scenario_manager_factory import _ScenarioManagerFactory
 from ..scenario.scenario import Scenario
 from ..scenario.scenario_id import ScenarioId
@@ -342,15 +341,15 @@ class _SequenceManager(_Manager[Sequence], _VersionMixin):
         Notifier.publish(_make_event(sequence, EventOperation.UPDATE, attribute_name="subscribers"))
 
     @classmethod
-    def _is_submittable(cls, sequence: Union[Sequence, SequenceId]) -> Reasons:
+    def _is_submittable(cls, sequence: Union[Sequence, SequenceId]) -> ReasonCollection:
         if isinstance(sequence, str):
             sequence = cls._get(sequence)
 
         if not isinstance(sequence, Sequence):
             sequence = str(sequence)
-            reason = Reasons(sequence)
-            reason._add_reason(sequence, _build_not_submittable_entity_reason(sequence))
-            return reason
+            reason_collector = ReasonCollection()
+            reason_collector._add_reason(sequence, EntityIsNotSubmittableEntity(sequence))
+            return reason_collector
 
         return sequence.is_ready_to_run()
 

+ 17 - 5
taipy/core/taipy.py

@@ -45,8 +45,7 @@ from .exceptions.exceptions import (
 from .job._job_manager_factory import _JobManagerFactory
 from .job.job import Job
 from .job.job_id import JobId
-from .reason._reason_factory import _build_not_submittable_entity_reason
-from .reason.reason import Reasons
+from .reason import EntityIsNotSubmittableEntity, ReasonCollection
 from .scenario._scenario_manager_factory import _ScenarioManagerFactory
 from .scenario.scenario import Scenario
 from .scenario.scenario_id import ScenarioId
@@ -85,7 +84,7 @@ def set(entity: Union[DataNode, Task, Sequence, Scenario, Cycle, Submission]):
         return _SubmissionManagerFactory._build_manager()._set(entity)
 
 
-def is_submittable(entity: Union[Scenario, ScenarioId, Sequence, SequenceId, Task, TaskId, str]) -> Reasons:
+def is_submittable(entity: Union[Scenario, ScenarioId, Sequence, SequenceId, Task, TaskId, str]) -> ReasonCollection:
     """Indicate if an entity can be submitted.
 
     This function checks if the given entity can be submitted for execution.
@@ -105,7 +104,7 @@ def is_submittable(entity: Union[Scenario, ScenarioId, Sequence, SequenceId, Tas
         return _TaskManagerFactory._build_manager()._is_submittable(entity)
     if isinstance(entity, str) and entity.startswith(Task._ID_PREFIX):
         return _TaskManagerFactory._build_manager()._is_submittable(TaskId(entity))
-    return Reasons(str(entity))._add_reason(str(entity), _build_not_submittable_entity_reason(str(entity)))
+    return ReasonCollection()._add_reason(str(entity), EntityIsNotSubmittableEntity(str(entity)))
 
 
 def is_editable(
@@ -510,6 +509,8 @@ def get_scenarios(
     tag: Optional[str] = None,
     is_sorted: bool = False,
     descending: bool = False,
+    created_start_time: Optional[datetime] = None,
+    created_end_time: Optional[datetime] = None,
     sort_key: Literal["name", "id", "config_id", "creation_date", "tags"] = "name",
 ) -> List[Scenario]:
     """Retrieve a list of existing scenarios filtered by cycle or tag.
@@ -526,6 +527,8 @@ def get_scenarios(
             The default value is False.
         descending (bool): If True, sort the output list of scenarios in descending order.
             The default value is False.
+        created_start_time (Optional[datetime]): The optional inclusive start date to filter scenarios by creation date.
+        created_end_time (Optional[datetime]): The optional inclusive end date to filter scenarios by creation date.
         sort_key (Literal["name", "id", "creation_date", "tags"]): The optional sort_key to
             decide upon what key scenarios are sorted. The sorting is in increasing order for
             dates, in alphabetical order for name and id, and in lexicographical order for tags.
@@ -548,6 +551,8 @@ def get_scenarios(
     else:
         scenarios = []
 
+    if created_start_time or created_end_time:
+        scenarios = scenario_manager._filter_by_creation_time(scenarios, created_start_time, created_end_time)
     if is_sorted:
         scenario_manager._sort_scenarios(scenarios, descending, sort_key)
     return scenarios
@@ -569,6 +574,8 @@ def get_primary(cycle: Cycle) -> Optional[Scenario]:
 def get_primary_scenarios(
     is_sorted: bool = False,
     descending: bool = False,
+    created_start_time: Optional[datetime] = None,
+    created_end_time: Optional[datetime] = None,
     sort_key: Literal["name", "id", "config_id", "creation_date", "tags"] = "name",
 ) -> List[Scenario]:
     """Retrieve a list of all primary scenarios.
@@ -578,6 +585,8 @@ def get_primary_scenarios(
             The default value is False.
         descending (bool): If True, sort the output list of scenarios in descending order.
             The default value is False.
+        created_start_time (Optional[datetime]): The optional inclusive start date to filter scenarios by creation date.
+        created_end_time (Optional[datetime]): The optional inclusive end date to filter scenarios by creation date.
         sort_key (Literal["name", "id", "creation_date", "tags"]): The optional sort_key to
             decide upon what key scenarios are sorted. The sorting is in increasing order for
             dates, in alphabetical order for name and id, and in lexicographical order for tags.
@@ -589,6 +598,9 @@ def get_primary_scenarios(
     """
     scenario_manager = _ScenarioManagerFactory._build_manager()
     scenarios = scenario_manager._get_primary_scenarios()
+
+    if created_start_time or created_end_time:
+        scenarios = scenario_manager._filter_by_creation_time(scenarios, created_start_time, created_end_time)
     if is_sorted:
         scenario_manager._sort_scenarios(scenarios, descending, sort_key)
     return scenarios
@@ -867,7 +879,7 @@ def get_cycles() -> List[Cycle]:
     return _CycleManagerFactory._build_manager()._get_all()
 
 
-def can_create(config: Optional[Union[ScenarioConfig, DataNodeConfig]] = None) -> Reasons:
+def can_create(config: Optional[Union[ScenarioConfig, DataNodeConfig]] = None) -> ReasonCollection:
     """Indicate if a config can be created. The config should be a scenario or data node config.
 
     If no config is provided, the function indicates if any scenario or data node config can be created.

+ 8 - 13
taipy/core/task/_task_manager.py

@@ -26,12 +26,7 @@ from ..cycle.cycle_id import CycleId
 from ..data._data_manager_factory import _DataManagerFactory
 from ..exceptions.exceptions import NonExistingTask
 from ..notification import EventEntityType, EventOperation, Notifier, _make_event
-from ..reason._reason_factory import (
-    _build_data_node_is_being_edited_reason,
-    _build_data_node_is_not_written,
-    _build_not_submittable_entity_reason,
-)
-from ..reason.reason import Reasons
+from ..reason import DataNodeEditInProgress, DataNodeIsNotWritten, EntityIsNotSubmittableEntity, ReasonCollection
 from ..scenario.scenario_id import ScenarioId
 from ..sequence.sequence_id import SequenceId
 from ..submission.submission import Submission
@@ -169,24 +164,24 @@ class _TaskManager(_Manager[Task], _VersionMixin):
         return entity_ids
 
     @classmethod
-    def _is_submittable(cls, task: Union[Task, TaskId]) -> Reasons:
+    def _is_submittable(cls, task: Union[Task, TaskId]) -> ReasonCollection:
         if isinstance(task, str):
             task = cls._get(task)
+
+        reason_collection = ReasonCollection()
         if not isinstance(task, Task):
             task = str(task)
-            reason = Reasons(task)
-            reason._add_reason(task, _build_not_submittable_entity_reason(task))
+            reason_collection._add_reason(task, EntityIsNotSubmittableEntity(task))
         else:
-            reason = Reasons(task.id)
             data_manager = _DataManagerFactory._build_manager()
             for node in task.input.values():
                 node = data_manager._get(node)
                 if node._edit_in_progress:
-                    reason._add_reason(node.id, _build_data_node_is_being_edited_reason(node.id))
+                    reason_collection._add_reason(node.id, DataNodeEditInProgress(node.id))
                 if not node._last_edit_date:
-                    reason._add_reason(node.id, _build_data_node_is_not_written(node.id))
+                    reason_collection._add_reason(node.id, DataNodeIsNotWritten(node.id))
 
-        return reason
+        return reason_collection
 
     @classmethod
     def _submit(

+ 14 - 3
taipy/gui/_renderers/builder.py

@@ -159,7 +159,7 @@ class _Builder:
             if hashname is None:
                 if callable(v):
                     if v.__name__ == "<lambda>":
-                        hashname = _get_expr_var_name(v.__code__)
+                        hashname = f"__lambda_{id(v)}"
                         gui._bind_var_val(hashname, v)
                     else:
                         hashname = _get_expr_var_name(v.__name__)
@@ -312,6 +312,15 @@ class _Builder:
             return self
         return self.set_attribute(_to_camel_case(name), str(strattr))
 
+    def __set_dynamic_date_attribute(self, var_name: str, default_value: t.Optional[str] = None):
+        date_attr = self.__attributes.get(var_name, default_value)
+        if date_attr is None:
+            date_attr = default_value
+        if isinstance(date_attr, (datetime, date, time)):
+            value = _date_to_string(date_attr)
+            self.set_attribute(_to_camel_case(var_name), value)
+        return self
+
     def __set_dynamic_string_attribute(
         self,
         name: str,
@@ -395,7 +404,7 @@ class _Builder:
                 adapter = self.__gui._get_adapter_for_type(var_type)
             elif var_type == str.__name__ and callable(adapter):
                 var_type += (
-                    _get_expr_var_name(str(adapter.__code__))
+                    f"__lambda_{id(adapter)}"
                     if adapter.__name__ == "<lambda>"
                     else _get_expr_var_name(adapter.__name__)
                 )
@@ -879,7 +888,7 @@ class _Builder:
     def _set_input_type(self, type_name: str, allow_password=False):
         if allow_password and self.__get_boolean_attribute("password", False):
             return self.set_attribute("type", "password")
-        return self.set_attribute("type", type_name)
+        return self.set_attribute("type", self.__attributes.get("type", type_name))
 
     def _set_kind(self):
         if self.__attributes.get("theme", False):
@@ -1012,6 +1021,8 @@ class _Builder:
                     self.__set_dynamic_bool_attribute(attr[0], _get_tuple_val(attr, 2, False), True, update_main=False)
                 else:
                     self.__set_dynamic_string_list(attr[0], _get_tuple_val(attr, 2, None))
+            elif var_type == PropertyType.dynamic_date:
+                self.__set_dynamic_date_attribute(attr[0], _get_tuple_val(attr, 2, None))
             elif var_type == PropertyType.data:
                 self.__set_dynamic_property_without_default(attr[0], var_type)
             elif var_type == PropertyType.lov or var_type == PropertyType.single_lov:

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

@@ -138,6 +138,8 @@ class _Factory:
             [
                 ("with_time", PropertyType.boolean),
                 ("active", PropertyType.dynamic_boolean, True),
+                ("min", PropertyType.dynamic_date),
+                ("max", PropertyType.dynamic_date),
                 ("editable", PropertyType.dynamic_boolean, True),
                 ("hover_text", PropertyType.dynamic_string),
                 ("label",),
@@ -386,6 +388,10 @@ class _Factory:
         .set_attributes(
             [
                 ("active", PropertyType.dynamic_boolean, True),
+                ("step", PropertyType.dynamic_number, 1),
+                ("step_multiplier", PropertyType.dynamic_number, 10),
+                ("min", PropertyType.dynamic_number),
+                ("max", PropertyType.dynamic_number),
                 ("hover_text", PropertyType.dynamic_string),
                 ("on_change", PropertyType.function),
                 ("on_action", PropertyType.function),

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

@@ -120,7 +120,6 @@ class _ElementApiGenerator(object, metaclass=_Singleton):
             classname,
             (ElementBaseClass,),
             {
-                "__init__": lambda self, *args, **kwargs: ElementBaseClass.__init__(self, *args, **kwargs),
                 "_ELEMENT_NAME": element_name,
                 "_DEFAULT_PROPERTY": default_property,
             },

+ 69 - 6
taipy/gui/builder/_element.py

@@ -11,14 +11,26 @@
 
 from __future__ import annotations
 
+import ast
 import copy
+import inspect
+import io
 import re
+import sys
 import typing as t
+import uuid
 from abc import ABC, abstractmethod
 from collections.abc import Iterable
+from types import FrameType, FunctionType
 
+from .._warnings import _warn
+from ..utils import _getscopeattr
+
+if sys.version_info < (3, 9):
+    from ..utils.unparse import _Unparser
 from ._context_manager import _BuilderContextManager
 from ._factory import _BuilderFactory
+from ._utils import _LambdaByName, _python_builtins, _TransformVarToValue
 
 if t.TYPE_CHECKING:
     from ..gui import Gui
@@ -30,6 +42,7 @@ class _Element(ABC):
     _ELEMENT_NAME = ""
     _DEFAULT_PROPERTY = ""
     __RE_INDEXED_PROPERTY = re.compile(r"^(.*?)__([\w\d]+)$")
+    _NEW_LAMBDA_NAME = "new_lambda"
 
     def __new__(cls, *args, **kwargs):
         obj = super(_Element, cls).__new__(cls)
@@ -40,6 +53,11 @@ class _Element(ABC):
 
     def __init__(self, *args, **kwargs) -> None:
         self._properties: t.Dict[str, t.Any] = {}
+        self._lambdas: t.Dict[str, str] = {}
+        self.__calling_frame = t.cast(
+            FrameType, t.cast(FrameType, t.cast(FrameType, inspect.currentframe()).f_back).f_back
+        )
+
         if args and self._DEFAULT_PROPERTY != "":
             self._properties = {self._DEFAULT_PROPERTY: args[0]}
         self._properties.update(kwargs)
@@ -49,10 +67,15 @@ class _Element(ABC):
         self._properties.update(kwargs)
         self.parse_properties()
 
-    # Convert property value to string
+    def _evaluate_lambdas(self, gui: Gui):
+        for k, lmbd in self._lambdas.items():
+            expr = gui._evaluate_expr(f"{{{lmbd}}}")
+            gui._bind_var_val(k, _getscopeattr(gui, expr))
+
+    # Convert property value to string/function
     def parse_properties(self):
         self._properties = {
-            _Element._parse_property_key(k): _Element._parse_property(v) for k, v in self._properties.items()
+            _Element._parse_property_key(k): self._parse_property(k, v) for k, v in self._properties.items()
         }
 
     # Get a deepcopy version of the properties
@@ -65,10 +88,47 @@ class _Element(ABC):
             return f"{match.group(1)}[{match.group(2)}]"
         return key
 
-    @staticmethod
-    def _parse_property(value: t.Any) -> t.Any:
+    def _parse_property(self, key: str, value: t.Any) -> t.Any:
         if isinstance(value, (str, dict, Iterable)):
             return value
+        if isinstance(value, FunctionType):
+            if key.startswith("on_"):
+                if value.__name__.startswith("<"):
+                    return value
+                return value.__name__
+
+            try:
+                st = ast.parse(inspect.getsource(value.__code__).strip())
+                lambda_by_name: t.Dict[str, ast.Lambda] = {}
+                _LambdaByName(self._ELEMENT_NAME, lambda_by_name).visit(st)
+                lambda_fn = lambda_by_name.get(
+                    key,
+                    lambda_by_name.get(_LambdaByName._DEFAULT_NAME, None) if key == self._DEFAULT_PROPERTY else None,
+                )
+                if lambda_fn is not None:
+                    args = [arg.arg for arg in lambda_fn.args.args]
+                    targets = [
+                        compr.target.id  # type: ignore[attr-defined]
+                        for node in ast.walk(lambda_fn.body)
+                        if isinstance(node, ast.ListComp)
+                        for compr in node.generators
+                    ]
+                    tree = _TransformVarToValue(self.__calling_frame, args + targets + _python_builtins).visit(
+                        lambda_fn
+                    )
+                    ast.fix_missing_locations(tree)
+                    if sys.version_info < (3, 9):  # python 3.8 ast has no unparse
+                        string_fd = io.StringIO()
+                        _Unparser(tree, string_fd)
+                        string_fd.seek(0)
+                        lambda_text = string_fd.read()
+                    else:
+                        lambda_text = ast.unparse(tree)
+                    lambda_name = f"__lambda_{uuid.uuid4().hex}"
+                    self._lambdas[lambda_name] = lambda_text
+                    return f'{{{lambda_name}({", ".join(args)})}}'
+            except Exception as e:
+                _warn("Error in lambda expression", e)
         if hasattr(value, "__name__"):
             return str(getattr(value, "__name__"))  # noqa: B009
         return str(value)
@@ -99,6 +159,7 @@ class _Block(_Element):
         _BuilderContextManager().pop()
 
     def _render(self, gui: "Gui") -> str:
+        self._evaluate_lambdas(gui)
         el = _BuilderFactory.create_element(gui, self._ELEMENT_NAME, self._deepcopy_properties())
         return f"{el[0]}{self._render_children(gui)}</{el[1]}>"
 
@@ -109,7 +170,7 @@ class _Block(_Element):
 class _DefaultBlock(_Block):
     _ELEMENT_NAME = "part"
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs):  # do not remove as it could break the search in frames
         super().__init__(*args, **kwargs)
 
 
@@ -162,6 +223,7 @@ class html(_Block):
         self._content = args[1] if len(args) > 1 else ""
 
     def _render(self, gui: "Gui") -> str:
+        self._evaluate_lambdas(gui)
         if self._ELEMENT_NAME:
             attrs = ""
             if self._properties:
@@ -174,10 +236,11 @@ class html(_Block):
 class _Control(_Element):
     """NOT DOCUMENTED"""
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs):  # do not remove as it could break the search in frames
         super().__init__(*args, **kwargs)
 
     def _render(self, gui: "Gui") -> str:
+        self._evaluate_lambdas(gui)
         el = _BuilderFactory.create_element(gui, self._ELEMENT_NAME, self._deepcopy_properties())
         return (
             f"<div>{el[0]}</{el[1]}></div>"

+ 65 - 0
taipy/gui/builder/_utils.py

@@ -0,0 +1,65 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+from __future__ import annotations
+
+import ast
+import builtins
+import typing as t
+from operator import attrgetter
+from types import FrameType
+
+_python_builtins = dir(builtins)
+
+
+def _get_value_in_frame(frame: FrameType, name: str):
+    if not frame:
+        return None
+    if name in frame.f_locals:
+        return frame.f_locals.get(name)
+    return _get_value_in_frame(t.cast(FrameType, frame.f_back), name)
+
+
+class _TransformVarToValue(ast.NodeTransformer):
+    def __init__(self, frame: FrameType, non_vars: t.List[str]) -> None:
+        super().__init__()
+        self.frame = frame
+        self.non_vars = non_vars
+
+    def visit_Name(self, node):
+        var_parts = node.id.split(".", 2)
+        if var_parts[0] in self.non_vars:
+            return node
+        value = _get_value_in_frame(self.frame, var_parts[0])
+        if callable(value):
+            return node
+        if len(var_parts) > 1:
+            value = attrgetter(var_parts[1])(value)
+        return ast.Constant(value=value, kind=None)
+
+
+class _LambdaByName(ast.NodeVisitor):
+    _DEFAULT_NAME = "<default>"
+
+    def __init__(self, element_name: str, lambdas: t.Dict[str, ast.Lambda]) -> None:
+        super().__init__()
+        self.element_name = element_name
+        self.lambdas = lambdas
+
+    def visit_Call(self, node):
+        if node.func.attr == self.element_name:
+            if self.lambdas.get(_LambdaByName._DEFAULT_NAME, None) is None:
+                self.lambdas[_LambdaByName._DEFAULT_NAME] = next(
+                    (arg for arg in node.args if isinstance(arg, ast.Lambda)), None
+                )
+            for kwd in node.keywords:
+                if isinstance(kwd.value, ast.Lambda):
+                    self.lambdas[kwd.arg] = kwd.value

+ 129 - 45
taipy/gui/gui.py

@@ -49,7 +49,7 @@ import __main__  # noqa: F401
 from taipy.logger._taipy_logger import _TaipyLogger
 
 if util.find_spec("pyngrok"):
-    from pyngrok import ngrok
+    from pyngrok import ngrok  # type: ignore[reportMissingImports]
 
 from ._default_config import _default_stylekit, default_config
 from ._page import _Page
@@ -320,7 +320,7 @@ class Gui:
         self.__locals_context = _LocalsContext()
         self.__var_dir = _VariableDirectory(self.__locals_context)
 
-        self.__evaluator: _Evaluator = None  # type: ignore
+        self.__evaluator: _Evaluator = None  # type: ignore[assignment]
         self.__adapter = _Adapter()
         self.__directory_name_of_pages: t.List[str] = []
         self.__favicon: t.Optional[t.Union[str, Path]] = None
@@ -437,7 +437,7 @@ class Gui:
             if provider_fn is None:
                 # try plotly
                 if find_spec("plotly") and find_spec("plotly.graph_objs"):
-                    from plotly.graph_objs import Figure as PlotlyFigure
+                    from plotly.graph_objs import Figure as PlotlyFigure  # type: ignore[reportMissingImports]
 
                     if isinstance(content, PlotlyFigure):
 
@@ -509,6 +509,10 @@ class Gui:
     def _get_shared_variables(self) -> t.List[str]:
         return self.__evaluator.get_shared_variables()
 
+    @staticmethod
+    def _clear_shared_variable() -> None:
+        Gui.__shared_variables.clear()
+
     def __get_content_accessor(self):
         if self.__content_accessor is None:
             self.__content_accessor = _ContentAccessor(self._get_config("data_url_max_size", 50 * 1024))
@@ -1129,7 +1133,8 @@ class Gui:
                     {
                         "type": _WsType.GET_MODULE_CONTEXT.value,
                         "payload": {"context": mc, "metadata": meta_return},
-                    }
+                    },
+                    send_back_only=True,
                 )
 
     def __get_variable_tree(self, data: t.Dict[str, t.Any]):
@@ -1173,7 +1178,8 @@ class Gui:
                     "variable": self.__get_variable_tree(data),
                     "function": self.__get_variable_tree(function_data),
                 },
-            }
+            },
+            send_back_only=True,
         )
 
     def __handle_ws_app_id(self, message: t.Any):
@@ -1188,7 +1194,8 @@ class Gui:
             {
                 "type": _WsType.APP_ID.value,
                 "payload": {"name": name, "id": app_id},
-            }
+            },
+            send_back_only=True,
         )
 
     def __handle_ws_get_routes(self):
@@ -1206,7 +1213,8 @@ class Gui:
             {
                 "type": _WsType.GET_ROUTES.value,
                 "payload": routes,
-            }
+            },
+            send_back_only=True,
         )
 
     def __send_ws(self, payload: dict, allow_grouping=True, send_back_only=False) -> None:
@@ -1406,7 +1414,7 @@ class Gui:
             try:
                 fd, temp_path = mkstemp(".csv", var_name, text=True)
                 with os.fdopen(fd, "wt", newline="") as csv_file:
-                    df.to_csv(csv_file, index=False)  # type:ignore
+                    df.to_csv(csv_file, index=False)  # type: ignore[union-attr]
                 self._download(temp_path, "data.csv", Gui.__DOWNLOAD_DELETE_ACTION)
             except Exception as e:  # pragma: no cover
                 if not self._call_on_exception("download_csv", e):
@@ -1467,60 +1475,135 @@ class Gui:
                     _warn(f"on_action(): Exception raised in '{action_function.__name__}()'", e)
         return False
 
-    def _call_function_with_state(self, user_function: t.Callable, args: t.List[t.Any]) -> t.Any:
-        args.insert(0, self.__get_state())
+    def _call_function_with_state(self, user_function: t.Callable, args: t.Optional[t.List[t.Any]] = None) -> t.Any:
+        cp_args = [] if args is None else args.copy()
+        cp_args.insert(0, self.__get_state())
         argcount = user_function.__code__.co_argcount
         if argcount > 0 and inspect.ismethod(user_function):
             argcount -= 1
-        if argcount > len(args):
-            args += (argcount - len(args)) * [None]
+        if argcount > len(cp_args):
+            cp_args += (argcount - len(cp_args)) * [None]
         else:
-            args = args[:argcount]
-        return user_function(*args)
+            cp_args = cp_args[:argcount]
+        return user_function(*cp_args)
 
     def _set_module_context(self, module_context: t.Optional[str]) -> t.ContextManager[None]:
         return self._set_locals_context(module_context) if module_context is not None else contextlib.nullcontext()
 
-    def _call_user_callback(
+    def invoke_callback(
         self,
-        state_id: t.Optional[str],
-        user_callback: t.Union[t.Callable, str],
-        args: t.List[t.Any],
-        module_context: t.Optional[str],
+        state_id: str,
+        callback: t.Callable,
+        args: t.Optional[t.Sequence[t.Any]] = None,
+        module_context: t.Optional[str] = None,
     ) -> t.Any:
+        """Invoke a user callback for a given state.
+
+        See the
+        [section on Long Running Callbacks in a Thread](../gui/callbacks.md#long-running-callbacks-in-a-thread)
+        in the User Manual for details on when and how this function can be used.
+
+        Arguments:
+            state_id: The identifier of the state to use, as returned by `get_state_id()^`.
+            callback (Callable[[State^, ...], None]): The user-defined function that is invoked.<br/>
+                The first parameter of this function **must** be a `State^`.
+            args (Optional[Sequence]): The remaining arguments, as a List or a Tuple.
+            module_context (Optional[str]): the name of the module that will be used.
+        """
+        this_sid = None
+        if request:
+            # avoid messing with the client_id => Set(ws id)
+            this_sid = getattr(request, "sid", None)
+            request.sid = None  # type: ignore[attr-defined]
         try:
             with self.get_flask_app().app_context():
-                self.__set_client_id_in_context(state_id)
+                setattr(g, Gui.__ARG_CLIENT_ID, state_id)
                 with self._set_module_context(module_context):
-                    if not callable(user_callback):
-                        user_callback = self._get_user_function(user_callback)
-                    if not callable(user_callback):
-                        _warn(f"invoke_callback(): {user_callback} is not callable.")
+                    if not callable(callback):
+                        callback = self._get_user_function(callback)
+                    if not callable(callback):
+                        _warn(f"invoke_callback(): {callback} is not callable.")
                         return None
-                    return self._call_function_with_state(user_callback, args)
+                    return self._call_function_with_state(callback, list(args) if args else None)
         except Exception as e:  # pragma: no cover
-            if not self._call_on_exception(user_callback.__name__ if callable(user_callback) else user_callback, e):
+            if not self._call_on_exception(callback.__name__ if callable(callback) else callback, e):
                 _warn(
-                    "invoke_callback(): Exception raised in "
-                    + f"'{user_callback.__name__ if callable(user_callback) else user_callback}()'",
+                    "Gui.invoke_callback(): Exception raised in "
+                    + f"'{callback.__name__ if callable(callback) else callback}()'",
                     e,
                 )
+        finally:
+            if this_sid and request:
+                request.sid = this_sid  # type: ignore[attr-defined]
         return None
 
-    def _call_broadcast_callback(
-        self, user_callback: t.Callable, args: t.List[t.Any], module_context: t.Optional[str]
-    ) -> t.Any:
-        @contextlib.contextmanager
-        def _broadcast_callback() -> t.Iterator[None]:
-            try:
-                setattr(g, Gui.__BRDCST_CALLBACK_G_ID, True)
-                yield
-            finally:
-                setattr(g, Gui.__BRDCST_CALLBACK_G_ID, False)
+    def broadcast_callback(
+        self,
+        callback: t.Callable,
+        args: t.Optional[t.Sequence[t.Any]] = None,
+        module_context: t.Optional[str] = None,
+    ) -> t.Dict[str, t.Any]:
+        """Invoke a callback for every client.
 
-        with _broadcast_callback():
-            # Use global scopes for broadcast callbacks
-            return self._call_user_callback(_DataScopes._GLOBAL_ID, user_callback, args, module_context)
+        This callback gets invoked for every client connected to the application with the appropriate
+        `State^` instance. You can then perform client-specific tasks, such as updating the state
+        variable reflected in the user interface.
+
+        Arguments:
+            gui (Gui^): The current Gui instance.
+            callback: The user-defined function to be invoked.<br/>
+                The first parameter of this function must be a `State^` object representing the
+                client for which it is invoked.<br/>
+                The other parameters should reflect the ones provided in the *args* collection.
+            args: The parameters to send to *callback*, if any.
+        """
+        # Iterate over all the scopes
+        res = {}
+        for id in [id for id in self.__bindings._get_all_scopes() if id != _DataScopes._GLOBAL_ID]:
+            ret = self.invoke_callback(id, callback, args, module_context)
+            res[id] = ret
+        return res
+
+    def broadcast_change(self, var_name: str, value: t.Any):
+        """Propagates a new value for a given variable to all states.
+
+        This callback gets invoked for every client connected to the application to update the value
+        of the variable called *var_name* to the new value *value*, in their specific `State^`
+        instance. All user interfaces reflect the change.
+
+        Arguments:
+            gui (Gui^): The current Gui instance.
+            var_name: The name of the variable to change.
+            value: The new value for the variable.
+        """
+        self.broadcast_callback(lambda s, n, v: s.assign(n, v), [var_name, value])
+
+    @staticmethod
+    def __broadcast_changes_fn(state: State, values: dict[str, t.Any]) -> None:
+        with state:
+            for n, v in values.items():
+                state.assign(n, v)
+
+    def broadcast_changes(self, values: t.Optional[dict[str, t.Any]] = None, **kwargs):
+        """Propagates new values for several variables to all states.
+
+        This callback gets invoked for every client connected to the application to update the value
+        of all the variables that are keys in *values*, in their specific `State^` instance. All
+        user interfaces reflect the change.
+
+        Arguments:
+            values: An optional dictionary where each key is the name of a variable to change, and
+                where the associated value is the new value to set for that variable, in each state
+                for the application.
+            **kwargs: A collection of variable name-value pairs that are updated for each state of
+                the application. Name-value pairs overload the ones in *values* if the name exists
+                as a key in the dictionary.
+        """
+        if kwargs:
+            values = values.copy() if values else {}
+            for n, v in kwargs.items():
+                values[n] = v
+        self.broadcast_callback(Gui.__broadcast_changes_fn, [values])
 
     def _is_in_brdcst_callback(self):
         try:
@@ -2094,7 +2177,7 @@ class Gui:
                 if not isinstance(lib, ElementLibrary):
                     continue
                 try:
-                    self._call_function_with_state(lib.on_user_init, [])
+                    self._call_function_with_state(lib.on_user_init)
                 except Exception as e:  # pragma: no cover
                     if not self._call_on_exception(f"{name}.on_user_init", e):
                         _warn(f"Exception raised in {name}.on_user_init()", e)
@@ -2107,7 +2190,7 @@ class Gui:
             self.__init_libs()
             if hasattr(self, "on_init") and callable(self.on_init):
                 try:
-                    self._call_function_with_state(self.on_init, [])
+                    self._call_function_with_state(self.on_init)
                 except Exception as e:  # pragma: no cover
                     if not self._call_on_exception("on_init", e):
                         _warn("Exception raised in on_init()", e)
@@ -2298,7 +2381,7 @@ class Gui:
             config["extensions"] = {}
             for libs in self.__extensions.values():
                 for lib in libs:
-                    config["extensions"][f"./{Gui._EXTENSION_ROOT}/{lib.get_js_module_name()}"] = [  # type: ignore
+                    config["extensions"][f"./{Gui._EXTENSION_ROOT}/{lib.get_js_module_name()}"] = [
                         e._get_js_name(n)
                         for n, e in lib.get_elements().items()
                         if isinstance(e, Element) and not e._is_server_only()
@@ -2553,8 +2636,9 @@ class Gui:
 
         # Base global ctx is TaipyHolder classes + script modules and callables
         glob_ctx: t.Dict[str, t.Any] = {t.__name__: t for t in _TaipyBase.__subclasses__()}
-        glob_ctx.update({k: v for k, v in locals_bind.items() if inspect.ismodule(v) or callable(v)})
         glob_ctx[Gui.__SELF_VAR] = self
+        glob_ctx["state"] = self.__state
+        glob_ctx.update({k: v for k, v in locals_bind.items() if inspect.ismodule(v) or callable(v)})
 
         # Call on_init on each library
         for name, libs in self.__extensions.items():

+ 14 - 18
taipy/gui/gui_actions.py

@@ -267,39 +267,37 @@ def invoke_callback(
     gui: Gui,
     state_id: str,
     callback: t.Callable,
-    args: t.Union[t.Tuple, t.List],
+    args: t.Optional[t.Sequence[t.Any]] = None,
     module_context: t.Optional[str] = None,
 ) -> t.Any:
     """Invoke a user callback for a given state.
 
-    See the
-    [User Manual section on Long Running Callbacks in a Thread](../gui/callbacks.md#long-running-callbacks-in-a-thread)
-    for details on when and how this function can be used.
+    Calling this function is equivalent to calling
+    *gui*.[Gui.]invoke_callback(state_id, callback, args, module_context)^`.
 
     Arguments:
         gui (Gui^): The current Gui instance.
         state_id: The identifier of the state to use, as returned by `get_state_id()^`.
         callback (Callable[[State^, ...], None]): The user-defined function that is invoked.<br/>
             The first parameter of this function **must** be a `State^`.
-        args (Union[Tuple, List]): The remaining arguments, as a List or a Tuple.
+        args (Optional[Sequence]): The remaining arguments, as a List or a Tuple.
         module_context (Optional[str]): the name of the module that will be used.
     """
     if isinstance(gui, Gui):
-        return gui._call_user_callback(state_id, callback, list(args), module_context)
+        return gui.invoke_callback(state_id, callback, args, module_context)
     _warn("'invoke_callback()' must be called with a valid Gui instance.")
 
 
 def broadcast_callback(
     gui: Gui,
     callback: t.Callable,
-    args: t.Optional[t.Union[t.Tuple, t.List]] = None,
+    args: t.Optional[t.Sequence[t.Any]] = None,
     module_context: t.Optional[str] = None,
-) -> t.Any:
+) -> t.Dict[str, t.Any]:
     """Invoke a callback for every client.
 
-    This callback gets invoked for every client connected to the application with the appropriate
-    `State^` instance. You can then perform client-specific tasks, such as updating the state
-    variable reflected in the user interface.
+    Calling this function is equivalent to calling
+    *gui*.[Gui.]broadcast_callback(callback, args, module_context)^`.
 
     Arguments:
         gui (Gui^): The current Gui instance.
@@ -310,13 +308,13 @@ def broadcast_callback(
         args: The parameters to send to *callback*, if any.
     """
     if isinstance(gui, Gui):
-        return gui._call_broadcast_callback(callback, list(args) if args else [], module_context)
+        return gui.broadcast_callback(callback, args, module_context)
     _warn("'broadcast_callback()' must be called with a valid Gui instance.")
 
 
 def invoke_state_callback(gui: Gui, state_id: str, callback: t.Callable, args: t.Union[t.Tuple, t.List]) -> t.Any:
-    _warn("'invoke_state_callback()' was deprecated in Taipy GUI 2.0. Use 'invoke_callback()' instead.")
-    return invoke_callback(gui, state_id, callback, args)
+    _warn("'invoke_state_callback()' was deprecated in Taipy GUI 2.0. Use 'Gui.invoke_callback()' instead.")
+    return gui.invoke_callback(state_id, callback, args)
 
 
 def invoke_long_callback(
@@ -393,16 +391,14 @@ def invoke_long_callback(
         function_result: t.Optional[t.Any] = None,
     ):
         if callable(user_status_function):
-            invoke_callback(
-                this_gui,
+            this_gui.invoke_callback(
                 str(state_id),
                 user_status_function,
                 [status] + list(user_status_function_args) + [function_result],  # type: ignore
                 str(module_context),
             )
         if e:
-            invoke_callback(
-                this_gui,
+            this_gui.invoke_callback(
                 str(state_id),
                 callback_on_exception,
                 (

+ 1 - 1
taipy/gui/server.py

@@ -169,7 +169,7 @@ class _Server:
                     return render_template(
                         "index.html",
                         title=title,
-                        favicon=favicon,
+                        favicon=f"{favicon}?version={version}",
                         root_margin=root_margin,
                         watermark=watermark,
                         config=client_config,

+ 12 - 5
taipy/gui/types.py

@@ -82,26 +82,33 @@ class PropertyType(Enum):
     """
     The property holds a dictionary.
     """
+    dynamic_date = "dynamicdate"
+    """
+    The property is dynamic and holds a date.
+    """
     dynamic_dict = _TaipyDict
     """
-    The property holds a dynamic dictionary.
+    The property is dynamic and holds a dictionary.
     """
     dynamic_number = _TaipyNumber
     """
-    The property holds a dynamic number.
+    The property is dynamic and holds a number.
     """
     dynamic_lo_numbers = _TaipyLoNumbers
     """
-    The property holds a dynamic list of numbers.
+    The property is dynamic and holds a list of numbers.
     """
     dynamic_boolean = _TaipyBool
     """
-    The property holds a dynamic Boolean value.
+    The property is dynamic and holds a Boolean value.
     """
     dynamic_list = "dynamiclist"
+    """
+    The property is dynamic and holds a list.
+    """
     dynamic_string = "dynamicstring"
     """
-    The property holds a dynamic string.
+    The property is dynamic and holds a string.
     """
     function = "function"
     """

+ 731 - 0
taipy/gui/utils/unparse.py

@@ -0,0 +1,731 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+# from https://github.com/python/cpython/blob/3.8/Tools/parser/unparse.py to unparse an ast tree with python < 3.9
+
+import ast
+import io
+import sys
+
+# Large float and imaginary literals get turned into infinities in the AST.
+# We unparse those infinities to INFSTR.
+_INFSTR = "1e" + repr(sys.float_info.max_10_exp + 1)
+
+
+def _interleave(inter, f, seq):
+    """Call f on each item in seq, calling inter() in between."""
+    seq = iter(seq)
+    try:
+        f(next(seq))
+    except StopIteration:
+        pass
+    else:
+        for x in seq:
+            inter()
+            f(x)
+
+
+class _Unparser:
+    """Methods in this class recursively traverse an AST and
+    output source code for the abstract syntax; original formatting
+    is disregarded."""
+
+    def __init__(self, tree, file=sys.stdout):
+        """Unparser(tree, file=sys.stdout) -> None.
+        Print the source for tree to file."""
+        self.f = file
+        self._indent = 0
+        self.dispatch(tree)
+        print("", file=self.f)
+        self.f.flush()
+
+    def fill(self, text=""):
+        "Indent a piece of text, according to the current indentation level"
+        self.f.write("\n" + "    " * self._indent + text)
+
+    def write(self, text):
+        "Append a piece of text to the current line."
+        self.f.write(text)
+
+    def enter(self):
+        "Print ':', and increase the indentation."
+        self.write(":")
+        self._indent += 1
+
+    def leave(self):
+        "Decrease the indentation level."
+        self._indent -= 1
+
+    def dispatch(self, tree):
+        "Dispatcher function, dispatching tree type T to method _T."
+        if isinstance(tree, list):
+            for t in tree:
+                self.dispatch(t)
+            return
+        meth = getattr(self, "_" + tree.__class__.__name__)
+        meth(tree)
+
+    ############### Unparsing methods ######################
+    # There should be one method per concrete grammar type #
+    # Constructors should be grouped by sum type. Ideally, #
+    # this would follow the order in the grammar, but      #
+    # currently doesn't.                                   #
+    ########################################################
+
+    def _Module(self, tree):
+        for stmt in tree.body:
+            self.dispatch(stmt)
+
+    # stmt
+    def _Expr(self, tree):
+        self.fill()
+        self.dispatch(tree.value)
+
+    def _NamedExpr(self, tree):
+        self.write("(")
+        self.dispatch(tree.target)
+        self.write(" := ")
+        self.dispatch(tree.value)
+        self.write(")")
+
+    def _Import(self, t):
+        self.fill("import ")
+        _interleave(lambda: self.write(", "), self.dispatch, t.names)
+
+    def _ImportFrom(self, t):
+        self.fill("from ")
+        self.write("." * t.level)
+        if t.module:
+            self.write(t.module)
+        self.write(" import ")
+        _interleave(lambda: self.write(", "), self.dispatch, t.names)
+
+    def _Assign(self, t):
+        self.fill()
+        for target in t.targets:
+            self.dispatch(target)
+            self.write(" = ")
+        self.dispatch(t.value)
+
+    def _AugAssign(self, t):
+        self.fill()
+        self.dispatch(t.target)
+        self.write(" " + self.binop[t.op.__class__.__name__] + "= ")
+        self.dispatch(t.value)
+
+    def _AnnAssign(self, t):
+        self.fill()
+        if not t.simple and isinstance(t.target, ast.Name):
+            self.write("(")
+        self.dispatch(t.target)
+        if not t.simple and isinstance(t.target, ast.Name):
+            self.write(")")
+        self.write(": ")
+        self.dispatch(t.annotation)
+        if t.value:
+            self.write(" = ")
+            self.dispatch(t.value)
+
+    def _Return(self, t):
+        self.fill("return")
+        if t.value:
+            self.write(" ")
+            self.dispatch(t.value)
+
+    def _Pass(self, t):
+        self.fill("pass")
+
+    def _Break(self, t):
+        self.fill("break")
+
+    def _Continue(self, t):
+        self.fill("continue")
+
+    def _Delete(self, t):
+        self.fill("del ")
+        _interleave(lambda: self.write(", "), self.dispatch, t.targets)
+
+    def _Assert(self, t):
+        self.fill("assert ")
+        self.dispatch(t.test)
+        if t.msg:
+            self.write(", ")
+            self.dispatch(t.msg)
+
+    def _Global(self, t):
+        self.fill("global ")
+        _interleave(lambda: self.write(", "), self.write, t.names)
+
+    def _Nonlocal(self, t):
+        self.fill("nonlocal ")
+        _interleave(lambda: self.write(", "), self.write, t.names)
+
+    def _Await(self, t):
+        self.write("(")
+        self.write("await")
+        if t.value:
+            self.write(" ")
+            self.dispatch(t.value)
+        self.write(")")
+
+    def _Yield(self, t):
+        self.write("(")
+        self.write("yield")
+        if t.value:
+            self.write(" ")
+            self.dispatch(t.value)
+        self.write(")")
+
+    def _YieldFrom(self, t):
+        self.write("(")
+        self.write("yield from")
+        if t.value:
+            self.write(" ")
+            self.dispatch(t.value)
+        self.write(")")
+
+    def _Raise(self, t):
+        self.fill("raise")
+        if not t.exc:
+            assert not t.cause
+            return
+        self.write(" ")
+        self.dispatch(t.exc)
+        if t.cause:
+            self.write(" from ")
+            self.dispatch(t.cause)
+
+    def _Try(self, t):
+        self.fill("try")
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+        for ex in t.handlers:
+            self.dispatch(ex)
+        if t.orelse:
+            self.fill("else")
+            self.enter()
+            self.dispatch(t.orelse)
+            self.leave()
+        if t.finalbody:
+            self.fill("finally")
+            self.enter()
+            self.dispatch(t.finalbody)
+            self.leave()
+
+    def _ExceptHandler(self, t):
+        self.fill("except")
+        if t.type:
+            self.write(" ")
+            self.dispatch(t.type)
+        if t.name:
+            self.write(" as ")
+            self.write(t.name)
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+
+    def _ClassDef(self, t):
+        self.write("\n")
+        for deco in t.decorator_list:
+            self.fill("@")
+            self.dispatch(deco)
+        self.fill("class " + t.name)
+        self.write("(")
+        comma = False
+        for e in t.bases:
+            if comma:
+                self.write(", ")
+            else:
+                comma = True
+            self.dispatch(e)
+        for e in t.keywords:
+            if comma:
+                self.write(", ")
+            else:
+                comma = True
+            self.dispatch(e)
+        self.write(")")
+
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+
+    def _FunctionDef(self, t):
+        self.__FunctionDef_helper(t, "def")
+
+    def _AsyncFunctionDef(self, t):
+        self.__FunctionDef_helper(t, "async def")
+
+    def __FunctionDef_helper(self, t, fill_suffix):
+        self.write("\n")
+        for deco in t.decorator_list:
+            self.fill("@")
+            self.dispatch(deco)
+        def_str = fill_suffix + " " + t.name + "("
+        self.fill(def_str)
+        self.dispatch(t.args)
+        self.write(")")
+        if t.returns:
+            self.write(" -> ")
+            self.dispatch(t.returns)
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+
+    def _For(self, t):
+        self.__For_helper("for ", t)
+
+    def _AsyncFor(self, t):
+        self.__For_helper("async for ", t)
+
+    def __For_helper(self, fill, t):
+        self.fill(fill)
+        self.dispatch(t.target)
+        self.write(" in ")
+        self.dispatch(t.iter)
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+        if t.orelse:
+            self.fill("else")
+            self.enter()
+            self.dispatch(t.orelse)
+            self.leave()
+
+    def _If(self, t):
+        self.fill("if ")
+        self.dispatch(t.test)
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+        # collapse nested ifs into equivalent elifs.
+        while t.orelse and len(t.orelse) == 1 and isinstance(t.orelse[0], ast.If):
+            t = t.orelse[0]
+            self.fill("elif ")
+            self.dispatch(t.test)
+            self.enter()
+            self.dispatch(t.body)
+            self.leave()
+        # final else
+        if t.orelse:
+            self.fill("else")
+            self.enter()
+            self.dispatch(t.orelse)
+            self.leave()
+
+    def _While(self, t):
+        self.fill("while ")
+        self.dispatch(t.test)
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+        if t.orelse:
+            self.fill("else")
+            self.enter()
+            self.dispatch(t.orelse)
+            self.leave()
+
+    def _With(self, t):
+        self.fill("with ")
+        _interleave(lambda: self.write(", "), self.dispatch, t.items)
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+
+    def _AsyncWith(self, t):
+        self.fill("async with ")
+        _interleave(lambda: self.write(", "), self.dispatch, t.items)
+        self.enter()
+        self.dispatch(t.body)
+        self.leave()
+
+    # expr
+    def _JoinedStr(self, t):
+        self.write("f")
+        string = io.StringIO()
+        self._fstring_JoinedStr(t, string.write)
+        self.write(repr(string.getvalue()))
+
+    def _FormattedValue(self, t):
+        self.write("f")
+        string = io.StringIO()
+        self._fstring_FormattedValue(t, string.write)
+        self.write(repr(string.getvalue()))
+
+    def _fstring_JoinedStr(self, t, write):
+        for value in t.values:
+            meth = getattr(self, "_fstring_" + type(value).__name__)
+            meth(value, write)
+
+    def _fstring_Constant(self, t, write):
+        assert isinstance(t.value, str)
+        value = t.value.replace("{", "{{").replace("}", "}}")
+        write(value)
+
+    def _fstring_FormattedValue(self, t, write):
+        write("{")
+        expr = io.StringIO()
+        _Unparser(t.value, expr)
+        expr = expr.getvalue().rstrip("\n")
+        if expr.startswith("{"):
+            write(" ")  # Separate pair of opening brackets as "{ {"
+        write(expr)
+        if t.conversion != -1:
+            conversion = chr(t.conversion)
+            assert conversion in "sra"
+            write(f"!{conversion}")
+        if t.format_spec:
+            write(":")
+            meth = getattr(self, "_fstring_" + type(t.format_spec).__name__)
+            meth(t.format_spec, write)
+        write("}")
+
+    def _Name(self, t):
+        self.write(t.id)
+
+    def _write_constant(self, value):
+        if isinstance(value, (float, complex)):
+            # Substitute overflowing decimal literal for AST infinities.
+            self.write(repr(value).replace("inf", _INFSTR))
+        else:
+            self.write(repr(value))
+
+    def _Constant(self, t):
+        value = t.value
+        if isinstance(value, tuple):
+            self.write("(")
+            if len(value) == 1:
+                self._write_constant(value[0])
+                self.write(",")
+            else:
+                _interleave(lambda: self.write(", "), self._write_constant, value)
+            self.write(")")
+        elif value is ...:
+            self.write("...")
+        else:
+            if t.kind == "u":
+                self.write("u")
+            self._write_constant(t.value)
+
+    def _List(self, t):
+        self.write("[")
+        _interleave(lambda: self.write(", "), self.dispatch, t.elts)
+        self.write("]")
+
+    def _ListComp(self, t):
+        self.write("[")
+        self.dispatch(t.elt)
+        for gen in t.generators:
+            self.dispatch(gen)
+        self.write("]")
+
+    def _GeneratorExp(self, t):
+        self.write("(")
+        self.dispatch(t.elt)
+        for gen in t.generators:
+            self.dispatch(gen)
+        self.write(")")
+
+    def _SetComp(self, t):
+        self.write("{")
+        self.dispatch(t.elt)
+        for gen in t.generators:
+            self.dispatch(gen)
+        self.write("}")
+
+    def _DictComp(self, t):
+        self.write("{")
+        self.dispatch(t.key)
+        self.write(": ")
+        self.dispatch(t.value)
+        for gen in t.generators:
+            self.dispatch(gen)
+        self.write("}")
+
+    def _comprehension(self, t):
+        if t.is_async:
+            self.write(" async for ")
+        else:
+            self.write(" for ")
+        self.dispatch(t.target)
+        self.write(" in ")
+        self.dispatch(t.iter)
+        for if_clause in t.ifs:
+            self.write(" if ")
+            self.dispatch(if_clause)
+
+    def _IfExp(self, t):
+        self.write("(")
+        self.dispatch(t.body)
+        self.write(" if ")
+        self.dispatch(t.test)
+        self.write(" else ")
+        self.dispatch(t.orelse)
+        self.write(")")
+
+    def _Set(self, t):
+        assert t.elts  # should be at least one element
+        self.write("{")
+        _interleave(lambda: self.write(", "), self.dispatch, t.elts)
+        self.write("}")
+
+    def _Dict(self, t):
+        self.write("{")
+
+        def write_key_value_pair(k, v):
+            self.dispatch(k)
+            self.write(": ")
+            self.dispatch(v)
+
+        def write_item(item):
+            k, v = item
+            if k is None:
+                # for dictionary unpacking operator in dicts {**{'y': 2}}
+                # see PEP 448 for details
+                self.write("**")
+                self.dispatch(v)
+            else:
+                write_key_value_pair(k, v)
+
+        _interleave(lambda: self.write(", "), write_item, zip(t.keys, t.values))
+        self.write("}")
+
+    def _Tuple(self, t):
+        self.write("(")
+        if len(t.elts) == 1:
+            elt = t.elts[0]
+            self.dispatch(elt)
+            self.write(",")
+        else:
+            _interleave(lambda: self.write(", "), self.dispatch, t.elts)
+        self.write(")")
+
+    unop = {"Invert": "~", "Not": "not", "UAdd": "+", "USub": "-"}
+
+    def _UnaryOp(self, t):
+        self.write("(")
+        self.write(self.unop[t.op.__class__.__name__])
+        self.write(" ")
+        self.dispatch(t.operand)
+        self.write(")")
+
+    binop = {
+        "Add": "+",
+        "Sub": "-",
+        "Mult": "*",
+        "MatMult": "@",
+        "Div": "/",
+        "Mod": "%",
+        "LShift": "<<",
+        "RShift": ">>",
+        "BitOr": "|",
+        "BitXor": "^",
+        "BitAnd": "&",
+        "FloorDiv": "//",
+        "Pow": "**",
+    }
+
+    def _BinOp(self, t):
+        self.write("(")
+        self.dispatch(t.left)
+        self.write(" " + self.binop[t.op.__class__.__name__] + " ")
+        self.dispatch(t.right)
+        self.write(")")
+
+    cmpops = {
+        "Eq": "==",
+        "NotEq": "!=",
+        "Lt": "<",
+        "LtE": "<=",
+        "Gt": ">",
+        "GtE": ">=",
+        "Is": "is",
+        "IsNot": "is not",
+        "In": "in",
+        "NotIn": "not in",  # codespell:ignore
+    }
+
+    def _Compare(self, t):
+        self.write("(")
+        self.dispatch(t.left)
+        for o, e in zip(t.ops, t.comparators):
+            self.write(" " + self.cmpops[o.__class__.__name__] + " ")
+            self.dispatch(e)
+        self.write(")")
+
+    boolops = {ast.And: "and", ast.Or: "or"}
+
+    def _BoolOp(self, t):
+        self.write("(")
+        s = " %s " % self.boolops[t.op.__class__]
+        _interleave(lambda: self.write(s), self.dispatch, t.values)
+        self.write(")")
+
+    def _Attribute(self, t):
+        self.dispatch(t.value)
+        # Special case: 3.__abs__() is a syntax error, so if t.value
+        # is an integer literal then we need to either parenthesize
+        # it or add an extra space to get 3 .__abs__().
+        if isinstance(t.value, ast.Constant) and isinstance(t.value.value, int):
+            self.write(" ")
+        self.write(".")
+        self.write(t.attr)
+
+    def _Call(self, t):
+        self.dispatch(t.func)
+        self.write("(")
+        comma = False
+        for e in t.args:
+            if comma:
+                self.write(", ")
+            else:
+                comma = True
+            self.dispatch(e)
+        for e in t.keywords:
+            if comma:
+                self.write(", ")
+            else:
+                comma = True
+            self.dispatch(e)
+        self.write(")")
+
+    def _Subscript(self, t):
+        self.dispatch(t.value)
+        self.write("[")
+        if isinstance(t.slice, ast.Index) and isinstance(t.slice.value, ast.Tuple) and t.slice.value.elts:
+            if len(t.slice.value.elts) == 1:
+                elt = t.slice.value.elts[0]
+                self.dispatch(elt)
+                self.write(",")
+            else:
+                _interleave(lambda: self.write(", "), self.dispatch, t.slice.value.elts)
+        else:
+            self.dispatch(t.slice)
+        self.write("]")
+
+    def _Starred(self, t):
+        self.write("*")
+        self.dispatch(t.value)
+
+    # slice
+    def _Ellipsis(self, t):
+        self.write("...")
+
+    def _Index(self, t):
+        self.dispatch(t.value)
+
+    def _Slice(self, t):
+        if t.lower:
+            self.dispatch(t.lower)
+        self.write(":")
+        if t.upper:
+            self.dispatch(t.upper)
+        if t.step:
+            self.write(":")
+            self.dispatch(t.step)
+
+    def _ExtSlice(self, t):
+        if len(t.dims) == 1:
+            elt = t.dims[0]
+            self.dispatch(elt)
+            self.write(",")
+        else:
+            _interleave(lambda: self.write(", "), self.dispatch, t.dims)
+
+    # argument
+    def _arg(self, t):
+        self.write(t.arg)
+        if t.annotation:
+            self.write(": ")
+            self.dispatch(t.annotation)
+
+    # others
+    def _arguments(self, t):
+        first = True
+        # normal arguments
+        all_args = t.posonlyargs + t.args
+        defaults = [None] * (len(all_args) - len(t.defaults)) + t.defaults
+        for index, elements in enumerate(zip(all_args, defaults), 1):
+            a, d = elements
+            if first:
+                first = False
+            else:
+                self.write(", ")
+            self.dispatch(a)
+            if d:
+                self.write("=")
+                self.dispatch(d)
+            if index == len(t.posonlyargs):
+                self.write(", /")
+
+        # varargs, or bare '*' if no varargs but keyword-only arguments present
+        if t.vararg or t.kwonlyargs:
+            if first:
+                first = False
+            else:
+                self.write(", ")
+            self.write("*")
+            if t.vararg:
+                self.write(t.vararg.arg)
+                if t.vararg.annotation:
+                    self.write(": ")
+                    self.dispatch(t.vararg.annotation)
+
+        # keyword-only arguments
+        if t.kwonlyargs:
+            for a, d in zip(t.kwonlyargs, t.kw_defaults):
+                if first:
+                    first = False
+                else:
+                    self.write(", ")
+                (self.dispatch(a),)
+                if d:
+                    self.write("=")
+                    self.dispatch(d)
+
+        # kwargs
+        if t.kwarg:
+            if first:
+                first = False
+            else:
+                self.write(", ")
+            self.write("**" + t.kwarg.arg)
+            if t.kwarg.annotation:
+                self.write(": ")
+                self.dispatch(t.kwarg.annotation)
+
+    def _keyword(self, t):
+        if t.arg is None:
+            self.write("**")
+        else:
+            self.write(t.arg)
+            self.write("=")
+        self.dispatch(t.value)
+
+    def _Lambda(self, t):
+        self.write("(")
+        self.write("lambda ")
+        self.dispatch(t.args)
+        self.write(": ")
+        self.dispatch(t.body)
+        self.write(")")
+
+    def _alias(self, t):
+        self.write(t.name)
+        if t.asname:
+            self.write(" as " + t.asname)
+
+    def _withitem(self, t):
+        self.dispatch(t.context_expr)
+        if t.optional_vars:
+            self.write(" as ")
+            self.dispatch(t.optional_vars)

+ 97 - 59
taipy/gui/viselements.json

@@ -109,6 +109,12 @@
                         "type": "int",
                         "default_value": "5",
                         "doc": "The height of the displayed element if multiline is True."
+                    },
+                    {
+                        "name": "type",
+                        "type": "str",
+                        "default_value": "\"text\"",
+                        "doc": "TODO: The type of input: text, tel, email ... as defined in HTML standards https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types "
                     }
                 ]
             }
@@ -133,6 +139,28 @@
                         "type": "str",
                         "default_value": "None",
                         "doc": "The label associated with the input."
+                    },
+                    {
+                        "name": "step",
+                        "type": "dynamic(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)",
+                        "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)",
+                        "doc": "The minimum value to accept for this input."
+                    },
+                    {
+                        "name": "max",
+                        "type": "dynamic(int|float)",
+                        "doc": "The maximum value to accept for this input."
                     }
                 ]
             }
@@ -284,6 +312,16 @@
                         "name": "label",
                         "type": "str",
                         "doc": "The label associated with the input."
+                    },
+                    {
+                        "name": "min",
+                        "type": "dynamic(datetime)",
+                        "doc": "The minimum date to accept for this input."
+                    },
+                    {
+                        "name": "max",
+                        "type": "dynamic(datetime)",
+                        "doc": "The maximum date to accept for this input."
                     }
                 ]
             }
@@ -1513,65 +1551,6 @@
                 ]
             }
         ],
-        [
-            "dialog",
-            {
-                "inherits": [
-                    "partial",
-                    "active",
-                    "shared"
-                ],
-                "properties": [
-                    {
-                        "name": "open",
-                        "default_property": true,
-                        "type": "bool",
-                        "default_value": "False",
-                        "doc": "If True, the dialog is visible. If False, it is hidden."
-                    },
-                    {
-                        "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>",
-                        "signature": [
-                            [
-                                "state",
-                                "State"
-                            ],
-                            [
-                                "id",
-                                "str"
-                            ],
-                            [
-                                "payload",
-                                "dict"
-                            ]
-                        ]
-                    },
-                    {
-                        "name": "close_label",
-                        "type": "str",
-                        "default_value": "\"Close\"",
-                        "doc": "The tooltip of the top-right close icon button. In the <tt>on_action</tt> callback, args will hold -1."
-                    },
-                    {
-                        "name": "labels",
-                        "type": " 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",
-                        "doc": "The width, in CSS units, of this dialog.<br/>(CSS property)"
-                    },
-                    {
-                        "name": "height",
-                        "type": "str|int|float",
-                        "doc": "The height, in CSS units, of this dialog.<br/>(CSS property)"
-                    }
-                ]
-            }
-        ],
         [
             "tree",
             {
@@ -1669,6 +1648,65 @@
                 ]
             }
         ],
+        [
+            "dialog",
+            {
+                "inherits": [
+                    "partial",
+                    "active",
+                    "shared"
+                ],
+                "properties": [
+                    {
+                        "name": "open",
+                        "default_property": true,
+                        "type": "bool",
+                        "default_value": "False",
+                        "doc": "If True, the dialog is visible. If False, it is hidden."
+                    },
+                    {
+                        "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>",
+                        "signature": [
+                            [
+                                "state",
+                                "State"
+                            ],
+                            [
+                                "id",
+                                "str"
+                            ],
+                            [
+                                "payload",
+                                "dict"
+                            ]
+                        ]
+                    },
+                    {
+                        "name": "close_label",
+                        "type": "str",
+                        "default_value": "\"Close\"",
+                        "doc": "The tooltip of the top-right close icon button. In the <tt>on_action</tt> callback, args will hold -1."
+                    },
+                    {
+                        "name": "labels",
+                        "type": " 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",
+                        "doc": "The width, in CSS units, of this dialog.<br/>(CSS property)"
+                    },
+                    {
+                        "name": "height",
+                        "type": "str|int|float",
+                        "doc": "The height, in CSS units, of this dialog.<br/>(CSS property)"
+                    }
+                ]
+            }
+        ],
         [
             "layout",
             {

+ 2 - 1
taipy/gui_core/_context.py

@@ -125,6 +125,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         elif event.entity_type == EventEntityType.JOB:
             with self.lock:
                 self.jobs_list = None
+            self.broadcast_core_changed({"jobs": event.entity_id})
         elif event.entity_type == EventEntityType.SUBMISSION:
             self.submission_status_callback(event.entity_id, event)
         elif event.entity_type == EventEntityType.DATA_NODE:
@@ -181,7 +182,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         # callback
                         submission_name = submission.properties.get("on_submission")
                         if submission_name:
-                            self.gui._call_user_callback(
+                            self.gui.invoke_callback(
                                 client_id,
                                 submission_name,
                                 [

+ 18 - 18
tests/core/_entity/test_ready_to_run_property.py

@@ -14,7 +14,7 @@ from taipy import ScenarioId, SequenceId, TaskId
 from taipy.config.common.frequency import Frequency
 from taipy.config.config import Config
 from taipy.core._entity._ready_to_run_property import _ReadyToRunProperty
-from taipy.core.reason.reason import Reasons
+from taipy.core.reason import DataNodeEditInProgress, DataNodeIsNotWritten, ReasonCollection
 from taipy.core.scenario._scenario_manager_factory import _ScenarioManagerFactory
 from taipy.core.sequence._sequence_manager_factory import _SequenceManagerFactory
 from taipy.core.task._task_manager_factory import _TaskManagerFactory
@@ -33,7 +33,7 @@ def test_scenario_without_input_is_ready_to_run():
     scenario = scenario_manager._create(scenario_config)
 
     assert scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -46,7 +46,7 @@ def test_scenario_submittable_with_inputs_is_ready_to_run():
     scenario = scenario_manager._create(scenario_config)
 
     assert scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -61,7 +61,7 @@ def test_scenario_submittable_even_with_output_not_ready_to_run():
     dn_3 = scenario.dn_3
 
     assert not dn_3.is_ready_for_reading
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -78,7 +78,7 @@ def test_scenario_not_submittable_not_in_property_because_it_is_lazy():
     assert dn_1.is_ready_for_reading
     assert not dn_2.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
 
     # Since it is a lazy property, the scenario and the datanodes is not yet in the dictionary
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
@@ -97,14 +97,14 @@ def test_scenario_not_submittable_if_one_input_edit_in_progress():
 
     assert not dn_1.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
 
     assert scenario.id in _ReadyToRunProperty._submittable_id_datanodes
     assert dn_1.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons
     assert dn_1.id in _ReadyToRunProperty._datanode_id_submittables
     assert scenario.id in _ReadyToRunProperty._datanode_id_submittables[dn_1.id]
     assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_1.id] == {
-        f"DataNode {dn_1.id} is being edited"
+        DataNodeEditInProgress(dn_1.id)
     }
     assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons == f"DataNode {dn_1.id} is being edited."
 
@@ -125,7 +125,7 @@ def test_scenario_not_submittable_for_multiple_reasons():
     assert not dn_1.is_ready_for_reading
     assert not dn_2.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
 
     assert scenario.id in _ReadyToRunProperty._submittable_id_datanodes
     assert dn_1.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons
@@ -134,12 +134,12 @@ def test_scenario_not_submittable_for_multiple_reasons():
     assert dn_2.id in _ReadyToRunProperty._datanode_id_submittables
     assert scenario.id in _ReadyToRunProperty._datanode_id_submittables[dn_1.id]
     assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_1.id] == {
-        f"DataNode {dn_1.id} is being edited"
+        DataNodeEditInProgress(dn_1.id)
     }
     assert scenario.id in _ReadyToRunProperty._datanode_id_submittables[dn_2.id]
     assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_2.id] == {
-        f"DataNode {dn_2.id} is being edited",
-        f"DataNode {dn_2.id} is not written",
+        DataNodeEditInProgress(dn_2.id),
+        DataNodeIsNotWritten(dn_2.id),
     }
     reason_str = _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons
     assert f"DataNode {dn_2.id} is being edited" in reason_str
@@ -156,14 +156,14 @@ def test_writing_input_remove_reasons():
 
     assert not dn_1.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     # Since it is a lazy property, the scenario is not yet in the dictionary
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
     dn_1.lock_edit()
     assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_1.id] == {
-        f"DataNode {dn_1.id} is being edited",
-        f"DataNode {dn_1.id} is not written",
+        DataNodeEditInProgress(dn_1.id),
+        DataNodeIsNotWritten(dn_1.id),
     }
     reason_str = _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons
     assert f"DataNode {dn_1.id} is being edited" in reason_str
@@ -171,7 +171,7 @@ def test_writing_input_remove_reasons():
 
     dn_1.write(10)
     assert scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
     assert dn_1.id not in _ReadyToRunProperty._datanode_id_submittables
 
@@ -188,8 +188,8 @@ def __assert_not_submittable_becomes_submittable_when_dn_edited(entity, manager,
 
     dn.lock_edit()
     assert _ReadyToRunProperty._submittable_id_datanodes[entity.id]._reasons[dn.id] == {
-        f"DataNode {dn.id} is being edited",
-        f"DataNode {dn.id} is not written",
+        DataNodeEditInProgress(dn.id),
+        DataNodeIsNotWritten(dn.id),
     }
     reason_str = _ReadyToRunProperty._submittable_id_datanodes[entity.id].reasons
     assert f"DataNode {dn.id} is being edited" in reason_str
@@ -197,7 +197,7 @@ def __assert_not_submittable_becomes_submittable_when_dn_edited(entity, manager,
 
     dn.write("ANY VALUE")
     assert manager._is_submittable(entity)
-    assert isinstance(manager._is_submittable(entity), Reasons)
+    assert isinstance(manager._is_submittable(entity), ReasonCollection)
     assert entity.id not in _ReadyToRunProperty._submittable_id_datanodes
     assert dn.id not in _ReadyToRunProperty._datanode_id_submittables
 

+ 45 - 43
tests/core/common/test_reason.py

@@ -9,61 +9,63 @@
 # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 # specific language governing permissions and limitations under the License.
 
-from taipy.core.reason.reason import Reasons
+from taipy.core.reason import ReasonCollection
 
 
 def test_create_reason():
-    reason = Reasons("entity_id")
-    assert reason.entity_id == "entity_id"
-    assert reason._reasons == {}
-    assert reason
-    assert not reason._entity_id_exists_in_reason("entity_id")
-    assert reason.reasons == ""
+    reason_collection = ReasonCollection()
+    assert reason_collection._reasons == {}
+    assert reason_collection
+    assert not reason_collection._entity_id_exists_in_reason("entity_id")
+    assert reason_collection.reasons == ""
 
 
 def test_add_and_remove_reason():
-    reason = Reasons("entity_id")
-    reason._add_reason("entity_id_1", "Some reason")
-    assert reason._reasons == {"entity_id_1": {"Some reason"}}
-    assert not reason
-    assert reason._entity_id_exists_in_reason("entity_id_1")
-    assert reason.reasons == "Some reason."
+    reason_collection = ReasonCollection()
+    reason_collection._add_reason("entity_id_1", "Some reason")
+    assert reason_collection._reasons == {"entity_id_1": {"Some reason"}}
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert reason_collection.reasons == "Some reason."
 
-    reason._add_reason("entity_id_1", "Another reason")
-    reason._add_reason("entity_id_2", "Some more reason")
-    assert reason._reasons == {"entity_id_1": {"Some reason", "Another reason"}, "entity_id_2": {"Some more reason"}}
-    assert not reason
-    assert reason._entity_id_exists_in_reason("entity_id_1")
-    assert reason._entity_id_exists_in_reason("entity_id_2")
+    reason_collection._add_reason("entity_id_1", "Another reason")
+    reason_collection._add_reason("entity_id_2", "Some more reason")
+    assert reason_collection._reasons == {
+        "entity_id_1": {"Some reason", "Another reason"},
+        "entity_id_2": {"Some more reason"},
+    }
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert reason_collection._entity_id_exists_in_reason("entity_id_2")
 
-    reason._remove_reason("entity_id_1", "Some reason")
-    assert reason._reasons == {"entity_id_1": {"Another reason"}, "entity_id_2": {"Some more reason"}}
-    assert not reason
-    assert reason._entity_id_exists_in_reason("entity_id_1")
-    assert reason._entity_id_exists_in_reason("entity_id_2")
+    reason_collection._remove_reason("entity_id_1", "Some reason")
+    assert reason_collection._reasons == {"entity_id_1": {"Another reason"}, "entity_id_2": {"Some more reason"}}
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert reason_collection._entity_id_exists_in_reason("entity_id_2")
 
-    reason._remove_reason("entity_id_2", "Some more reason")
-    assert reason._reasons == {"entity_id_1": {"Another reason"}}
-    assert not reason
-    assert reason._entity_id_exists_in_reason("entity_id_1")
-    assert not reason._entity_id_exists_in_reason("entity_id_2")
+    reason_collection._remove_reason("entity_id_2", "Some more reason")
+    assert reason_collection._reasons == {"entity_id_1": {"Another reason"}}
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert not reason_collection._entity_id_exists_in_reason("entity_id_2")
 
-    reason._remove_reason("entity_id_1", "Another reason")
-    assert reason._reasons == {}
-    assert reason
-    assert not reason._entity_id_exists_in_reason("entity_id_1")
+    reason_collection._remove_reason("entity_id_1", "Another reason")
+    assert reason_collection._reasons == {}
+    assert reason_collection
+    assert not reason_collection._entity_id_exists_in_reason("entity_id_1")
 
 
 def test_get_reason_string_from_reason():
-    reason = Reasons("entity_id")
-    reason._add_reason("entity_id_1", "Some reason")
-    assert reason.reasons == "Some reason."
+    reason_collection = ReasonCollection()
+    reason_collection._add_reason("entity_id_1", "Some reason")
+    assert reason_collection.reasons == "Some reason."
 
-    reason._add_reason("entity_id_2", "Some more reason")
-    assert reason.reasons == "Some reason; Some more reason."
+    reason_collection._add_reason("entity_id_2", "Some more reason")
+    assert reason_collection.reasons == "Some reason; Some more reason."
 
-    reason._add_reason("entity_id_1", "Another reason")
-    assert reason.reasons.count(";") == 2
-    assert "Some reason" in reason.reasons
-    assert "Another reason" in reason.reasons
-    assert "Some more reason" in reason.reasons
+    reason_collection._add_reason("entity_id_1", "Another reason")
+    assert reason_collection.reasons.count(";") == 2
+    assert "Some reason" in reason_collection.reasons
+    assert "Another reason" in reason_collection.reasons
+    assert "Some more reason" in reason_collection.reasons

+ 8 - 2
tests/core/data/test_data_manager.py

@@ -24,6 +24,7 @@ from taipy.core.data.data_node_id import DataNodeId
 from taipy.core.data.in_memory import InMemoryDataNode
 from taipy.core.data.pickle import PickleDataNode
 from taipy.core.exceptions.exceptions import InvalidDataNodeType, ModelNotFound
+from taipy.core.reason import NotGlobalScope, WrongConfigType
 from tests.core.utils.named_temporary_file import NamedTemporaryFile
 
 
@@ -61,11 +62,16 @@ class TestDataManager:
 
         reasons = _DataManager._can_create(dn_config)
         assert bool(reasons) is False
-        assert reasons._reasons == {dn_config.id: {'Data node config "dn" does not have GLOBAL scope'}}
+        assert reasons._reasons[dn_config.id] == {NotGlobalScope(dn_config.id)}
+        assert (
+            str(list(reasons._reasons[dn_config.id])[0])
+            == f'Data node config "{dn_config.id}" does not have GLOBAL scope'
+        )
 
         reasons = _DataManager._can_create(1)
         assert bool(reasons) is False
-        assert reasons._reasons == {"1": {'Object "1" must be a valid DataNodeConfig'}}
+        assert reasons._reasons["1"] == {WrongConfigType("1", DataNodeConfig.__name__)}
+        assert str(list(reasons._reasons["1"])[0]) == 'Object "1" must be a valid DataNodeConfig'
 
     def test_create_data_node_with_name_provided(self):
         dn_config = Config.configure_data_node(id="dn", foo="bar", name="acb")

+ 59 - 2
tests/core/scenario/test_scenario_manager.py

@@ -13,6 +13,7 @@ from datetime import datetime, timedelta
 from typing import Callable, Iterable, Optional
 from unittest.mock import ANY, patch
 
+import freezegun
 import pytest
 
 from taipy.config.common.frequency import Frequency
@@ -24,6 +25,7 @@ from taipy.core._orchestrator._orchestrator import _Orchestrator
 from taipy.core._version._version_manager import _VersionManager
 from taipy.core.common import _utils
 from taipy.core.common._utils import _Subscriber
+from taipy.core.config.scenario_config import ScenarioConfig
 from taipy.core.cycle._cycle_manager import _CycleManager
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data.in_memory import InMemoryDataNode
@@ -39,6 +41,7 @@ from taipy.core.exceptions.exceptions import (
     UnauthorizedTagError,
 )
 from taipy.core.job._job_manager import _JobManager
+from taipy.core.reason import WrongConfigType
 from taipy.core.scenario._scenario_manager import _ScenarioManager
 from taipy.core.scenario._scenario_manager_factory import _ScenarioManagerFactory
 from taipy.core.scenario.scenario import Scenario
@@ -381,13 +384,15 @@ def test_can_create():
 
     reasons = _ScenarioManager._can_create(task_config)
     assert bool(reasons) is False
-    assert reasons._reasons == {task_config.id: {'Object "task" must be a valid ScenarioConfig'}}
+    assert reasons._reasons[task_config.id] == {WrongConfigType(task_config.id, ScenarioConfig.__name__)}
+    assert str(list(reasons._reasons[task_config.id])[0]) == 'Object "task" must be a valid ScenarioConfig'
     with pytest.raises(AttributeError):
         _ScenarioManager._create(task_config)
 
     reasons = _ScenarioManager._can_create(1)
     assert bool(reasons) is False
-    assert reasons._reasons == {"1": {'Object "1" must be a valid ScenarioConfig'}}
+    assert reasons._reasons["1"] == {WrongConfigType(1, ScenarioConfig.__name__)}
+    assert str(list(reasons._reasons["1"])[0]) == 'Object "1" must be a valid ScenarioConfig'
     with pytest.raises(AttributeError):
         _ScenarioManager._create(1)
 
@@ -1481,3 +1486,55 @@ def test_get_scenarios_by_config_id_in_multiple_versions_environment():
 
     assert len(_ScenarioManager._get_by_config_id(scenario_config_1.id)) == 3
     assert len(_ScenarioManager._get_by_config_id(scenario_config_2.id)) == 2
+
+
+def test_filter_scenarios_by_creation_datetime():
+    scenario_config_1 = Config.configure_scenario("s1", sequence_configs=[])
+
+    with freezegun.freeze_time("2024-01-01"):
+        s_1_1 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-01-03"):
+        s_1_2 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-02-01"):
+        s_1_3 = _ScenarioManager._create(scenario_config_1)
+
+    all_scenarios = _ScenarioManager._get_all()
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios
+
+    # The time period is inclusive
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 3),
+    )
+    assert len(filtered_scenarios) == 2
+    assert sorted([s_1_1.id, s_1_2.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2023, 1, 1),
+        created_end_time=datetime(2025, 1, 1),
+    )
+    assert len(filtered_scenarios) == 3
+    assert sorted([s_1_1.id, s_1_2.id, s_1_3.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 2, 1),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_3] == filtered_scenarios
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios

+ 53 - 0
tests/core/scenario/test_scenario_manager_with_sql_repo.py

@@ -11,6 +11,7 @@
 
 from datetime import datetime, timedelta
 
+import freezegun
 import pytest
 
 from taipy.config.common.frequency import Frequency
@@ -435,3 +436,55 @@ def test_get_scenarios_by_config_id_in_multiple_versions_environment(init_sql_re
 
     assert len(_ScenarioManager._get_by_config_id(scenario_config_1.id)) == 3
     assert len(_ScenarioManager._get_by_config_id(scenario_config_2.id)) == 2
+
+
+def test_filter_scenarios_by_creation_datetime(init_sql_repo):
+    scenario_config_1 = Config.configure_scenario("s1", sequence_configs=[])
+
+    with freezegun.freeze_time("2024-01-01"):
+        s_1_1 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-01-03"):
+        s_1_2 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-02-01"):
+        s_1_3 = _ScenarioManager._create(scenario_config_1)
+
+    all_scenarios = _ScenarioManager._get_all()
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios
+
+    # The time period is inclusive
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 3),
+    )
+    assert len(filtered_scenarios) == 2
+    assert sorted([s_1_1.id, s_1_2.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2023, 1, 1),
+        created_end_time=datetime(2025, 1, 1),
+    )
+    assert len(filtered_scenarios) == 3
+    assert sorted([s_1_1.id, s_1_2.id, s_1_3.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 2, 1),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_3] == filtered_scenarios
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios

+ 6 - 0
tests/core/test_taipy.py

@@ -431,6 +431,9 @@ class TestTaipy:
         with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._get_all_by_tag") as mck:
             tp.get_scenarios(tag="tag")
             mck.assert_called_once_with("tag")
+        with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._filter_by_creation_time") as mck:
+            tp.get_scenarios(created_start_time=datetime.datetime(2021, 1, 1))
+            mck.assert_called_once_with([], datetime.datetime(2021, 1, 1), None)
 
     def test_get_scenarios_sorted(self):
         scenario_1_cfg = Config.configure_scenario(id="scenario_1")
@@ -500,6 +503,9 @@ class TestTaipy:
         with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._get_primary_scenarios") as mck:
             tp.get_primary_scenarios()
             mck.assert_called_once_with()
+        with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._filter_by_creation_time") as mck:
+            tp.get_scenarios(created_end_time=datetime.datetime(2021, 1, 1))
+            mck.assert_called_once_with([], None, datetime.datetime(2021, 1, 1))
 
     def test_set_primary(self, scenario):
         with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._set_primary") as mck:

+ 46 - 5
tests/gui/actions/test_invoke_callback.py

@@ -9,11 +9,20 @@
 # 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 contextlib
 import inspect
 
 from flask import g
 
-from taipy.gui import Gui, Markdown, State, invoke_callback
+from taipy.gui import Gui, Markdown, State
+
+
+@contextlib.contextmanager
+def get_state(gui: Gui, state_id: str):
+    with gui.get_flask_app().app_context():
+        client_id = gui._bindings()._get_or_create_scope(state_id)[0]
+        gui._Gui__set_client_id_in_context(client_id)  # type: ignore[attr-defined]
+        yield gui._Gui__state  # type: ignore[attr-defined]
 
 
 def test_invoke_callback(gui: Gui, helpers):
@@ -29,13 +38,45 @@ def test_invoke_callback(gui: Gui, helpers):
     gui._set_frame(inspect.currentframe())
 
     gui.add_page("test", Markdown("<|Hello {name}|button|id={btn_id}|>\n<|{val}|>"))
-    gui.run(run_server=False, single_client=True)
+    gui.run(run_server=False)
     flask_client = gui._server.test_client()
     # client id
     cid = helpers.create_scope_and_get_sid(gui)
+    # Get the jsx once so that the page will be evaluated -> variable will be registered
+    flask_client.get(f"/taipy-jsx/test?client_id={cid}")
+
+    gui.invoke_callback(cid, user_callback, [])
+    with get_state(gui, cid) as state:
+        assert state.val == 10
+
+
+def test_invoke_callback_sid(gui: Gui, helpers):
+    name = "World!"  # noqa: F841
+    btn_id = "button1"  # noqa: F841
+
+    val = 1  # noqa: F841
+
+    def user_callback(state: State):
+        state.val = 10
+
+    # set gui frame
+    gui._set_frame(inspect.currentframe())
+
+    gui.add_page("test", Markdown("<|Hello {name}|button|id={btn_id}|>\n<|{val}|>"))
+    gui.run(run_server=False)
+    flask_client = gui._server.test_client()
+    # client id
+    cid = helpers.create_scope_and_get_sid(gui)
+    base_sid, _ = gui._bindings()._get_or_create_scope("base sid")
+
     # Get the jsx once so that the page will be evaluated -> variable will be registered
     flask_client.get(f"/taipy-jsx/test?client_id={cid}")
     with gui.get_flask_app().app_context():
-        g.client_id = cid
-        invoke_callback(gui, cid, user_callback, [])
-        assert gui._Gui__state.val == 10  # type: ignore[attr-defined]
+        g.client_id = base_sid
+        gui.invoke_callback(cid, user_callback, [])
+        assert g.client_id == base_sid
+
+    with get_state(gui, base_sid) as base_state:
+        assert base_state.val == 1
+    with get_state(gui, cid) as a_state:
+        assert a_state.val == 10

+ 10 - 0
tests/gui/builder/control/test_dialog.py

@@ -74,3 +74,13 @@ def test_dialog_labels_builder(gui: Gui, helpers):
         "open={_TpB_tpec_TpExPr_dialog_open_TPMDL_0}",
     ]
     helpers.test_control_builder(gui, page, expected_list)
+
+def test_dialog_builder_block(gui: Gui, helpers):
+    with tgb.dialog(title="Another Dialog") as content:  # type: ignore[attr-defined]
+        tgb.text(value="This is in a dialog")  # type: ignore[attr-defined]
+    expected_list = [
+        "<Dialog",
+        'title="Another Dialog"',
+        "This is in a dialog",
+    ]
+    helpers.test_control_builder(gui, tgb.Page(content, frame=None), expected_list)

+ 33 - 0
tests/gui/builder/test_lambda.py

@@ -0,0 +1,33 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import taipy.gui.builder as tgb
+from taipy.gui import Gui
+
+
+def test_builder_lambda(gui: Gui, test_client, helpers):
+    message = {"a": "value A", "b": "value B"}
+    gui._bind_var_val("message", message)
+    with tgb.Page(frame=None) as page:
+        for key in message:
+            tgb.text(lambda message: str(message.get(key)))  # type: ignore[attr-defined] # noqa: B023
+    expected_list = ['defaultValue="value A"', 'defaultValue="value B"']
+    helpers.test_control_builder(gui, page, expected_list)
+
+
+def test_builder_lambda_2(gui: Gui, test_client, helpers):
+    message = {"a": "value A", "b": "value B"}
+    gui._bind_var_val("message", message)
+    with tgb.Page(frame=None) as page:
+        for key in message:
+            tgb.text(lambda message: str(message.get(key)), mode=lambda message: "mode " + str(message.get(key)))  # type: ignore[attr-defined] # noqa: B023
+    expected_list = ['defaultValue="value A"', 'defaultValue="value B"', 'mode="mode value A"', 'mode="mode value B"']
+    helpers.test_control_builder(gui, page, expected_list)

+ 30 - 0
tests/gui/builder/test_on_action.py

@@ -0,0 +1,30 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import taipy.gui.builder as tgb
+from taipy.gui import Gui, notify
+
+
+def test_builder_on_function(gui: Gui, test_client, helpers):
+    def on_slider(state):
+        notify(state, "success", f"Value: {state.value}")
+    gui._bind_var_val("on_slider", on_slider)
+    with tgb.Page(frame=None) as page:
+        tgb.slider(value="{value}", on_change=on_slider)  # type: ignore[attr-defined] # noqa: B023
+    expected_list = ['<Slider','onChange="on_slider"']
+    helpers.test_control_builder(gui, page, expected_list)
+
+
+def test_builder_on_lambda(gui: Gui, test_client, helpers):
+    with tgb.Page(frame=None) as page:
+        tgb.slider(value="{value}", on_change=lambda s: notify(s, "success", f"Lambda Value: {s.value}"))  # type: ignore[attr-defined] # noqa: B023
+    expected_list = ['<Slider','onChange="__lambda_']
+    helpers.test_control_builder(gui, page, expected_list)

+ 138 - 0
tests/gui/gui_specific/test_broadcast.py

@@ -0,0 +1,138 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import contextlib
+import inspect
+
+import pytest
+
+from taipy.gui import Gui
+
+
+@contextlib.contextmanager
+def get_state(gui: Gui, state_id: str):
+    with gui.get_flask_app().app_context():
+        client_id = gui._bindings()._get_or_create_scope(state_id)[0]
+        gui._Gui__set_client_id_in_context(client_id)  # type: ignore[attr-defined]
+        yield gui._Gui__state  # type: ignore[attr-defined]
+
+def test_multiple_scopes(gui: Gui):
+    var = 1  # noqa: F841
+    gui._set_frame(inspect.currentframe())
+    gui.add_page("page1", "<|{var}|>")
+    gui.run(run_server=False)
+    with get_state(gui, "s1") as state1:
+        assert state1.var == 1
+    with get_state(gui, "s2") as state2:
+        assert state2.var == 1
+    with get_state(gui, "s1") as state1:
+        state1.var = 2
+        assert state1.var == 2
+    with get_state(gui, "s2") as state2:
+        assert state2.var == 1
+
+
+def test_shared_variable(gui: Gui):
+    var = 1  # noqa: F841
+    s_var = 10  # noqa: F841
+    gui._set_frame(inspect.currentframe())
+    gui.add_page("test", "<|{var}|>")
+    gui.add_shared_variable("s_var")
+    gui.run(run_server=False)
+    with get_state(gui, "s1") as state1:
+        assert state1.var == 1
+        assert state1.s_var == 10
+        state1.var = 2
+        state1.s_var = 20
+        assert state1.var == 2
+        assert state1.s_var == 20
+    with get_state(gui, "s2") as state2:
+        assert state2.var == 1
+        assert state1.s_var == 20
+    Gui._clear_shared_variable()
+
+
+def test_broadcast_change(gui: Gui):
+    # Bind test variables
+    v1 = "none"  # noqa: F841
+    v2 = 1  # noqa: F841
+    gui._set_frame(inspect.currentframe())
+    gui.add_page("test", " <|{v1}|><|{v2}|>")
+    gui.run(run_server=False)
+    s1, _ = gui._bindings()._get_or_create_scope("s1")
+    s2, _ = gui._bindings()._get_or_create_scope("s2")
+    gui.broadcast_change("v2", 2)
+    with get_state(gui, s1) as state1:
+        assert state1.v1 == "none"
+        assert state1.v2 == 2
+    with get_state(gui, s2) as state2:
+        assert state2.v1 == "none"
+        assert state2.v2 == 2
+
+def test_broadcast_changes(gui: Gui):
+    # Bind test variables
+    v1 = "none"  # noqa: F841
+    v2 = 1  # noqa: F841
+    gui._set_frame(inspect.currentframe())
+    gui.add_page("test", " <|{v1}|><|{v2}|>")
+    gui.run(run_server=False)
+    s1, _ = gui._bindings()._get_or_create_scope("s1")
+    s2, _ = gui._bindings()._get_or_create_scope("s2")
+
+    changes = { "v1": "some", "v2": 2}
+    gui.broadcast_changes(changes)
+    with get_state(gui, s1) as state1:
+        assert state1.v1 == "some"
+        assert state1.v2 == 2
+    with get_state(gui, s2) as state2:
+        assert state2.v1 == "some"
+        assert state2.v2 == 2
+
+    gui.broadcast_changes(v1="more", v2=3)
+    with get_state(gui, s1) as state1:
+        assert state1.v1 == "more"
+        assert state1.v2 == 3
+    with get_state(gui, s2) as state2:
+        assert state2.v1 == "more"
+        assert state2.v2 == 3
+
+    gui.broadcast_changes({ "v1": "more yet"}, v2=4)
+    with get_state(gui, s1) as state1:
+        assert state1.v1 == "more yet"
+        assert state1.v2 == 4
+    with get_state(gui, s2) as state2:
+        assert state2.v1 == "more yet"
+        assert state2.v2 == 4
+
+
+def test_broadcast_callback(gui: Gui):
+    gui.run(run_server=False)
+
+    res = gui.broadcast_callback(lambda _, t: t, ["Hello World"], "mine")
+    assert isinstance(res, dict)
+    assert not res
+
+    gui._bindings()._get_or_create_scope("test scope")
+
+    res = gui.broadcast_callback(lambda _, t: t, ["Hello World"], "mine")
+    assert len(res) == 1
+    assert res.get("test scope", None) == "Hello World"
+
+    with pytest.warns(UserWarning):
+        res = gui.broadcast_callback(print, ["Hello World"], "mine")
+        assert isinstance(res, dict)
+        assert res.get("test scope", "Hello World") is None
+
+    gui._bindings()._get_or_create_scope("another scope")
+    res = gui.broadcast_callback(lambda s, t: t, ["Hello World"], "mine")
+    assert len(res) == 2
+    assert res.get("test scope", None) == "Hello World"
+    assert res.get("another scope", None) == "Hello World"

+ 0 - 12
tests/gui/gui_specific/test_gui.py

@@ -42,18 +42,6 @@ def test__get_user_instance(gui: Gui):
             gui._get_user_instance("", type(None))
 
 
-def test__call_broadcast_callback(gui: Gui):
-    gui.run(run_server=False)
-    with gui.get_flask_app().app_context():
-        res = gui._call_broadcast_callback(lambda s, t: t, ["Hello World"], "mine")
-        assert res == "Hello World"
-
-    with gui.get_flask_app().app_context():
-        with pytest.warns(UserWarning):
-            res = gui._call_broadcast_callback(print, ["Hello World"], "mine")
-            assert res is None
-
-
 def test__refresh_expr(gui: Gui):
     gui.run(run_server=False)
     with gui.get_flask_app().app_context():

+ 6 - 2
tests/gui/gui_specific/test_shared.py

@@ -13,9 +13,13 @@ from taipy.gui import Gui
 
 
 def test_add_shared_variables(gui: Gui):
+    assert len(gui._Gui__shared_variables) == 0  # type: ignore[attr-defined]
+
     Gui.add_shared_variable("var1", "var2")
     assert isinstance(gui._Gui__shared_variables, list)  # type: ignore[attr-defined]
     assert len(gui._Gui__shared_variables) == 2  # type: ignore[attr-defined]
 
-    Gui.add_shared_variables("var1", "var2")
-    assert len(gui._Gui__shared_variables) == 2  # type: ignore[attr-defined]
+    Gui.add_shared_variables("var1", "var3")
+    assert len(gui._Gui__shared_variables) == 3  # type: ignore[attr-defined]
+
+    Gui._clear_shared_variable()

+ 6 - 6
tests/gui_core/test_context_is_submitable.py

@@ -14,7 +14,7 @@ from unittest.mock import Mock, patch
 from taipy.config.common.scope import Scope
 from taipy.core import Job, JobId, Scenario, Task
 from taipy.core.data.pickle import PickleDataNode
-from taipy.core.reason.reason import Reasons
+from taipy.core.reason import ReasonCollection
 from taipy.gui_core._context import _GuiCoreContext
 
 a_scenario = Scenario("scenario_config_id", None, {}, sequences={"sequence": {}})
@@ -25,13 +25,13 @@ a_datanode = PickleDataNode("data_node_config_id", Scope.SCENARIO)
 
 
 def mock_is_submittable_reason(entity_id):
-    reason = Reasons(entity_id)
-    reason._add_reason(entity_id, "a reason")
-    return reason
+    reasons = ReasonCollection()
+    reasons._add_reason(entity_id, "a reason")
+    return reasons
 
 
-def mock_has_no_reason(entity_id):
-    return Reasons(entity_id)
+def mock_has_no_reason():
+    return ReasonCollection()
 
 
 def mock_core_get(entity_id):

+ 12 - 4
tools/gui/generate_pyi.py

@@ -110,7 +110,7 @@ def build_doc(name: str, element: t.Dict[str, t.Any]):
         r"\1\3",
         doc,
     )
-    doc = markdownify(doc, strip=['br'])
+    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
 
 
@@ -118,16 +118,24 @@ 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 property["name"] not in property_names and "[" not in property["name"]:
+        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
+        ):
             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])
-    doc_arguments = "\n".join([build_doc(name, p) for p in property_list])
+    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(

+ 2 - 0
tools/release/fetch_latest_versions.py

@@ -35,6 +35,8 @@ def fetch_latest_releases_from_github(dev=False, target_version="", target_packa
             releases["rest"] = releases.get("rest") or tag.split("-")[0]
         elif "templates" in tag:
             releases["templates"] = releases.get("templates") or tag.split("-")[0]
+        elif "-" not in tag:
+            releases["taipy"] = releases.get("taipy") or tag
     releases[target_package] = target_version
     return releases
 

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác