Browse Source

Merge branch 'develop' into refactor/expand-Event-and-get_manager-method

Đỗ Trường Giang 6 months ago
parent
commit
b05c6f3d4e
73 changed files with 2159 additions and 1101 deletions
  1. 40 0
      .github/PULL_REQUEST_TEMPLATE.md
  2. 17 1
      .github/workflows/build-and-release.yml
  3. 1 1
      Pipfile
  4. 2 5
      README.md
  5. 28 0
      doc/gui/examples/Alert.py
  6. 1 0
      doc/gui/examples/charts/matplotlib/__init__.py
  7. 44 0
      doc/gui/examples/charts/matplotlib/builder.py
  8. 43 0
      doc/gui/examples/charts/matplotlib/markdown.py
  9. 192 0
      doc/gui/examples/controls/chat-streaming.py
  10. 28 0
      doc/gui/examples/controls/date_range_with_time_analog_picker.py
  11. 26 0
      doc/gui/examples/controls/date_with_time_analog_picker.py
  12. 37 0
      doc/gui/examples/controls/file_selector_image.py
  13. 25 0
      doc/gui/examples/controls/file_selector_simple.py
  14. 26 0
      doc/gui/examples/controls/menu_inactive.py
  15. 26 0
      doc/gui/examples/controls/menu_inactive_options.py
  16. 26 0
      doc/gui/examples/controls/menu_label.py
  17. 30 0
      doc/gui/examples/controls/menu_on_action.py
  18. 27 0
      doc/gui/examples/controls/menu_selected.py
  19. 26 0
      doc/gui/examples/controls/menu_simple.py
  20. 38 0
      doc/gui/examples/controls/slider_labels_dictionary.py
  21. 9 0
      doc/gui/extension/example_library/example_library.py
  22. 80 0
      doc/gui/extension/example_library/front-end/src/GameTable.tsx
  23. 2 1
      doc/gui/extension/example_library/front-end/src/index.ts
  24. 32 0
      doc/gui/extension/table_chess_game.py
  25. 2 2
      frontend/taipy-gui/src/components/Router.tsx
  26. 26 176
      frontend/taipy-gui/src/components/Taipy/Alert.spec.tsx
  27. 32 64
      frontend/taipy-gui/src/components/Taipy/Alert.tsx
  28. 14 19
      frontend/taipy-gui/src/components/Taipy/Menu.spec.tsx
  29. 34 16
      frontend/taipy-gui/src/components/Taipy/Menu.tsx
  30. 1 0
      frontend/taipy-gui/src/components/Taipy/MenuCtl.spec.tsx
  31. 33 29
      frontend/taipy-gui/src/components/Taipy/MenuCtl.tsx
  32. 12 2
      frontend/taipy-gui/src/components/Taipy/Metric.spec.tsx
  33. 1 1
      frontend/taipy-gui/src/components/Taipy/Metric.tsx
  34. 202 0
      frontend/taipy-gui/src/components/Taipy/Notification.spec.tsx
  35. 81 0
      frontend/taipy-gui/src/components/Taipy/Notification.tsx
  36. 3 1
      frontend/taipy-gui/src/components/Taipy/index.ts
  37. 3 5
      frontend/taipy-gui/src/components/Taipy/lovUtils.tsx
  38. 1 0
      frontend/taipy-gui/src/utils/lov.ts
  39. 161 539
      frontend/taipy/package-lock.json
  40. 20 11
      frontend/taipy/src/DataNodeViewer.tsx
  41. 1 1
      taipy/gui/_default_config.py
  42. 11 1
      taipy/gui/_page.py
  43. 5 2
      taipy/gui/_renderers/builder.py
  44. 21 4
      taipy/gui/_renderers/factory.py
  45. 13 4
      taipy/gui/_warnings.py
  46. 1 1
      taipy/gui/config.py
  47. 22 6
      taipy/gui/extension/library.py
  48. 12 13
      taipy/gui/gui.py
  49. 10 0
      taipy/gui/mock/__init__.py
  50. 62 0
      taipy/gui/mock/mock_state.py
  51. 10 8
      taipy/gui/server.py
  52. 110 95
      taipy/gui/state.py
  53. 13 3
      taipy/gui/utils/_evaluator.py
  54. 75 38
      taipy/gui/viselements.json
  55. 4 3
      taipy/gui_core/_GuiCoreLib.py
  56. 7 5
      taipy/gui_core/_context.py
  57. 28 22
      taipy/gui_core/viselements.json
  58. 3 2
      tests/gui/actions/test_download.py
  59. 38 0
      tests/gui/builder/control/test_metric.py
  60. 53 0
      tests/gui/control/test_metric.py
  61. 1 1
      tests/gui/gui_specific/test_cli.py
  62. 1 1
      tests/gui/helpers.py
  63. 106 0
      tests/gui/mock/test_mock_state.py
  64. 4 3
      tests/rest/test_datanode.py
  65. 4 3
      tests/rest/test_scenario.py
  66. 4 3
      tests/rest/test_sequence.py
  67. 4 3
      tests/rest/test_task.py
  68. 1 1
      tools/packages/pipfiles/Pipfile3.10.max
  69. 1 1
      tools/packages/pipfiles/Pipfile3.11.max
  70. 1 1
      tools/packages/pipfiles/Pipfile3.12.max
  71. 1 1
      tools/packages/pipfiles/Pipfile3.9.max
  72. 97 0
      tools/release/bump_version.py
  73. 3 2
      tools/release/setup_version.py

+ 40 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,40 @@
+## What type of PR is this? (check all applicable)
+
+- [ ] Refactor
+- [ ] Feature
+- [ ] Bug Fix
+- [ ] Optimization
+- [ ] Documentation Update
+
+## Description
+
+## Related Tickets & Documents
+
+<!--
+For pull requests that relate or close an issue, please include them
+below.  We like to follow [Github's guidance on linking issues to pull requests](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
+
+For example having the text: "closes #1234" would connect the current pull
+request to issue 1234.  And when we merge the pull request, Github will
+automatically close the issue.
+-->
+
+- Related Issue #
+- Closes #
+
+## How to reproduce the issue
+
+_Please replace this line with instructions on how to reproduce the issue or test the feature._
+
+## Other branches or releases that this needs to be backported
+_Describe which projects this change will impact and that needs to be backported._
+
+## Checklist
+_We encourage you to keep the code coverage percentage at 80% and above._
+
+- [ ] Does this solution meet the acceptance criteria of the related issue?
+- [ ] Is the related issue checklist completed?
+- [ ] Does this PR adds unit tests for the developed code? If not, why?
+- [ ] End-to-End tests have been added or updated?
+- [ ] Was the documentation updated, or a dedicated issue for documentation created? (If applicable)
+- [ ] Is the release notes updated? (If applicable)

+ 17 - 1
.github/workflows/build-and-release.yml

@@ -20,6 +20,7 @@ env:
 
 permissions:
   contents: write
+  pull-requests: write
 
 jobs:
   fetch-versions:
@@ -238,11 +239,26 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
+      - name: Bump Version
+        if: github.event.inputs.release_type == 'dev'
+        id: bump-version
+        run: |
+          python tools/release/bump_version.py
+
       - uses: stefanzweifel/git-auto-commit-action@v5
+        if: github.event.inputs.release_type == 'dev'
         with:
-          file_pattern: '*/version.json'
+          branch: "feature/update-dev-version-${{ github.run_id }}"
+          create_branch: 'true'
+          file_pattern: '**/version.json'
           commit_message: Update version to ${{ needs.fetch-versions.outputs.NEW_VERSION }}
 
+      - name: create pull request
+        if: github.event.inputs.release_type == 'dev'
+        run: gh pr create -B develop -H "feature/update-dev-version-${{ github.run_id }}" --title 'Update Dev Version' --body 'Created by Github action'
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
       - name: Reset changes
         run: |
           git reset --hard HEAD

+ 1 - 1
Pipfile

@@ -77,4 +77,4 @@ types-tzlocal = "*"
 python_version = "3"
 
 [pipenv]
-allow_prereleases = true
+allow_prereleases = false

+ 2 - 5
README.md

@@ -1,6 +1,3 @@
-[![Hacktoberfest_clickable](https://github.com/user-attachments/assets/b1b5a430-6df9-40c2-999f-de3433f61251)](https://assorted-son-815.notion.site/Hacktoberfest-2024-with-Taipy-2a5032a3f01642709e88ffaa5d0d169e)
-
-
 <div align="center">
   <a href="https://taipy.io?utm_source=github" target="_blank">
   <picture>
@@ -40,10 +37,10 @@ No more compromises on performance, customization, and scalability.
 - [What's Taipy?](#%EF%B8%8F-whats-taipy)
 - [Key Features](#-key-features)
 - [Quickstart](#️-quickstart)
-- [Scenario and Data Management](#-scenario-and-data-management)
+- [Scenario and Data Management](#-scenario--data-management)
 - [Taipy Studio](#taipy-studio)
 - [User Interface Generation and Scenario & Data Management](#user-interface-generation-and-scenario--data-management)
-- [Contributing](#-contributing)
+- [Contributing](#%EF%B8%8F-contributing)
 - [Code of Conduct](#-code-of-conduct)
 - [License](#-license)
 

+ 28 - 0
doc/gui/examples/Alert.py

@@ -0,0 +1,28 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+severity = "error"
+variant = "filled"
+message = "This is an error message."
+
+page = """
+<|{message}|alert|severity={severity}|variant={variant}|>
+"""
+
+if __name__ == "__main__":
+    gui = Gui(page)
+    gui.run(title="Test Alert")

+ 1 - 0
doc/gui/examples/charts/matplotlib/__init__.py

@@ -0,0 +1 @@
+# This file makes this directory a module on its own, mandatory for mypy.

+ 44 - 0
doc/gui/examples/charts/matplotlib/builder.py

@@ -0,0 +1,44 @@
+# 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>
+# -----------------------------------------------------------------------------------------
+# This script needs to run in a Python environment where the Matplotlib library is
+# installed.
+# -----------------------------------------------------------------------------------------
+# Matplotlib example
+import numpy as np
+
+import matplotlib.pyplot as plt
+import taipy.gui.builder as tgb
+from taipy.gui import Gui
+
+fig = plt.figure(figsize=(5, 4))
+xx = np.arange(0, 2 * np.pi, 0.01)
+plot = fig.subplots(1, 1)
+plot.fill(xx, np.sin(xx), facecolor="none", edgecolor="purple", linewidth=2)
+
+with tgb.Page(
+    style={
+        ".matplotlib_example": {
+            "display": "inline-flex", "width": "520px", "height": "420px"
+            }
+        }
+) as page:
+    tgb.html("h1", "Taipy Example for Matplotlib Integration")
+    tgb.part(content="{fig}", class_name = "matplotlib_example")
+
+
+# Run the Taipy Application:
+if __name__ == "__main__":
+    Gui(page).run(title="Matplotlib Example")

+ 43 - 0
doc/gui/examples/charts/matplotlib/markdown.py

@@ -0,0 +1,43 @@
+# 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>
+# -----------------------------------------------------------------------------------------
+# This script needs to run in a Python environment where the Matplotlib library is
+# installed.
+# -----------------------------------------------------------------------------------------
+# Matplotlib example
+import numpy as np
+
+import matplotlib.pyplot as plt
+from taipy.gui import Gui, Markdown
+
+fig = plt.figure(figsize=(5,4))
+xx = np.arange(0, 2 * np.pi, 0.01)
+plot = fig.subplots(1, 1)
+plot.fill(xx, np.sin(xx), facecolor="none", edgecolor="purple", linewidth=2)
+
+page = Markdown("""
+# Taipy Example for Matplotlib Integration
+<|part|content={fig}|class_name=matplotlib_example|>
+""",style={
+    ".matplotlib_example": {
+        "display": "inline-flex",
+        "width": "520px",
+        "height": "420px"
+    }}
+)
+
+# Run the Taipy Application:
+if __name__ == "__main__":
+    Gui(page).run(title="Matplotlib Example")

+ 192 - 0
doc/gui/examples/controls/chat-streaming.py

@@ -0,0 +1,192 @@
+# 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
+import re
+import time
+import typing as t
+
+import requests  # type: ignore[import-untyped]
+
+from taipy.gui import Gui, Icon, State, get_state_id, invoke_callback, invoke_long_callback
+
+# The Wikipedia API used to generate content for a date
+wiki_url = "https://en.wikipedia.org/api/rest_v1/feed/onthisday/{type}/{month}/{day}"
+event_types = {
+    "happen": "events",
+    "passé": "events",
+    "born": "births",
+    "né": "births",
+    "dead": "deaths",
+    "mort": "deaths",
+}
+user_agent = "https://taipy.io/demo"
+
+# The list of messages
+messages: list[tuple[str, str, str]] = []  # (Message id, message, sender)
+
+# The two users of this app
+users = [
+    ["wikipedia", Icon("https://www.wikipedia.org/static/apple-touch/wikipedia.png", "Wikipedia")],
+    ["taipy", Icon("https://docs.taipy.io/en/latest/assets/images/favicon.png", "Taipy")],
+]
+
+
+# Initialize the user state
+def on_init(state: State):
+    #  Messages are for this user only
+    state.messages = []
+
+
+# Add the image if there is one in the Wikipedia returned data
+def add_image_to_message(state: State, idx: int, text: str, image_url: str):
+    msg_content: str = state.messages[idx][1]
+    if (pos := msg_content.find(text)) > -1:
+        msg_content = msg_content[: pos + len(text)] + f"\n\n![{text}]({image_url})" + msg_content[pos + len(text) :]
+        set_message(state, msg_content, idx)
+
+
+# Invoked by update_message through a thread
+def update_message_with_image(gui: Gui, state_id: str, message_idx: int, text: str, image: dict):
+    if src := image.get("source"):
+        time.sleep(0.2) # Apply the typewriter effect
+        invoke_callback(
+            gui,
+            state_id,
+            add_image_to_message,
+            [message_idx, text, src],
+        )
+
+
+# Invoked by query_wikipedia()
+def update_message(state: State, json, event_type: str, for_date: str, idx: int):
+    if isinstance(json, dict):
+        # Initial response content
+        set_message(state, f"{event_type} for {for_date}:\n", idx)
+
+        for event in json.get(event_type, []):
+            time.sleep(0.2) # Apply the typewriter effect
+            # Update response text
+            append_to_message(state, f"\n* {event.get('year', '')}: {event.get('text', '')}", idx)
+            # Invoke update_message_with_image() in a separated thread
+            invoke_long_callback(
+                state=state,
+                user_function=update_message_with_image,
+                user_function_args=[
+                    state.get_gui(),
+                    get_state_id(state),
+                    idx,
+                    event.get("text", ""),
+                    pages[0].get("thumbnail", {}) if (pages := event.get("pages", [])) and len(pages) else {},
+                ],
+            )
+
+
+# Set a new message or append to an existing message.
+# Return the message index in the list.
+def set_message(state: State, message: str, idx: t.Optional[int] = None):
+    if idx is not None and idx < len(state.messages):
+        msg = state.messages[idx]
+        state.messages[idx] = (msg[0], message, msg[2])
+    else:
+        idx = len(state.messages)
+        state.messages.append((f"{len(state.messages)}", message, users[0][0]))
+    state.refresh("messages")
+    return idx
+
+
+# Append text to an existing message
+def append_to_message(state: State, message: str, idx: int):
+    if idx < len(state.messages):
+        msg = state.messages[idx]
+        state.messages[idx] = (msg[0], f"{msg[1]}{message}", msg[2])
+        state.refresh("messages")
+    return idx
+
+
+# Invoke the Wikipedia API. This is invoked by send_message()
+def request_wikipedia(gui: Gui, state_id: str, event_type: str, month: str, day: str):
+    # Let the user known that a query was sent
+    idx = invoke_callback(
+        gui,
+        state_id,
+        set_message,
+        ["Fetching information from Wikipedia ..."],
+    )
+    request = wiki_url.format(type=event_type, month=month, day=day)
+    req = requests.get(request, headers={"accept": "application/json; charset=utf-8;", "User-Agent": user_agent})
+    # Handle the response
+    if req.status_code == 200:
+        # Display the response
+        invoke_callback(
+            gui,
+            state_id,
+            update_message,
+            [req.json(), event_type, f"{day}/{month}", idx],
+        )
+    else:
+        # Display the error
+        invoke_callback(
+            gui,
+            state_id,
+            set_message,
+            [f"Wikipedia API call failed: {req.status_code}", idx],
+        )
+
+
+# Invoked by the 'on_action' callback of the chat control when the user presses the Send button
+def send_message(state: State, id: str, payload: dict):
+    args = payload.get("args", [])
+
+    # Display the request
+    state.messages.append((f"{len(state.messages)}", args[2], args[3]))
+    state.refresh("messages")
+
+    # Analyse the request
+    request = args[2].lower()
+    type_event = None
+    for word in event_types:
+        if word in request:
+            type_event = event_types[word]
+            break
+    type_event = type_event if type_event else "events"
+
+    month = None
+    day = None
+    for m in re.finditer(r"(\d\d?)", request):
+        if month is None:
+            month = m.group()
+        elif day is None:
+            day = m.group()
+            break
+    if month is None:
+        month = f"{datetime.datetime.now().month}"
+    if day is None:
+        day = f"{datetime.datetime.now().day}"
+
+    # Process the request
+    invoke_long_callback(
+        state=state,
+        user_function=request_wikipedia,
+        user_function_args=[state.get_gui(), get_state_id(state), type_event, month, day],
+    )
+
+
+page = """
+<|{messages}|chat|users={users}|on_action=send_message|height=80vh|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Chat - Ask Wikipedia")

+ 28 - 0
doc/gui/examples/controls/date_range_with_time_analog_picker.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
+
+start_date = datetime.datetime(2023, 3, 26, 7, 37)
+end_date   = datetime.datetime(2023, 3, 26, 19, 2)
+dates = [start_date, end_date]
+
+# Note: |analogic| option only take effect if you put |with_time| before it
+page = "<|{dates}|date_range|with_time|analogic|>"
+
+if __name__ == "__main__":
+    Gui(page).run(title="Date Range - With time")

+ 26 - 0
doc/gui/examples/controls/date_with_time_analog_picker.py

@@ -0,0 +1,26 @@
+# 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.datetime(1789, 7, 14, 17, 5, 12)
+
+# Note: |analogic| option only take effect if you put |with_time| before it
+page = "<|{date}|date|with_time|analogic|>"
+
+if __name__ == "__main__":
+    Gui(page).run(title="Date - With time")

+ 37 - 0
doc/gui/examples/controls/file_selector_image.py

@@ -0,0 +1,37 @@
+# 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 io
+
+from PIL import Image
+
+from taipy.gui import Gui, State
+
+path = ""
+image = None
+
+def upload(state: State):
+    img = Image.open(state.path)
+    img_byte_arr = io.BytesIO()
+    img.save(img_byte_arr, format="PNG")
+    state.image = img_byte_arr.getvalue()
+
+page = """
+<|{path}|file_selector|on_action=upload|extensions=png,jpg|>
+<|{image}|image|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="File Selector - With image")

+ 25 - 0
doc/gui/examples/controls/file_selector_simple.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
+
+filename = "<unknown>"
+
+page = """<|{filename}|file_selector|>
+<|selected File: {filename}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="File Selector - Simple")

+ 26 - 0
doc/gui/examples/controls/menu_inactive.py

@@ -0,0 +1,26 @@
+# 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
+
+options = [("a", "Option A"), ("b", "Option B"), ("c", "Option C"), ("d", "Option D")]
+
+
+page = """
+<|menu|lov={options}|not active|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Menu - Inactive")

+ 26 - 0
doc/gui/examples/controls/menu_inactive_options.py

@@ -0,0 +1,26 @@
+# 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
+
+options = [("a", "Option A"), ("b", "Option B"), ("c", "Option C"), ("d", "Option D")]
+inactive_options = ["b", "d"]
+
+page = """
+<|menu|lov={options}|inactive_ids={inactive_options}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Menu - Inactive options")

+ 26 - 0
doc/gui/examples/controls/menu_label.py

@@ -0,0 +1,26 @@
+# 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
+
+options = [("a", "Option A"), ("b", "Option B"), ("c", "Option C"), ("d", "Option D")]
+
+
+page = """
+<|menu|label=menu|lov={options}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Menu - Label")

+ 30 - 0
doc/gui/examples/controls/menu_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.
+# -----------------------------------------------------------------------------------------
+# 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
+
+options = [("a", "Option A"), ("b", "Option B"), ("c", "Option C"), ("d", "Option D")]
+selected = ["a", "b"]
+
+def menu_action(state, id, payload):
+    if payload.get("args")[0] in state.selected:
+        print(f"Option {payload.get('args')[0]} is already selected") # noqa: F401, T201
+
+page = """
+<|menu|lov={options}|selected={selected}|on_action=menu_action|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Menu - On Action")

+ 27 - 0
doc/gui/examples/controls/menu_selected.py

@@ -0,0 +1,27 @@
+# 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
+
+options = [("a", "Option A"), ("b", "Option B"), ("c", "Option C"), ("d", "Option D")]
+selected = ["a", "b"]
+
+
+page = """
+<|menu|lov={options}|selected={selected}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Menu - Selected")

+ 26 - 0
doc/gui/examples/controls/menu_simple.py

@@ -0,0 +1,26 @@
+# 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
+
+options = [("a", "Option A"), ("b", "Option B"), ("c", "Option C"), ("d", "Option D")]
+
+
+page = """
+<|menu|lov={options}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Menu - Simple")

+ 38 - 0
doc/gui/examples/controls/slider_labels_dictionary.py

@@ -0,0 +1,38 @@
+# 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
+
+# Dictionary for slider labels
+labels = {
+    0: "$0",
+    20: "$20",
+    40: "$40",
+    60: "$60",
+    80: "$80",
+    100: "$100",
+}
+
+# Initial value of the slider
+value = 20
+
+page = """
+<|{value}|slider|labels={labels}|>
+
+Value: <|${value}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Slider - Labels with Dictionary")

+ 9 - 0
doc/gui/extension/example_library/example_library.py

@@ -33,6 +33,15 @@ class ExampleLibrary(ElementLibrary):
                 # element, exported as ExampleLabel in front-end/src/index.ts
                 react_component="ExampleLabel",
             ),
+            "game_table": Element(
+                "data",
+                {
+                    "data": ElementProperty(PropertyType.data),
+                },
+                # The name of the React component (GameTable) that implements this custom
+                # element, exported as GameTable in front-end/src/index.ts
+                # react_component="GameTable",
+            ),
         }
 
     # The implementation of the rendering for the "fraction" static element

+ 80 - 0
doc/gui/extension/example_library/front-end/src/GameTable.tsx

@@ -0,0 +1,80 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+    createRequestDataUpdateAction,
+    useDispatch,
+    useDispatchRequestUpdateOnFirstRender,
+    useModule,
+    TaipyDynamicProps,
+    TableValueType,
+    RowType,
+    RowValue,
+} from "taipy-gui";
+
+interface GameTableProps extends TaipyDynamicProps {
+    data: TableValueType;
+}
+
+const pageKey = "no-page-key";
+
+const GameTable = (props: GameTableProps) => {
+    const { data, updateVarName = "", updateVars = "", id } = props;
+    const [value, setValue] = useState<Record<string, Array<RowValue>>>({});
+    const dispatch = useDispatch();
+    const module = useModule();
+    const refresh = data?.__taipy_refresh !== undefined;
+    useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars);
+
+    const colsOrder = useMemo(() => {
+        return Object.keys(value);
+    }, [value]);
+
+    const rows = useMemo(() => {
+        const rows: RowType[] = [];
+        if (value) {
+            Object.entries(value).forEach(([col, colValues]) => {
+                    colValues.forEach((val, idx) => {
+                        rows[idx] = rows[idx] || {};
+                        rows[idx][col] = val;
+                    });
+            });
+        }
+        return rows;
+    }, [value]);
+
+    useEffect(() => {
+        if (refresh || !data || data[pageKey] === undefined) {
+            dispatch(
+                createRequestDataUpdateAction(
+                    updateVarName,
+                    id,
+                    module,
+                    colsOrder,
+                    pageKey,
+                    {},
+                    true,
+                    "ExampleLibrary",
+                ),
+            );
+        } else {
+            setValue(data[pageKey]);
+        }
+    }, [refresh, data, colsOrder, updateVarName, id, dispatch, module]);
+
+    return (
+        <div>
+            <table border={1} cellPadding={10} cellSpacing={0}>
+                <tbody>
+                    {rows.map((row, index) => (
+                        <tr key={"row" + index}>
+                            {colsOrder.map((col, cidx) => (
+                                <td key={"val" + index + "-" + cidx}>{row[col]}</td>
+                            ))}
+                        </tr>
+                    ))}
+                </tbody>
+            </table>
+        </div>
+    );
+};
+
+export default GameTable;

+ 2 - 1
doc/gui/extension/example_library/front-end/src/index.ts

@@ -7,5 +7,6 @@
 // Note that we export the 'ColoredLabel' component as 'ExampleLabel', which is
 // the name used in the element declaration in the element library.
 import ColoredLabel from "./ColoredLabel";
+import GameTable from "./GameTable";
 
-export { ColoredLabel as ExampleLabel };
+export { ColoredLabel as ExampleLabel, GameTable };

+ 32 - 0
doc/gui/extension/table_chess_game.py

@@ -0,0 +1,32 @@
+# 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 example_library import ExampleLibrary
+
+from taipy.gui import Gui
+
+data = [
+    ["♖", "♘", "♗", "♕", "♔", "♗", "♘", "♖"],
+    ["♙", "♙", "♙", "♙", "♙", "♙", "♙", "♙"],
+    ["", "", "", "", "", "", "", ""],
+    ["", "", "", "", "", "", "", ""],
+    ["", "", "", "", "", "", "", ""],
+    ["", "", "", "", "", "", "", ""],
+    ["♟", "♟", "♟", "♟", "♟", "♟", "♟", "♟"],
+    ["♜", "♞", "♝", "♛", "♚", "♝", "♞", "♜"]
+]
+
+page = """
+## Chess Game
+<|{data}|example.game_table|>
+"""
+
+if __name__ == "__main__":
+    Gui(page, libraries=[ExampleLibrary()]).run(title="Chess Game")

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

@@ -34,10 +34,10 @@ import {
     taipyInitialize,
     taipyReducer,
 } from "../context/taipyReducers";
-import Alert from "./Taipy/Alert";
 import UIBlocker from "./Taipy/UIBlocker";
 import Navigate from "./Taipy/Navigate";
 import Menu from "./Taipy/Menu";
+import TaipyNotification from "./Taipy/Notification";
 import GuiDownload from "./Taipy/GuiDownload";
 import ErrorFallback from "../utils/ErrorBoundary";
 import MainPage from "./pages/MainPage";
@@ -152,7 +152,7 @@ const Router = () => {
                                         ) : null}
                                     </Box>
                                     <ErrorBoundary FallbackComponent={ErrorFallback}>
-                                        <Alert alerts={state.alerts} />
+                                        <TaipyNotification alerts={state.alerts} />
                                         <UIBlocker block={state.block} />
                                         <Navigate
                                             to={state.navigateTo}

+ 26 - 176
frontend/taipy-gui/src/components/Taipy/Alert.spec.tsx

@@ -12,191 +12,41 @@
  */
 
 import React from "react";
-import { render, screen, waitFor } from "@testing-library/react";
+import { render } from "@testing-library/react";
 import "@testing-library/jest-dom";
-import { SnackbarProvider } from "notistack";
+import TaipyAlert from "./Alert";
 
-import Alert from "./Alert";
-import { AlertMessage } from "../../context/taipyReducers";
-import userEvent from "@testing-library/user-event";
-
-const defaultMessage = "message";
-const defaultAlerts: AlertMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
-const getAlertsWithType = (aType: string) => [{ ...defaultAlerts[0], atype: aType }];
-
-class myNotification {
-    static requestPermission = jest.fn(() => Promise.resolve("granted"));
-    static permission = "granted";
-}
-
-describe("Alert Component", () => {
-    beforeAll(() => {
-        globalThis.Notification = myNotification as unknown as jest.Mocked<typeof Notification>;
-    });
-    beforeEach(() => {
-        jest.clearAllMocks();
-    });
-    it("renders", async () => {
-        const { getByText } = render(
-            <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
-            </SnackbarProvider>,
-        );
-        const elt = getByText(defaultMessage);
-        expect(elt.tagName).toBe("DIV");
-    });
-    it("displays a success alert", async () => {
-        const { getByText } = render(
-            <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
-            </SnackbarProvider>,
-        );
-        const elt = getByText(defaultMessage);
-        expect(elt.closest(".notistack-MuiContent-success")).toBeInTheDocument();
-    });
-    it("displays an error alert", async () => {
-        const { getByText } = render(
-            <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("error")} />
-            </SnackbarProvider>,
-        );
-        const elt = getByText(defaultMessage);
-        expect(elt.closest(".notistack-MuiContent-error")).toBeInTheDocument();
-    });
-    it("displays a warning alert", async () => {
-        const { getByText } = render(
-            <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("warning")} />
-            </SnackbarProvider>,
-        );
-        const elt = getByText(defaultMessage);
-        expect(elt.closest(".notistack-MuiContent-warning")).toBeInTheDocument();
-    });
-    it("displays an info alert", async () => {
-        const { getByText } = render(
-            <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("info")} />
-            </SnackbarProvider>,
-        );
-        const elt = getByText(defaultMessage);
-        expect(elt.closest(".notistack-MuiContent-info")).toBeInTheDocument();
-    });
-    it("gets favicon URL from document link tags", () => {
-        const link = document.createElement("link");
-        link.rel = "icon";
-        link.href = "/test-icon.png";
-        document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
-        ];
-        render(
-            <SnackbarProvider>
-                <Alert alerts={alerts} />
-            </SnackbarProvider>,
-        );
-        const linkElement = document.querySelector("link[rel='icon']");
-        if (linkElement) {
-            expect(linkElement.getAttribute("href")).toBe("/test-icon.png");
-        } else {
-            expect(true).toBe(false);
-        }
-        document.head.removeChild(link);
-    });
-
-    it("closes alert on close button click", async () => {
-        const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
-        render(
-            <SnackbarProvider>
-                <Alert alerts={alerts} />
-            </SnackbarProvider>,
-        );
-        const closeButton = await screen.findByRole("button", { name: /close/i });
-        await userEvent.click(closeButton);
-        await waitFor(() => {
-            const alertMessage = screen.queryByText("Test Alert");
-            expect(alertMessage).not.toBeInTheDocument();
-        });
-    });
-
-    it("Alert disappears when alert type is empty", async () => {
-        const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
-        const { rerender } = render(
-            <SnackbarProvider>
-                <Alert alerts={alerts} />
-            </SnackbarProvider>,
-        );
-        await screen.findByRole("button", { name: /close/i });
-        const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false }];
-        rerender(
-            <SnackbarProvider>
-                <Alert alerts={newAlerts} />
-            </SnackbarProvider>,
-        );
-        await waitFor(() => {
-            const alertMessage = screen.queryByText("Test Alert");
-            expect(alertMessage).not.toBeInTheDocument();
-        });
+describe("TaipyAlert Component", () => {
+    it("renders with default properties", () => {
+        const { getByRole } = render(<TaipyAlert message="Default Alert" />);
+        const alert = getByRole("alert");
+        expect(alert).toBeInTheDocument();
+        expect(alert).toHaveClass("MuiAlert-filledError");
     });
 
-    it("does nothing when alert is undefined", async () => {
-        render(
-            <SnackbarProvider>
-                <Alert alerts={[]} />
-            </SnackbarProvider>,
-        );
-        expect(Notification.requestPermission).not.toHaveBeenCalled();
+    it("applies the correct severity", () => {
+        const { getByRole } = render(<TaipyAlert message="Warning Alert" severity="warning" />);
+        const alert = getByRole("alert");
+        expect(alert).toBeInTheDocument();
+        expect(alert).toHaveClass("MuiAlert-filledWarning");
     });
 
-    it("validates href when rel attribute is 'icon' and href is set", () => {
-        const link = document.createElement("link");
-        link.rel = "icon";
-        link.href = "/test-icon.png";
-        document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
-        ];
-        render(
-            <SnackbarProvider>
-                <Alert alerts={alerts} />
-            </SnackbarProvider>,
-        );
-        const linkElement = document.querySelector("link[rel='icon']");
-        expect(linkElement?.getAttribute("href")).toBe("/test-icon.png");
-        document.head.removeChild(link);
+    it("applies the correct variant", () => {
+        const { getByRole } = render(<TaipyAlert message="Outlined Alert" variant="outlined" />);
+        const alert = getByRole("alert");
+        expect(alert).toBeInTheDocument();
+        expect(alert).toHaveClass("MuiAlert-outlinedError");
     });
 
-    it("verifies default favicon for 'icon' rel attribute when href is unset/empty", () => {
-        const link = document.createElement("link");
-        link.rel = "icon";
-        document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
-        ];
-        render(
-            <SnackbarProvider>
-                <Alert alerts={alerts} />
-            </SnackbarProvider>,
-        );
-        const linkElement = document.querySelector("link[rel='icon']");
-        expect(linkElement?.getAttribute("href") || "/favicon.png").toBe("/favicon.png");
-        document.head.removeChild(link);
+    it("does not render if render prop is false", () => {
+        const { queryByRole } = render(<TaipyAlert message="Hidden Alert" render={false} />);
+        const alert = queryByRole("alert");
+        expect(alert).toBeNull();
     });
 
-    it("validates href when rel attribute is 'shortcut icon' and href is provided", () => {
-        const link = document.createElement("link");
-        link.rel = "shortcut icon";
-        link.href = "/test-shortcut-icon.png";
-        document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
-        ];
-        render(
-            <SnackbarProvider>
-                <Alert alerts={alerts} />
-            </SnackbarProvider>,
-        );
-        const linkElement = document.querySelector("link[rel='shortcut icon']");
-        expect(linkElement?.getAttribute("href")).toBe("/test-shortcut-icon.png");
-        document.head.removeChild(link);
+    it("handles dynamic class names", () => {
+        const { getByRole } = render(<TaipyAlert message="Dynamic Alert" className="custom-class" />);
+        const alert = getByRole("alert");
+        expect(alert).toHaveClass("custom-class");
     });
 });

+ 32 - 64
frontend/taipy-gui/src/components/Taipy/Alert.tsx

@@ -11,72 +11,40 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useCallback, useEffect, useMemo } from "react";
-import { SnackbarKey, useSnackbar, VariantType } from "notistack";
-import IconButton from "@mui/material/IconButton";
-import CloseIcon from "@mui/icons-material/Close";
-import { nanoid } from 'nanoid';
-
-import { AlertMessage, createDeleteAlertAction } from "../../context/taipyReducers";
-import { useDispatch } from "../../utils/hooks";
-
-interface AlertProps {
-    alerts: AlertMessage[];
+import React from "react";
+import Alert from "@mui/material/Alert";
+import { TaipyBaseProps } from "./utils";
+import { useClassNames, useDynamicProperty } from "../../utils/hooks";
+
+interface AlertProps extends TaipyBaseProps {
+    severity?: "error" | "warning" | "info" | "success";
+    message?: string;
+    variant?: "filled" | "outlined";
+    render?: boolean;
+    defaultMessage?: string;
+    defaultSeverity?: string;
+    defaultVariant?: string;
+    defaultRender?: boolean;
 }
 
-const Alert = ({ alerts }: AlertProps) => {
-    const alert = alerts.length ? alerts[0] : undefined;
-    const { enqueueSnackbar, closeSnackbar } = useSnackbar();
-    const dispatch = useDispatch();
-
-    const resetAlert = useCallback(
-        (key: SnackbarKey) => () => {
-            closeSnackbar(key);
-        },
-        [closeSnackbar]
-    );
-
-    const notifAction = useCallback(
-        (key: SnackbarKey) => (
-            <IconButton size="small" aria-label="close" color="inherit" onClick={resetAlert(key)}>
-                <CloseIcon fontSize="small" />
-            </IconButton>
-        ),
-        [resetAlert]
+const TaipyAlert = (props: AlertProps) => {
+    const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
+    const render = useDynamicProperty(props.render, props.defaultRender, true);
+    const severity = useDynamicProperty(props.severity, props.defaultSeverity, "error") as
+        | "error"
+        | "warning"
+        | "info"
+        | "success";
+    const variant = useDynamicProperty(props.variant, props.defaultVariant, "filled") as "filled" | "outlined";
+    const message = useDynamicProperty(props.message, props.defaultMessage, "");
+
+    if (!render) return null;
+
+    return (
+        <Alert severity={severity} variant={variant} id={props.id} className={className}>
+            {message}
+        </Alert>
     );
-
-    const faviconUrl = useMemo(() => {
-        const nodeList = document.getElementsByTagName("link");
-        for (let i = 0; i < nodeList.length; i++) {
-            if (nodeList[i].getAttribute("rel") == "icon" || nodeList[i].getAttribute("rel") == "shortcut icon") {
-                return nodeList[i].getAttribute("href") || "/favicon.png";
-            }
-        }
-        return "/favicon.png";
-    }, []);
-
-    useEffect(() => {
-        if (alert) {
-            const notificationId = nanoid(); 
-            if (alert.atype === "") {
-                closeSnackbar(notificationId);  
-            } else {
-                enqueueSnackbar(alert.message, {
-                    variant: alert.atype as VariantType,
-                    action: notifAction,  
-                    autoHideDuration: alert.duration,
-                    key: notificationId,  
-                });
-                alert.system && new Notification(document.title || "Taipy", { body: alert.message, icon: faviconUrl });
-            }
-            dispatch(createDeleteAlertAction(notificationId));
-        }
-    }, [alert, enqueueSnackbar, closeSnackbar, notifAction, faviconUrl, dispatch]);
-    useEffect(() => {
-        alert?.system && window.Notification && Notification.requestPermission();
-    }, [alert?.system]);
-
-    return null;
 };
 
-export default Alert;
+export default TaipyAlert;

+ 14 - 19
frontend/taipy-gui/src/components/Taipy/Menu.spec.tsx

@@ -1,16 +1,3 @@
-/*
- * 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 React from "react";
 import { render } from "@testing-library/react";
 import "@testing-library/jest-dom";
@@ -22,13 +9,13 @@ import { TaipyContext } from "../../context/taipyContext";
 import { LovItem } from "../../utils/lov";
 
 const lov: LovItem[] = [
-    {id: "id1", item: "Item 1"},
-    {id: "id2", item:"Item 2"},
-    {id: "id3", item:"Item 3"},
-    {id: "id4", item:"Item 4"},
+    { id: "id1", item: "Item 1" },
+    { id: "id2", item: "Item 2" },
+    { id: "id3", item: "Item 3" },
+    { id: "id4", item: "Item 4" },
 ];
 
-const imageItem: LovItem = {id: "ii1", item: { path: "/img/fred.png", text: "Image" }};
+const imageItem: LovItem = { id: "ii1", item: { path: "/img/fred.png", text: "Image" } };
 
 describe("Menu Component", () => {
     it("renders", async () => {
@@ -36,44 +23,52 @@ describe("Menu Component", () => {
         const elt = getByText("Item 1");
         expect(elt.tagName).toBe("SPAN");
     });
+
     it("uses the class", async () => {
         const { getByText } = render(<Menu lov={lov} className="taipy-menu" />);
         const elt = getByText("Item 1");
         expect(elt.closest(".taipy-menu")).not.toBeNull();
     });
+
     it("can display an avatar with initials", async () => {
         const lovWithImage = [...lov, imageItem];
         const { getByText } = render(<Menu lov={lovWithImage} />);
         const elt = getByText("I2");
         expect(elt.tagName).toBe("DIV");
     });
+
     it("can display an image", async () => {
         const lovWithImage = [...lov, imageItem];
         const { getByAltText } = render(<Menu lov={lovWithImage} />);
         const elt = getByAltText("Image");
         expect(elt.tagName).toBe("IMG");
     });
+
     it("is disabled", async () => {
         const { getAllByRole } = render(<Menu lov={lov} active={false} />);
         const elts = getAllByRole("button");
         elts.forEach((elt, idx) => idx > 0 && expect(elt).toHaveClass("Mui-disabled"));
     });
+
     it("is enabled by default", async () => {
         const { getAllByRole } = render(<Menu lov={lov} />);
         const elts = getAllByRole("button");
         elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
     });
+
     it("is enabled by active", async () => {
         const { getAllByRole } = render(<Menu lov={lov} active={true} />);
         const elts = getAllByRole("button");
         elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
     });
+
     it("can disable a specific item", async () => {
         const { getByText } = render(<Menu lov={lov} inactiveIds={[lov[0].id]} />);
         const elt = getByText(lov[0].item as string);
-        const button = elt.closest('[role="button"]')
+        const button = elt.closest('[role="button"]');
         expect(button).toHaveClass("Mui-disabled");
     });
+
     it("dispatch a well formed message", async () => {
         const dispatch = jest.fn();
         const state: TaipyState = INITIAL_STATE;

+ 34 - 16
frontend/taipy-gui/src/components/Taipy/Menu.tsx

@@ -20,7 +20,7 @@ import Avatar from "@mui/material/Avatar";
 import CardHeader from "@mui/material/CardHeader";
 import ListItemAvatar from "@mui/material/ListItemAvatar";
 import Box from "@mui/material/Box";
-import Tooltip from '@mui/material/Tooltip';
+import Tooltip from "@mui/material/Tooltip";
 import { Theme, useTheme } from "@mui/system";
 
 import { SingleItem } from "./lovUtils";
@@ -37,7 +37,7 @@ const baseTitleProps = { noWrap: true, variant: "h6" } as const;
 
 const Menu = (props: MenuProps) => {
     const { label, onAction = "", lov, width, inactiveIds = emptyArray, active = true } = props;
-    const [selectedValue, setSelectedValue] = useState("");
+    const [selectedValue, setSelectedValue] = useState<string>("");
     const [opened, setOpened] = useState(false);
     const dispatch = useDispatch();
     const theme = useTheme();
@@ -55,7 +55,7 @@ const Menu = (props: MenuProps) => {
                 });
             }
         },
-        [onAction, dispatch, active, module]
+        [active, dispatch, module, onAction]
     );
 
     const openHandler = useCallback((evt: MouseEvent<HTMLElement>) => {
@@ -63,23 +63,39 @@ const Menu = (props: MenuProps) => {
         setOpened((o) => !o);
     }, []);
 
+    const selected = useMemo(() => {
+        const selected = Array.isArray(props.selected) ? props.selected : [];
+        if (selectedValue && !selected.includes(selectedValue)) {
+            return [...selected, selectedValue];
+        }
+        return selected;
+    }, [props.selected, selectedValue]);
+
     const [drawerSx, titleProps] = useMemo(() => {
         const drawerWidth = opened ? width : `calc(${theme.spacing(9)} + 1px)`;
-        const titleWidth = opened ? `calc(${width} - ${theme.spacing(10)})`: undefined;
-        return [{
-            width: drawerWidth,
-            flexShrink: 0,
-            "& .MuiDrawer-paper": {
+        const titleWidth = opened ? `calc(${width} - ${theme.spacing(10)})` : undefined;
+        return [
+            {
                 width: drawerWidth,
-                boxSizing: "border-box",
+                flexShrink: 0,
+                "& .MuiDrawer-paper": {
+                    width: drawerWidth,
+                    boxSizing: "border-box",
+                    transition: "width 0.3s",
+                },
                 transition: "width 0.3s",
             },
-            transition: "width 0.3s",
-        }, {...baseTitleProps, width: titleWidth}];
+            { ...baseTitleProps, width: titleWidth },
+        ];
     }, [opened, width, theme]);
 
     return lov && lov.length ? (
-        <Drawer variant="permanent" anchor="left" sx={drawerSx} className={`${className} ${getComponentClassName(props.children)}`}>
+        <Drawer
+            variant="permanent"
+            anchor="left"
+            sx={drawerSx}
+            className={`${className} ${getComponentClassName(props.children)}`}
+        >
             <Box style={boxDrawerStyle}>
                 <List>
                     <ListItemButton key="taipy_menu_0" onClick={openHandler}>
@@ -87,9 +103,11 @@ const Menu = (props: MenuProps) => {
                             <CardHeader
                                 sx={headerSx}
                                 avatar={
-                                    <Tooltip title={label || false}><Avatar sx={avatarSx}>
-                                        <MenuIco />
-                                    </Avatar></Tooltip>
+                                    <Tooltip title={label || false}>
+                                        <Avatar sx={avatarSx}>
+                                            <MenuIco />
+                                        </Avatar>
+                                    </Tooltip>
                                 }
                                 title={label}
                                 titleTypographyProps={titleProps}
@@ -101,7 +119,7 @@ const Menu = (props: MenuProps) => {
                             key={elt.id}
                             value={elt.id}
                             item={elt.item}
-                            selectedValue={selectedValue}
+                            selectedValue={selected}
                             clickHandler={clickHandler}
                             disabled={!active || inactiveIds.includes(elt.id)}
                             withAvatar={true}

+ 1 - 0
frontend/taipy-gui/src/components/Taipy/MenuCtl.spec.tsx

@@ -86,6 +86,7 @@ describe("MenuCtl Component", () => {
                         },
                     ],
                     onAction: "on_action",
+                    selected: [],
                     width: "15vw",
                 },
                 type: "SET_MENU",

+ 33 - 29
frontend/taipy-gui/src/components/Taipy/MenuCtl.tsx

@@ -11,12 +11,19 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useMemo, useEffect } from "react";
+import React, {useMemo, useEffect} from "react";
 
-import { LovProps, useLovListMemo } from "./lovUtils";
-import { useClassNames, useDispatch, useDispatchRequestUpdateOnFirstRender, useDynamicProperty, useIsMobile, useModule } from "../../utils/hooks";
-import { createSetMenuAction } from "../../context/taipyReducers";
-import { MenuProps } from "../../utils/lov";
+import {LovProps, useLovListMemo} from "./lovUtils";
+import {
+    useClassNames,
+    useDispatch,
+    useDispatchRequestUpdateOnFirstRender,
+    useDynamicProperty,
+    useIsMobile,
+    useModule,
+} from "../../utils/hooks";
+import {createSetMenuAction} from "../../context/taipyReducers";
+import {MenuProps} from "../../utils/lov";
 
 interface MenuCtlProps extends LovProps<string> {
     label?: string;
@@ -25,17 +32,12 @@ interface MenuCtlProps extends LovProps<string> {
     onAction?: string;
     inactiveIds?: string[];
     defaultInactiveIds?: string;
+    selected?: string[];
+    defaultSelected?: string;
 }
 
 const MenuCtl = (props: MenuCtlProps) => {
-    const {
-        id,
-        label,
-        onAction,
-        defaultLov = "",
-        width = "15vw",
-        width_Mobile_ = "85vw",
-    } = props;
+    const {id, label, onAction, defaultLov = "", width = "15vw", width_Mobile_ = "85vw"} = props;
     const dispatch = useDispatch();
     const isMobile = useIsMobile();
     const module = useModule();
@@ -50,17 +52,29 @@ const MenuCtl = (props: MenuCtlProps) => {
     const inactiveIds = useMemo(() => {
         if (props.inactiveIds) {
             return props.inactiveIds;
-        }
-        if (props.defaultInactiveIds) {
+        } else if (props.defaultInactiveIds) {
             try {
                 return JSON.parse(props.defaultInactiveIds) as string[];
             } catch {
-                // too bad
+                console.error("Failed to parse defaultInactiveIds");
             }
         }
-        return [];
+        return [] as string[];
     }, [props.inactiveIds, props.defaultInactiveIds]);
 
+    const selected = useMemo(() => {
+        if (props.selected) {
+            return props.selected;
+        } else if (props.defaultSelected) {
+            try {
+                return JSON.parse(props.defaultSelected) as string[];
+            } catch (error) {
+                console.error("Failed to parse defaultSelected:", error);
+            }
+        }
+        return [] as string[];
+    }, [props.selected, props.defaultSelected]);
+
     useEffect(() => {
         dispatch(
             createSetMenuAction({
@@ -71,21 +85,11 @@ const MenuCtl = (props: MenuCtlProps) => {
                 inactiveIds: inactiveIds,
                 width: isMobile ? width_Mobile_ : width,
                 className: className,
+                selected: selected,
             } as MenuProps)
         );
         return () => dispatch(createSetMenuAction({}));
-    }, [
-        label,
-        onAction,
-        active,
-        lovList,
-        inactiveIds,
-        width,
-        width_Mobile_,
-        isMobile,
-        className,
-        dispatch,
-    ]);
+    }, [label, onAction, active, lovList, inactiveIds, width, width_Mobile_, isMobile, className, dispatch, selected]);
 
     return <></>;
 };

+ 12 - 2
frontend/taipy-gui/src/components/Taipy/Metric.spec.tsx

@@ -286,8 +286,18 @@ describe("Metric Component", () => {
         });
     });
 
-    it("processes type prop correctly when type is none", async () => {
-        const { container } = render(<Metric type="none"  />);
+    it("processes type prop correctly when type is none (string)", async () => {
+        const { container } = render(<Metric type="none" />);
+        await waitFor(() => {
+            const angularElm = container.querySelector(".angular");
+            const angularAxis = container.querySelector(".angularaxis");
+            expect(angularElm).not.toBeInTheDocument();
+            expect(angularAxis).not.toBeInTheDocument();
+        });
+    });
+    
+    it("processes type prop correctly when type is None", async () => {
+        const { container } = render(<Metric type="None" />);
         await waitFor(() => {
             const angularElm = container.querySelector(".angular");
             const angularAxis = container.querySelector(".angularaxis");

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

@@ -87,7 +87,7 @@ const Metric = (props: MetricProps) => {
     }, [props.colorMap, props.max]);
 
     const data = useMemo(() => {
-        const mode = props.type === "none" ? [] : ["gauge"];
+        const mode = typeof props.type === "string" && props.type.toLowerCase() === "none" ? [] : ["gauge"];
         showValue && mode.push("number");
         delta !== undefined && mode.push("delta");
         const deltaIncreasing = props.deltaColor

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

@@ -0,0 +1,202 @@
+/*
+ * 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 React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { SnackbarProvider } from "notistack";
+
+import Alert from "./Notification";
+import { AlertMessage } from "../../context/taipyReducers";
+import userEvent from "@testing-library/user-event";
+
+const defaultMessage = "message";
+const defaultAlerts: AlertMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
+const getAlertsWithType = (aType: string) => [{ ...defaultAlerts[0], atype: aType }];
+
+class myNotification {
+    static requestPermission = jest.fn(() => Promise.resolve("granted"));
+    static permission = "granted";
+}
+
+describe("Alert Component", () => {
+    beforeAll(() => {
+        globalThis.Notification = myNotification as unknown as jest.Mocked<typeof Notification>;
+    });
+    beforeEach(() => {
+        jest.clearAllMocks();
+    });
+    it("renders", async () => {
+        const { getByText } = render(
+            <SnackbarProvider>
+                <Alert alerts={defaultAlerts} />
+            </SnackbarProvider>,
+        );
+        const elt = getByText(defaultMessage);
+        expect(elt.tagName).toBe("DIV");
+    });
+    it("displays a success alert", async () => {
+        const { getByText } = render(
+            <SnackbarProvider>
+                <Alert alerts={defaultAlerts} />
+            </SnackbarProvider>,
+        );
+        const elt = getByText(defaultMessage);
+        expect(elt.closest(".notistack-MuiContent-success")).toBeInTheDocument();
+    });
+    it("displays an error alert", async () => {
+        const { getByText } = render(
+            <SnackbarProvider>
+                <Alert alerts={getAlertsWithType("error")} />
+            </SnackbarProvider>,
+        );
+        const elt = getByText(defaultMessage);
+        expect(elt.closest(".notistack-MuiContent-error")).toBeInTheDocument();
+    });
+    it("displays a warning alert", async () => {
+        const { getByText } = render(
+            <SnackbarProvider>
+                <Alert alerts={getAlertsWithType("warning")} />
+            </SnackbarProvider>,
+        );
+        const elt = getByText(defaultMessage);
+        expect(elt.closest(".notistack-MuiContent-warning")).toBeInTheDocument();
+    });
+    it("displays an info alert", async () => {
+        const { getByText } = render(
+            <SnackbarProvider>
+                <Alert alerts={getAlertsWithType("info")} />
+            </SnackbarProvider>,
+        );
+        const elt = getByText(defaultMessage);
+        expect(elt.closest(".notistack-MuiContent-info")).toBeInTheDocument();
+    });
+    it("gets favicon URL from document link tags", () => {
+        const link = document.createElement("link");
+        link.rel = "icon";
+        link.href = "/test-icon.png";
+        document.head.appendChild(link);
+        const alerts: AlertMessage[] = [
+            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        ];
+        render(
+            <SnackbarProvider>
+                <Alert alerts={alerts} />
+            </SnackbarProvider>,
+        );
+        const linkElement = document.querySelector("link[rel='icon']");
+        if (linkElement) {
+            expect(linkElement.getAttribute("href")).toBe("/test-icon.png");
+        } else {
+            expect(true).toBe(false);
+        }
+        document.head.removeChild(link);
+    });
+
+    it("closes alert on close button click", async () => {
+        const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
+        render(
+            <SnackbarProvider>
+                <Alert alerts={alerts} />
+            </SnackbarProvider>,
+        );
+        const closeButton = await screen.findByRole("button", { name: /close/i });
+        await userEvent.click(closeButton);
+        await waitFor(() => {
+            const alertMessage = screen.queryByText("Test Alert");
+            expect(alertMessage).not.toBeInTheDocument();
+        });
+    });
+
+    it("Alert disappears when alert type is empty", async () => {
+        const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
+        const { rerender } = render(
+            <SnackbarProvider>
+                <Alert alerts={alerts} />
+            </SnackbarProvider>,
+        );
+        await screen.findByRole("button", { name: /close/i });
+        const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
+        rerender(
+            <SnackbarProvider>
+                <Alert alerts={newAlerts} />
+            </SnackbarProvider>,
+        );
+        await waitFor(() => {
+            const alertMessage = screen.queryByText("Test Alert");
+            expect(alertMessage).not.toBeInTheDocument();
+        });
+    });
+
+    it("does nothing when alert is undefined", async () => {
+        render(
+            <SnackbarProvider>
+                <Alert alerts={[]} />
+            </SnackbarProvider>,
+        );
+        expect(Notification.requestPermission).not.toHaveBeenCalled();
+    });
+
+    it("validates href when rel attribute is 'icon' and href is set", () => {
+        const link = document.createElement("link");
+        link.rel = "icon";
+        link.href = "/test-icon.png";
+        document.head.appendChild(link);
+        const alerts: AlertMessage[] = [
+            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        ];
+        render(
+            <SnackbarProvider>
+                <Alert alerts={alerts} />
+            </SnackbarProvider>,
+        );
+        const linkElement = document.querySelector("link[rel='icon']");
+        expect(linkElement?.getAttribute("href")).toBe("/test-icon.png");
+        document.head.removeChild(link);
+    });
+
+    it("verifies default favicon for 'icon' rel attribute when href is unset/empty", () => {
+        const link = document.createElement("link");
+        link.rel = "icon";
+        document.head.appendChild(link);
+        const alerts: AlertMessage[] = [
+            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        ];
+        render(
+            <SnackbarProvider>
+                <Alert alerts={alerts} />
+            </SnackbarProvider>,
+        );
+        const linkElement = document.querySelector("link[rel='icon']");
+        expect(linkElement?.getAttribute("href") || "/favicon.png").toBe("/favicon.png");
+        document.head.removeChild(link);
+    });
+
+    it("validates href when rel attribute is 'shortcut icon' and href is provided", () => {
+        const link = document.createElement("link");
+        link.rel = "shortcut icon";
+        link.href = "/test-shortcut-icon.png";
+        document.head.appendChild(link);
+        const alerts: AlertMessage[] = [
+            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        ];
+        render(
+            <SnackbarProvider>
+                <Alert alerts={alerts} />
+            </SnackbarProvider>,
+        );
+        const linkElement = document.querySelector("link[rel='shortcut icon']");
+        expect(linkElement?.getAttribute("href")).toBe("/test-shortcut-icon.png");
+        document.head.removeChild(link);
+    });
+});

+ 81 - 0
frontend/taipy-gui/src/components/Taipy/Notification.tsx

@@ -0,0 +1,81 @@
+/*
+ * 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 React, { useCallback, useEffect, useMemo } from "react";
+import { SnackbarKey, useSnackbar, VariantType } from "notistack";
+import IconButton from "@mui/material/IconButton";
+import CloseIcon from "@mui/icons-material/Close";
+
+import { AlertMessage, createDeleteAlertAction } from "../../context/taipyReducers";
+import { useDispatch } from "../../utils/hooks";
+
+interface NotificationProps {
+    alerts: AlertMessage[];
+}
+
+const TaipyNotification = ({ alerts }: NotificationProps) => {
+    const alert = alerts.length ? alerts[0] : undefined;
+    const { enqueueSnackbar, closeSnackbar } = useSnackbar();
+    const dispatch = useDispatch();
+
+    const resetAlert = useCallback(
+        (key: SnackbarKey) => () => {
+            closeSnackbar(key);
+        },
+        [closeSnackbar]
+    );
+
+    const notifAction = useCallback(
+        (key: SnackbarKey) => (
+            <IconButton size="small" aria-label="close" color="inherit" onClick={resetAlert(key)}>
+                <CloseIcon fontSize="small" />
+            </IconButton>
+        ),
+        [resetAlert]
+    );
+
+    const faviconUrl = useMemo(() => {
+        const nodeList = document.getElementsByTagName("link");
+        for (let i = 0; i < nodeList.length; i++) {
+            if (nodeList[i].getAttribute("rel") == "icon" || nodeList[i].getAttribute("rel") == "shortcut icon") {
+                return nodeList[i].getAttribute("href") || "/favicon.png";
+            }
+        }
+        return "/favicon.png";
+    }, []);
+
+    useEffect(() => {
+        if (alert) {
+            const notificationId = alert.notificationId || "";
+            if (alert.atype === "") {
+                closeSnackbar(notificationId);
+            } else {
+                enqueueSnackbar(alert.message, {
+                    variant: alert.atype as VariantType,
+                    action: notifAction,
+                    autoHideDuration: alert.duration,
+                    key: notificationId,
+                });
+                alert.system && new Notification(document.title || "Taipy", { body: alert.message, icon: faviconUrl });
+            }
+            dispatch(createDeleteAlertAction(notificationId));
+        }
+    }, [alert, enqueueSnackbar, closeSnackbar, notifAction, faviconUrl, dispatch]);
+    useEffect(() => {
+        alert?.system && window.Notification && Notification.requestPermission();
+    }, [alert?.system]);
+
+    return null;
+};
+
+export default TaipyNotification;

+ 3 - 1
frontend/taipy-gui/src/components/Taipy/index.ts

@@ -40,6 +40,7 @@ import Selector from "./Selector";
 import Slider from "./Slider";
 import StatusList from "./StatusList";
 import Table from "./Table";
+import TaipyAlert from "./Alert";
 import TaipyStyle from "./TaipyStyle";
 import Toggle from "./Toggle";
 import TimeSelector from "./TimeSelector";
@@ -51,6 +52,7 @@ export const getRegisteredComponents = () => {
     if (registeredComponents.TreeView === undefined) {
         Object.entries({
             a: Link,
+            Alert: TaipyAlert,
             Button,
             Chat,
             Chart,
@@ -81,7 +83,7 @@ export const getRegisteredComponents = () => {
             Toggle,
             TreeView,
             Progress,
-        }).forEach(([name, comp]) => (registeredComponents[name] = comp  as ComponentType));
+        }).forEach(([name, comp]) => (registeredComponents[name] = comp as ComponentType));
         if (window.taipyConfig?.extensions) {
             Object.entries(window.taipyConfig.extensions).forEach(([libName, elements]) => {
                 if (elements && elements.length) {

+ 3 - 5
frontend/taipy-gui/src/components/Taipy/lovUtils.tsx

@@ -112,7 +112,7 @@ export const LovImage = ({
     item: Icon;
     disableTypo?: boolean;
     height?: string;
-    titleTypographyProps?: TypographyProps<"span", { component?: "span"; }>;
+    titleTypographyProps?: TypographyProps<"span", { component?: "span" }>;
 }) => {
     const sx = useMemo(
         () => (height ? { height: height, "& .MuiAvatar-img": { objectFit: "contain" } } : undefined) as SxProps,
@@ -121,9 +121,7 @@ export const LovImage = ({
     return (
         <CardHeader
             sx={cardSx}
-            avatar={
-                <IconAvatar img={item} sx={sx} />
-            }
+            avatar={<IconAvatar img={item} sx={sx} />}
             title={item.text}
             disableTypography={disableTypo}
             titleTypographyProps={titleTypographyProps}
@@ -147,7 +145,7 @@ export interface ItemProps {
     item: stringIcon;
     disabled: boolean;
     withAvatar?: boolean;
-    titleTypographyProps?: TypographyProps<"span", { component?: "span"; }>;
+    titleTypographyProps?: TypographyProps<"span", { component?: "span" }>;
 }
 
 export const SingleItem = ({

+ 1 - 0
frontend/taipy-gui/src/utils/lov.ts

@@ -33,4 +33,5 @@ export interface MenuProps extends TaipyBaseProps {
     inactiveIds?: string[];
     lov?: LovItem[];
     active?: boolean;
+    selected?: string[];
 }

File diff suppressed because it is too large
+ 161 - 539
frontend/taipy/package-lock.json


+ 20 - 11
frontend/taipy/src/DataNodeViewer.tsx

@@ -95,7 +95,7 @@ import { useUniqueId } from "./utils/hooks";
 import DataNodeChart from "./DataNodeChart";
 import DataNodeTable from "./DataNodeTable";
 
-const editTimestampFormat = "YYY/MM/dd HH:mm";
+const editTimestampFormat = "yyyy/MM/dd HH:mm";
 
 const tabBoxSx = { borderBottom: 1, borderColor: "divider" };
 const noDisplay = { display: "none" };
@@ -158,7 +158,7 @@ enum DatanodeDataProps {
     error,
 }
 
-interface DataNodeViewerProps extends  CoreProps {
+interface DataNodeViewerProps extends CoreProps {
     expandable?: boolean;
     expanded?: boolean;
     defaultDataNode?: string;
@@ -168,6 +168,7 @@ interface DataNodeViewerProps extends  CoreProps {
     showOwner?: boolean;
     showEditDate?: boolean;
     showExpirationDate?: boolean;
+    showCustomProperties?: boolean;
     showProperties?: boolean;
     showHistory?: boolean;
     showData?: boolean;
@@ -237,6 +238,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         showOwner = true,
         showEditDate = false,
         showExpirationDate = false,
+        showCustomProperties = true,
         showProperties = true,
         showHistory = true,
         showData = true,
@@ -281,7 +283,9 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
     const dtError = dnData[DatanodeDataProps.error];
 
     // Tabs
-    const [tabValue, setTabValue] = useState<TabValues>(TabValues.Data);
+    const [tabValue, setTabValue] = useState<TabValues | undefined>(
+        showData ? TabValues.Data : showProperties ? TabValues.Properties : showHistory ? TabValues.History : undefined
+    );
     const handleTabChange = useCallback(
         (_: SyntheticEvent, newValue: number) => {
             if (valid) {
@@ -378,14 +382,14 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                 );
             }
             if (!dn || isNewDn) {
-                setTabValue(showData ? TabValues.Data : TabValues.Properties);
+                (showData || showProperties || showHistory) && setTabValue(showData ? TabValues.Data : showProperties ? TabValues.Properties: showHistory ? TabValues.History: undefined);
             }
             if (!dn) {
                 return invalidDatanode;
             }
             editLock.current = dn[DataNodeFullProps.editInProgress];
             setHistoryRequested((req) => {
-                if (req && !isNewDn && tabValue == TabValues.History) {
+                if (req && showHistory && !isNewDn && tabValue == TabValues.History) {
                     const idVar = getUpdateVar(updateDnVars, "history_id");
                     const vars = getUpdateVarNames(updateVars, "history");
                     Promise.resolve().then(() =>
@@ -411,7 +415,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                 return false;
             });
             setPropertiesRequested((req) => {
-                if ((req || !showData) && tabValue == TabValues.Properties) {
+                if ((req || !showData) && showProperties && tabValue == TabValues.Properties) {
                     const idVar = getUpdateVar(updateDnVars, "properties_id");
                     const vars = getUpdateVarNames(updateVars, "dnProperties");
                     Promise.resolve().then(() =>
@@ -429,7 +433,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
             return dn;
         });
         // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [props.dataNode, props.defaultDataNode, showData, id, dispatch, module, props.onLock]);
+    }, [props.dataNode, props.defaultDataNode, showData, showProperties, showHistory, id, dispatch, module, props.onLock]);
 
     // clean lock on unmount
     useEffect(
@@ -665,14 +669,18 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
     useEffect(() => {
         const ids = coreChanged?.datanode;
         if ((typeof ids === "string" && ids === dnId) || (Array.isArray(ids) && ids.includes(dnId))) {
-            props.updateVarName &&
-                dispatch(createRequestUpdateAction(id, module, [props.updateVarName], true));
+            props.updateVarName && dispatch(createRequestUpdateAction(id, module, [props.updateVarName], true));
         }
     }, [coreChanged, props.updateVarName, id, module, dispatch, dnId]);
 
     return (
         <>
-            <Box sx={dnMainBoxSx} id={id} onClick={onFocus} className={`${className} ${getComponentClassName(props.children)}`}>
+            <Box
+                sx={dnMainBoxSx}
+                id={id}
+                onClick={onFocus}
+                className={`${className} ${getComponentClassName(props.children)}`}
+            >
                 <Accordion defaultExpanded={expanded} expanded={userExpanded} onChange={onExpand} disabled={!valid}>
                     <AccordionSummary
                         expandIcon={expandable ? <ArrowForwardIosSharp sx={AccordionIconSx} /> : null}
@@ -716,6 +724,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                         label="Properties"
                                         id={`${uniqId}-properties`}
                                         aria-controls={`${uniqId}-dn-tabpanel-properties`}
+                                        style={showProperties ? undefined : noDisplay}
                                     />
                                     <Tab
                                         label="History"
@@ -913,7 +922,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                             ? props.dnProperties
                                             : []
                                     }
-                                    show={showProperties}
+                                    show={showCustomProperties}
                                     focusName={focusName}
                                     setFocusName={setFocusName}
                                     onFocus={onFocus}

+ 1 - 1
taipy/gui/_default_config.py

@@ -46,7 +46,7 @@ default_config: Config = {
     "change_delay": None,
     "chart_dark_template": None,
     "base_url": "/",
-    "client_url": "http://localhost:{port}",
+    "client_url": None,
     "dark_mode": True,
     "dark_theme": None,
     "debug": False,

+ 11 - 1
taipy/gui/_page.py

@@ -17,6 +17,8 @@ import re
 import typing as t
 import warnings
 
+from ._warnings import TaipyGuiAlwaysWarning
+
 if t.TYPE_CHECKING:
     from ._renderers import Page
     from .gui import Gui
@@ -40,7 +42,15 @@ class _Page(object):
             warnings.resetwarnings()
             with gui._set_locals_context(self._renderer._get_module_name()):
                 self._rendered_jsx = self._renderer.render(gui)
-            if not silent:
+            if silent:
+                s = ""
+                for wm in w:
+                    if wm.category is TaipyGuiAlwaysWarning:
+                        s += f" - {wm.message}\n"
+                if s:
+                    logging.warning("\033[1;31m\n" + s)
+
+            else:
                 if (
                     self._rendered_jsx
                     and isinstance(self._rendered_jsx, str)

+ 5 - 2
taipy/gui/_renderers/builder.py

@@ -61,7 +61,7 @@ class _Builder:
 
     __BLOCK_CONTROLS = ["dialog", "expandable", "pane", "part"]
 
-    __TABLE_COLUMNS_DEPS = [
+    __TABLE_COLUMNS_DEPS = {
         "data",
         "columns",
         "date_format",
@@ -75,7 +75,10 @@ class _Builder:
         "style",
         "tooltip",
         "lov",
-    ]
+        "row_class_name",
+        "cell_class_name",
+        "format_fn"
+    }
 
     def __init__(
         self,

+ 21 - 4
taipy/gui/_renderers/factory.py

@@ -30,6 +30,7 @@ class _Factory:
     __TAIPY_NAME_SPACE = "taipy."
 
     __CONTROL_DEFAULT_PROP_NAME = {
+        "alert": "message",
         "button": "label",
         "chat": "messages",
         "chart": "data",
@@ -70,6 +71,21 @@ class _Factory:
     __LIBRARIES: t.Dict[str, t.List["ElementLibrary"]] = {}
 
     __CONTROL_BUILDERS = {
+        "alert":
+        lambda gui, control_type, attrs: _Builder(
+            gui=gui,
+            control_type=control_type,
+            element_name="Alert",
+            attributes=attrs,
+        )
+        .set_value_and_default(var_type=PropertyType.dynamic_string)
+        .set_attributes(
+            [
+                ("severity", PropertyType.dynamic_string),
+                ("variant", PropertyType.dynamic_string),
+                ("render", PropertyType.dynamic_boolean, True),
+            ]
+        ),
         "button": lambda gui, control_type, attrs: _Builder(
             gui=gui,
             control_type=control_type,
@@ -339,14 +355,15 @@ class _Factory:
         )
         .set_attributes(
             [
-                ("active", PropertyType.dynamic_boolean, True),
+                ("lov", PropertyType.lov),
                 ("label",),
-                ("width",),
-                ("width[mobile]",),
                 ("on_action", PropertyType.function),
+                ("selected", PropertyType.dynamic_list),
                 ("inactive_ids", PropertyType.dynamic_list),
+                ("active", PropertyType.dynamic_boolean, True),
                 ("hover_text", PropertyType.dynamic_string),
-                ("lov", PropertyType.lov),
+                ("width",),
+                ("width[mobile]",),
             ]
         )
         ._set_propagate(),

+ 13 - 4
taipy/gui/_warnings.py

@@ -30,15 +30,24 @@ class TaipyGuiWarning(UserWarning):
         )
 
 
-def _warn(message: str, e: t.Optional[BaseException] = None):
+class TaipyGuiAlwaysWarning(TaipyGuiWarning):
+    pass
+
+
+def _warn(
+    message: str,
+    e: t.Optional[BaseException] = None,
+    always_show: t.Optional[bool] = False,
+):
     warnings.warn(
         (
-            f"{message}:\n{''.join(traceback.format_exception(type(e), e, e.__traceback__))}"
+            f"{message}:\n{''.join(traceback.format_exception(e))}"
             if e and TaipyGuiWarning._tp_debug_mode
-            else f"{message}:\n{e}"
+            else f"{message}:\n"
+            + "".join(traceback.format_exception(None, e, e.__traceback__.tb_next if e.__traceback__ else None))
             if e
             else message
         ),
-        TaipyGuiWarning,
+        TaipyGuiWarning if not always_show else TaipyGuiAlwaysWarning,
         stacklevel=2,
     )

+ 1 - 1
taipy/gui/config.py

@@ -108,7 +108,7 @@ Config = t.TypedDict(
         "change_delay": t.Optional[int],
         "chart_dark_template": t.Optional[t.Dict[str, t.Any]],
         "base_url": t.Optional[str],
-        "client_url": str,
+        "client_url": t.Optional[str],
         "dark_mode": bool,
         "dark_theme": t.Optional[t.Dict[str, t.Any]],
         "data_url_max_size": t.Optional[int],

+ 22 - 6
taipy/gui/extension/library.py

@@ -16,7 +16,7 @@ import xml.etree.ElementTree as etree
 from abc import ABC, abstractmethod
 from inspect import isclass
 from pathlib import Path
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlparse
 
 from .._renderers.builder import _Builder
 from .._warnings import _warn
@@ -115,7 +115,7 @@ class Element:
             default_property (str): The name of the default property for this element.
             properties (Dict[str, ElementProperty]): The dictionary containing the properties of this element, where the keys are the property names and the values are instances of ElementProperty.
             inner_properties (Optional[List[ElementProperty]]): The optional list of inner properties for this element.<br/>
-                Default values are set/binded automatically.
+                Default values are set/bound automatically.
             react_component (Optional[str]): The name of the component to be created on the front-end.<br/>
                 If not specified, it is set to a camel case version of the element's name
                 ("one_name" is transformed to "OneName").
@@ -324,9 +324,27 @@ class ElementLibrary(ABC):
         """
         return _to_camel_case(self.get_name(), True)
 
+    def __get_class_folder(self):
+        if not hasattr(self, "_class_folder"):
+            module_obj = sys.modules.get(self.__class__.__module__)
+            base = (Path(".") if module_obj is None else Path(module_obj.__file__).parent).resolve()  # type: ignore
+            self._class_folder = base if base.exists() else Path(".").resolve()
+        return self._class_folder
+
+    def _do_get_relative_paths(self, paths: t.List[str]) -> t.List[str]:
+        ret = set()
+        for path in paths or []:
+            if bool(urlparse(path).netloc):
+                ret.add(path)
+            elif file_paths := self.__get_class_folder().glob(path):
+                ret.update([file_path.relative_to(self.__get_class_folder()).as_posix() for file_path in file_paths])
+            elif path:
+                ret.add(path)
+        return list(ret)
+
     def get_scripts(self) -> t.List[str]:
         """
-        Return the list of the mandatory script file pathnames.
+        Return the list of the mandatory script file path names.
 
         If a script file pathname is an absolute URL it will be used as is.<br/>
         If it's not it will be passed to `(ElementLibrary.)get_resource()^` to retrieve a local
@@ -363,9 +381,7 @@ class ElementLibrary(ABC):
         Arguments:
             name (str): The name of the resource for which a local Path should be returned.
         """  # noqa: E501
-        module_obj = sys.modules.get(self.__class__.__module__)
-        base = (Path(".") if module_obj is None else Path(module_obj.__file__).parent).resolve()  # type: ignore
-        base = base if base.exists() else Path(".").resolve()
+        base = self.__get_class_folder()
         file = (base / name).resolve()
         if str(file).startswith(str(base)) and file.exists():
             return file

+ 12 - 13
taipy/gui/gui.py

@@ -73,7 +73,7 @@ from .extension.library import Element, ElementLibrary
 from .page import Page
 from .partial import Partial
 from .server import _Server
-from .state import State
+from .state import State, _GuiState
 from .types import _WsType
 from .utils import (
     _delscopeattr,
@@ -1332,12 +1332,8 @@ class Gui:
         )
 
     def __send_ws_alert(
-            self, type: str,
-            message: str,
-            system_notification: bool,
-            duration: int,
-            notification_id: t.Optional[str] = None
-        ) -> None:
+        self, type: str, message: str, system_notification: bool, duration: int, notification_id: t.Optional[str] = None
+    ) -> None:
         payload = {
             "type": _WsType.ALERT.value,
             "atype": type,
@@ -2278,10 +2274,9 @@ class Gui:
                 message="",  # No need for a message when closing
                 system_notification=False,  # System notification not needed for closing
                 duration=0,  # No duration since it's an immediate close
-                notification_id=notification_id
+                notification_id=notification_id,
             )
 
-
     def _hold_actions(
         self,
         callback: t.Optional[t.Union[str, t.Callable]] = None,
@@ -2292,7 +2287,9 @@ class Gui:
             if isinstance(callback, str)
             else _get_lambda_id(t.cast(LambdaType, callback))
             if _is_unnamed_function(callback)
-            else callback.__name__ if callback is not None else None
+            else callback.__name__
+            if callback is not None
+            else None
         )
         func = self.__get_on_cancel_block_ui(action_name)
         def_action_name = func.__name__
@@ -2659,13 +2656,13 @@ class Gui:
             s if bool(urlparse(s).netloc) else f"{Gui._EXTENSION_ROOT}/{name}/{s}{lib.get_query(s)}"
             for name, libs in Gui.__extensions.items()
             for lib in libs
-            for s in (lib.get_scripts() or [])
+            for s in (lib._do_get_relative_paths(lib.get_scripts()))
         ]
         styles = [
             s if bool(urlparse(s).netloc) else f"{Gui._EXTENSION_ROOT}/{name}/{s}{lib.get_query(s)}"
             for name, libs in Gui.__extensions.items()
             for lib in libs
-            for s in (lib.get_styles() or [])
+            for s in (lib._do_get_relative_paths(lib.get_styles()))
         ]
         if self._get_config("stylekit", True):
             styles.append("stylekit/stylekit.css")
@@ -2809,7 +2806,9 @@ class Gui:
         self.__var_dir.set_default(self.__frame)
 
         if self.__state is None or is_reloading:
-            self.__state = State(self, self.__locals_context.get_all_keys(), self.__locals_context.get_all_context())
+            self.__state = _GuiState(
+                self, self.__locals_context.get_all_keys(), self.__locals_context.get_all_context()
+            )
 
         if _is_in_notebook():
             # Allow gui.state.x in notebook mode

+ 10 - 0
taipy/gui/mock/__init__.py

@@ -0,0 +1,10 @@
+# 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.

+ 62 - 0
taipy/gui/mock/mock_state.py

@@ -0,0 +1,62 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+import typing as t
+
+from .. import Gui, State
+from ..utils import _MapDict
+
+
+class MockState(State):
+    """A Mock implementation for `State`.
+    TODO
+    example of use:
+    ```py
+    def test_callback():
+        ms = MockState(Gui(""), a = 1)
+        on_action(ms) # function to test
+        assert ms.a == 2
+    ```
+    """
+
+    __VARS = "vars"
+
+    def __init__(self, gui: Gui, **kwargs) -> None:
+        super().__setattr__(MockState.__VARS, {k: _MapDict(v) if isinstance(v, dict) else v for k, v in kwargs.items()})
+        self._gui = gui
+        super().__init__()
+
+    def get_gui(self) -> Gui:
+        return self._gui
+
+    def __getattribute__(self, name: str) -> t.Any:
+        if (attr := t.cast(dict, super().__getattribute__(MockState.__VARS)).get(name, None)) is not None:
+            return attr
+        try:
+            return super().__getattribute__(name)
+        except Exception:
+            return None
+
+    def __setattr__(self, name: str, value: t.Any) -> None:
+        t.cast(dict, super().__getattribute__(MockState.__VARS))[name] = (
+            _MapDict(value) if isinstance(value, dict) else value
+        )
+
+    def __getitem__(self, key: str):
+        return self
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        return True
+
+    def broadcast(self, name: str, value: t.Any):
+        pass

+ 10 - 8
taipy/gui/server.py

@@ -30,6 +30,7 @@ from flask import (
     jsonify,
     make_response,
     render_template,
+    render_template_string,
     request,
     send_from_directory,
 )
@@ -169,9 +170,8 @@ class _Server:
             if resource_handler_id is not None:
                 resource_handler = _ExternalResourceHandlerManager().get(resource_handler_id)
                 if resource_handler is None:
-                    response = make_response(
-                        "Cookie was deleted due to invalid resource handler id. Please restart the page manually.", 400
-                    )
+                    reload_html = "<html><head><style>body {background-color: black; margin: 0;}</style></head><body><script>location.reload();</script></body></html>"  # noqa: E501
+                    response = make_response(render_template_string(reload_html), 400)
                     response.set_cookie(
                         _Server._RESOURCE_HANDLER_ARG, "", secure=request.is_secure, httponly=True, expires=0, path="/"
                     )
@@ -317,8 +317,8 @@ class _Server:
         self._host = host
         if port == "auto":
             port = self._get_random_port(port_auto_ranges)
+        server_url = f"http://{host_value}:{port}"
         self._port = port
-        client_url = client_url.format(port=port)
         if _is_in_notebook() and notebook_proxy:  # pragma: no cover
             from .utils.proxy import NotebookProxy
 
@@ -337,12 +337,14 @@ class _Server:
             log = logging.getLogger("werkzeug")
             log.disabled = True
             if not is_running_from_reloader():
-                _TaipyLogger._get_logger().info(f" * Server starting on http://{host_value}:{port}")
+                _TaipyLogger._get_logger().info(f" * Server starting on {server_url}")
             else:
-                _TaipyLogger._get_logger().info(f" * Server reloaded on http://{host_value}:{port}")
-            _TaipyLogger._get_logger().info(f" * Application is accessible at {client_url}")
+                _TaipyLogger._get_logger().info(f" * Server reloaded on {server_url}")
+            if client_url is not None:
+                client_url = client_url.format(port=port)
+                _TaipyLogger._get_logger().info(f" * Application is accessible at {client_url}")
         if not is_running_from_reloader() and self._gui._get_config("run_browser", False):
-            webbrowser.open(client_url, new=2)
+            webbrowser.open(client_url or server_url, new=2)
         if _is_in_notebook() or run_in_thread:
             self._thread = KThread(target=self._run_notebook)
             self._thread.start()

+ 110 - 95
taipy/gui/state.py

@@ -11,10 +11,11 @@
 
 import inspect
 import typing as t
+from abc import abstractmethod
 from contextlib import nullcontext
 from operator import attrgetter
 from pathlib import Path
-from types import FrameType
+from types import FrameType, SimpleNamespace
 
 from flask import has_app_context
 
@@ -25,7 +26,7 @@ if t.TYPE_CHECKING:
     from .gui import Gui
 
 
-class State:
+class State(SimpleNamespace):
     """Accessor to the bound variables from callbacks.
 
     `State` is used when you need to access the value of variables
@@ -73,6 +74,87 @@ class State:
     ```
     """
 
+    def __init__(self) -> None:
+        self._gui: "Gui"
+
+    @abstractmethod
+    def get_gui(self) -> "Gui":
+        """Return the Gui instance for this state object.
+
+        Returns:
+            Gui: The Gui instance for this state object.
+        """
+        raise NotImplementedError
+
+    def assign(self, name: str, value: t.Any) -> t.Any:
+        """Assign a value to a state variable.
+
+        This should be used only from within a lambda function used
+        as a callback in a visual element.
+
+        Arguments:
+            name (str): The variable name to assign to.
+            value (Any): The new variable value.
+
+        Returns:
+            Any: The previous value of the variable.
+        """
+        val = attrgetter(name)(self)
+        _attrsetter(self, name, value)
+        return val
+
+    def refresh(self, name: str):
+        """Refresh a state variable.
+
+        This allows to re-sync the user interface with a variable value.
+
+        Arguments:
+            name (str): The variable name to refresh.
+        """
+        val = attrgetter(name)(self)
+        _attrsetter(self, name, val)
+
+    def _set_context(self, gui: "Gui") -> t.ContextManager[None]:
+        return nullcontext()
+
+    def broadcast(self, name: str, value: t.Any):
+        """Update a variable on all clients.
+
+        All connected clients will receive an update of the variable called *name* with the
+        provided value, even if it is not shared.
+
+        Arguments:
+            name (str): The variable name to update.
+            value (Any): The new variable value.
+        """
+        with self._set_context(self._gui):
+            encoded_name = self._gui._bind_var(name)
+            self._gui._broadcast_all_clients(encoded_name, value)
+
+    def __enter__(self):
+        self._gui.__enter__()
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        return self._gui.__exit__(exc_type, exc_value, traceback)
+
+    def set_favicon(self, favicon_path: t.Union[str, Path]):
+        """Change the favicon for the client of this state.
+
+        This function dynamically changes the favicon (the icon associated with the application's
+        pages) of Taipy GUI pages for the specific client of this state.
+
+        Note that the *favicon* parameter to `(Gui.)run()^` can also be used to change
+        the favicon when the application starts.
+
+        Arguments:
+            favicon_path: The path to the image file to use.<br/>
+                This can be expressed as a path name or a URL (relative or not).
+        """
+        self._gui.set_favicon(favicon_path, self)
+
+
+class _GuiState(State):
     __gui_attr = "_gui"
     __attrs = (
         __gui_attr,
@@ -100,68 +182,66 @@ class State:
     __excluded_attrs = __attrs + __methods + __placeholder_attrs
 
     def __init__(self, gui: "Gui", var_list: t.Iterable[str], context_list: t.Iterable[str]) -> None:
-        super().__setattr__(State.__attrs[1], list(State.__filter_var_list(var_list, State.__excluded_attrs)))
-        super().__setattr__(State.__attrs[2], list(context_list))
-        super().__setattr__(State.__attrs[0], gui)
-
-    def get_gui(self) -> "Gui":
-        """Return the Gui instance for this state object.
-
-        Returns:
-            Gui: The Gui instance for this state object.
-        """
-        return super().__getattribute__(State.__gui_attr)
+        super().__setattr__(
+            _GuiState.__attrs[1], list(_GuiState.__filter_var_list(var_list, _GuiState.__excluded_attrs))
+        )
+        super().__setattr__(_GuiState.__attrs[2], list(context_list))
+        super().__setattr__(_GuiState.__attrs[0], gui)
+        super().__init__()
 
     @staticmethod
     def __filter_var_list(var_list: t.Iterable[str], excluded_attrs: t.Iterable[str]) -> t.Iterable[str]:
         return filter(lambda n: n not in excluded_attrs, var_list)
 
+    def get_gui(self) -> "Gui":
+        return super().__getattribute__(_GuiState.__gui_attr)
+
     def __getattribute__(self, name: str) -> t.Any:
         if name == "__class__":
-            return State
-        if name in State.__methods:
+            return _GuiState
+        if name in _GuiState.__methods:
             return super().__getattribute__(name)
         gui: "Gui" = self.get_gui()
-        if name == State.__gui_attr:
+        if name == _GuiState.__gui_attr:
             return gui
-        if name in State.__excluded_attrs:
+        if name in _GuiState.__excluded_attrs:
             raise AttributeError(f"Variable '{name}' is protected and is not accessible.")
         if gui._is_in_brdcst_callback() and (
             name not in gui._get_shared_variables() and not gui._bindings()._is_single_client()
         ):
             raise AttributeError(f"Variable '{name}' is not available to be accessed in shared callback.")
-        if not name.startswith("__") and name not in super().__getattribute__(State.__attrs[1]):
+        if not name.startswith("__") and name not in super().__getattribute__(_GuiState.__attrs[1]):
             raise AttributeError(f"Variable '{name}' is not defined.")
         with self._notebook_context(gui), self._set_context(gui):
             encoded_name = gui._bind_var(name)
             return getattr(gui._bindings(), encoded_name)
 
     def __setattr__(self, name: str, value: t.Any) -> None:
-        gui: "Gui" = super().__getattribute__(State.__gui_attr)
+        gui: "Gui" = super().__getattribute__(_GuiState.__gui_attr)
         if gui._is_in_brdcst_callback() and (
             name not in gui._get_shared_variables() and not gui._bindings()._is_single_client()
         ):
             raise AttributeError(f"Variable '{name}' is not available to be accessed in shared callback.")
-        if not name.startswith("__") and name not in super().__getattribute__(State.__attrs[1]):
+        if not name.startswith("__") and name not in super().__getattribute__(_GuiState.__attrs[1]):
             raise AttributeError(f"Variable '{name}' is not accessible.")
         with self._notebook_context(gui), self._set_context(gui):
             encoded_name = gui._bind_var(name)
             setattr(gui._bindings(), encoded_name, value)
 
     def __getitem__(self, key: str):
-        context = key if key in super().__getattribute__(State.__attrs[2]) else None
+        context = key if key in super().__getattribute__(_GuiState.__attrs[2]) else None
         if context is None:
-            gui: "Gui" = super().__getattribute__(State.__gui_attr)
+            gui: "Gui" = super().__getattribute__(_GuiState.__gui_attr)
             page_ctx = gui._get_page_context(key)
             context = page_ctx if page_ctx is not None else None
         if context is None:
             raise RuntimeError(f"Can't resolve context '{key}' from state object")
-        self._set_placeholder(State.__placeholder_attrs[1], context)
+        self._set_placeholder(_GuiState.__placeholder_attrs[1], context)
         return self
 
     def _set_context(self, gui: "Gui") -> t.ContextManager[None]:
-        if (pl_ctx := self._get_placeholder(State.__placeholder_attrs[1])) is not None:
-            self._set_placeholder(State.__placeholder_attrs[1], None)
+        if (pl_ctx := self._get_placeholder(_GuiState.__placeholder_attrs[1])) is not None:
+            self._set_placeholder(_GuiState.__placeholder_attrs[1], None)
             if pl_ctx != gui._get_locals_context():
                 return gui._set_locals_context(pl_ctx)
         if len(inspect.stack()) > 1:
@@ -176,7 +256,7 @@ class State:
         return gui.get_flask_app().app_context() if not has_app_context() and _is_in_notebook() else nullcontext()
 
     def _get_placeholder(self, name: str):
-        if name in State.__placeholder_attrs:
+        if name in _GuiState.__placeholder_attrs:
             try:
                 return super().__getattribute__(name)
             except AttributeError:
@@ -184,81 +264,16 @@ class State:
         return None
 
     def _set_placeholder(self, name: str, value: t.Any):
-        if name in State.__placeholder_attrs:
+        if name in _GuiState.__placeholder_attrs:
             super().__setattr__(name, value)
 
     def _get_placeholder_attrs(self):
-        return State.__placeholder_attrs
+        return _GuiState.__placeholder_attrs
 
     def _add_attribute(self, name: str, default_value: t.Optional[t.Any] = None) -> bool:
-        attrs: t.List[str] = super().__getattribute__(State.__attrs[1])
+        attrs: t.List[str] = super().__getattribute__(_GuiState.__attrs[1])
         if name not in attrs:
             attrs.append(name)
-            gui = super().__getattribute__(State.__gui_attr)
+            gui = super().__getattribute__(_GuiState.__gui_attr)
             return gui._bind_var_val(name, default_value)
         return False
-
-    def assign(self, name: str, value: t.Any) -> t.Any:
-        """Assign a value to a state variable.
-
-        This should be used only from within a lambda function used
-        as a callback in a visual element.
-
-        Arguments:
-            name (str): The variable name to assign to.
-            value (Any): The new variable value.
-
-        Returns:
-            Any: The previous value of the variable.
-        """
-        val = attrgetter(name)(self)
-        _attrsetter(self, name, value)
-        return val
-
-    def refresh(self, name: str):
-        """Refresh a state variable.
-
-        This allows to re-sync the user interface with a variable value.
-
-        Arguments:
-            name (str): The variable name to refresh.
-        """
-        val = attrgetter(name)(self)
-        _attrsetter(self, name, val)
-
-    def broadcast(self, name: str, value: t.Any):
-        """Update a variable on all clients.
-
-        All connected clients will receive an update of the variable called *name* with the
-        provided value, even if it is not shared.
-
-        Arguments:
-            name (str): The variable name to update.
-            value (Any): The new variable value.
-        """
-        gui: "Gui" = super().__getattribute__(State.__gui_attr)
-        with self._set_context(gui):
-            encoded_name = gui._bind_var(name)
-            gui._broadcast_all_clients(encoded_name, value)
-
-    def __enter__(self):
-        super().__getattribute__(State.__attrs[0]).__enter__()
-        return self
-
-    def __exit__(self, exc_type, exc_value, traceback):
-        return super().__getattribute__(State.__attrs[0]).__exit__(exc_type, exc_value, traceback)
-
-    def set_favicon(self, favicon_path: t.Union[str, Path]):
-        """Change the favicon for the client of this state.
-
-        This function dynamically changes the favicon (the icon associated with the application's
-        pages) of Taipy GUI pages for the specific client of this state.
-
-        Note that the *favicon* parameter to `(Gui.)run()^` can also be used to change
-        the favicon when the application starts.
-
-        Arguments:
-            favicon_path: The path to the image file to use.<br/>
-                This can be expressed as a path name or a URL (relative or not).
-        """
-        super().__getattribute__(State.__gui_attr).set_favicon(favicon_path, self)

+ 13 - 3
taipy/gui/utils/_evaluator.py

@@ -47,6 +47,7 @@ class _Evaluator:
     __EXPR_EDGE_CASE_F_STRING = re.compile(r"[\{]*[a-zA-Z_][a-zA-Z0-9_]*:.+")
     __IS_TAIPY_EXPR_RE = re.compile(r"TpExPr_(.*)")
     __IS_ARRAY_EXPR_RE = re.compile(r"[^[]*\[(\d+)][^]]*")
+    __CLEAN_LAMBDA_RE = re.compile(r"^__lambda_[\d_]+(TPMDL_\d+)?(.*)$")
 
     def __init__(self, default_bindings: t.Dict[str, t.Any], shared_variable: t.List[str]) -> None:
         # key = expression, value = hashed value of the expression
@@ -260,7 +261,12 @@ class _Evaluator:
             with gui._get_authorization():
                 expr_evaluated = eval(not_encoded_expr if is_edge_case else expr_string, ctx)
         except Exception as e:
-            _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
+            exception_str = not_encoded_expr if is_edge_case else expr_string
+            _warn(
+                f"Cannot evaluate expression '{_Evaluator._clean_exception_expr(exception_str)}'",
+                e,
+                always_show=True,
+            )
             expr_evaluated = None
         if lambda_expr and callable(expr_evaluated):
             expr_hash = _get_lambda_id(expr_evaluated, module=module_name)  # type: ignore[reportArgumentType]
@@ -291,7 +297,7 @@ class _Evaluator:
             if holder is not None:
                 holder.set(expr_evaluated)
         except Exception as e:
-            _warn(f"Exception raised evaluating {expr_string}", e)
+            _warn(f"Exception raised evaluating {_Evaluator._clean_exception_expr(expr_string)}", e)
 
     def re_evaluate_expr(self, gui: Gui, var_name: str) -> t.Set[str]:  # noqa C901
         """
@@ -366,7 +372,7 @@ class _Evaluator:
                         expr_evaluated = eval(expr_string, ctx)
                         _setscopeattr(gui, hash_expr, expr_evaluated)
                     except Exception as e:
-                        _warn(f"Exception raised evaluating {expr_string}", e)
+                        _warn(f"Exception raised evaluating {_Evaluator._clean_exception_expr(expr_string)}", e)
             # refresh holders if any
             for h in self.__expr_to_holders.get(expr, []):
                 holder_hash = self.__get_holder_hash(h, self.get_hash_from_expr(expr))
@@ -378,3 +384,7 @@ class _Evaluator:
 
     def _get_instance_in_context(self, name: str):
         return self.__global_ctx.get(name)
+
+    @staticmethod
+    def _clean_exception_expr(expr: str):
+        return _Evaluator.__CLEAN_LAMBDA_RE.sub(r"<lambda>\2", expr)

+ 75 - 38
taipy/gui/viselements.json

@@ -1452,7 +1452,7 @@
                         "type": "dynamic(str)",
                         "doc": "The title of the progress indicator."
                     },
-                                        {
+                    {
                         "name": "title_anchor",
                         "type": "str",
                         "default_value": "\"bottom\"",
@@ -1547,40 +1547,11 @@
                         "type": "dynamic(Union[str,list[Union[str,Icon,Any]]])",
                         "doc": "The list of menu option values."
                     },
-                    {
-                        "name": "adapter",
-                        "type": "Union[str, Callable]",
-                        "default_value": "<tt>lambda x: str(x)</tt>",
-                        "doc": "A function or the name of the function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:Union[str,Icon])</i>.<br/>The default value is a function that returns the string representation of the <i>lov</i> element."
-                    },
-                    {
-                        "name": "type",
-                        "type": "str",
-                        "default_value": "<i>Type name of the first lov element</i>",
-                        "doc": "This property is required if <i>lov</i> contains a non-specific type of data (e.g., a dictionary).<br/>Then:<ul><li><i>value</i> must be of that type</li><li><i>lov</i> must be an iterable containing elements of this type</li><li>The function set to <i>adapter</i> will receive an object of this type.</li></ul><br/>The default value is the type of the first element in <i>lov</i>."
-                    },
                     {
                         "name": "label",
                         "type": "str",
                         "doc": "The title of the menu."
                     },
-                    {
-                        "name": "inactive_ids",
-                        "type": "dynamic(Union[str,list[str]])",
-                        "doc": "Semicolon (';')-separated list or a list of menu items identifiers that are disabled."
-                    },
-                    {
-                        "name": "width",
-                        "type": "str",
-                        "default_value": "\"15vw\"",
-                        "doc": "The width of the menu when unfolded, in CSS units.<br/>Note that when running on a mobile device, the property <i>width[active]</i> is used instead."
-                    },
-                    {
-                        "name": "width[mobile]",
-                        "type": "str",
-                        "default_value": "\"85vw\"",
-                        "doc": "The width of the menu when unfolded, in CSS units, when running on a mobile device."
-                    },
                     {
                         "name": "on_action",
                         "type": "Union[str, Callable]",
@@ -1599,6 +1570,40 @@
                                 "dict"
                             ]
                         ]
+                    },
+                    {
+                        "name": "selected",
+                        "type": "dynamic(list[str])",
+                        "doc": "Semicolon (';')-separated list or a list of menu items identifiers that should be selected."
+                    },
+                    {
+                        "name": "inactive_ids",
+                        "type": "dynamic(Union[str,list[str]])",
+                        "doc": "Semicolon (';')-separated list or a list of menu items identifiers that are disabled."
+                    },
+                    {
+                        "name": "adapter",
+                        "type": "Union[str, Callable]",
+                        "default_value": "<tt>lambda x: str(x)</tt>",
+                        "doc": "A function or the name of the function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:Union[str,Icon])</i>.<br/>The default value is a function that returns the string representation of the <i>lov</i> element."
+                    },
+                    {
+                        "name": "type",
+                        "type": "str",
+                        "default_value": "<i>Type name of the first lov element</i>",
+                        "doc": "This property is required if <i>lov</i> contains a non-specific type of data (e.g., a dictionary).<br/>Then:<ul><li><i>value</i> must be of that type</li><li><i>lov</i> must be an iterable containing elements of this type</li><li>The function set to <i>adapter</i> will receive an object of this type.</li></ul><br/>The default value is the type of the first element in <i>lov</i>."
+                    },
+                    {
+                        "name": "width",
+                        "type": "str",
+                        "default_value": "\"15vw\"",
+                        "doc": "The width of the menu when unfolded, in CSS units.<br/>Note that when running on a mobile device, the property <i>width[active]</i> is used instead."
+                    },
+                    {
+                        "name": "width[mobile]",
+                        "type": "str",
+                        "default_value": "\"85vw\"",
+                        "doc": "The width of the menu when unfolded, in CSS units, when running on a mobile device."
                     }
                 ]
             }
@@ -1620,6 +1625,39 @@
                 ]
             }
         ],
+        [
+            "alert",  
+            {
+                "inherits": ["shared"],
+                "properties": [
+                    {
+                        "name": "message",
+                        "default_property": true,
+                        "type": "dynamic(str)",
+                        "default_value": "\"\"",
+                        "doc": "The message displayed in the notification. Can be a dynamic string."
+                    },
+                    {
+                        "name": "severity",
+                        "type": "dynamic(str)",
+                        "default_value": "\"error\"",
+                        "doc": "The severity level of the alert. Valid values: \"error\", \"warning\", \"info\", \"success\".\nThe default is \"error\"."
+                    },
+                    {
+                        "name": "variant",
+                        "type": "dynamic(str)",
+                        "default_value": "\"filled\"",
+                        "doc": "The variant of the alert. Valid values: \"filled\", \"outlined\".\nThe default is \"filled\"."
+                    },
+                    {
+                        "name": "render",
+                        "type": "dynamic(bool)",
+                        "default_value": "True",
+                        "doc": "If False, the alert is hidden."
+                    }
+                ]
+            }                       
+        ],
         [
             "status",
             {
@@ -1698,13 +1736,6 @@
                         "type": "dynamic(list[str])",
                         "doc": "The list of messages. Each item of this list must consist of a list of three strings: a message identifier, a message content, a user identifier, and an image URL."
                     },
-                    {
-                        "name": "max_file_size",
-                        "type": "int",
-                        "default_value": "1 * 1024 * 1024 (1MB)",
-                        "doc": "The maximum file size can be uploaded to a chat message."
-
-                    },
                     {
                         "name": "users",
                         "type": "dynamic(list[Union[str,Icon]])",
@@ -1763,6 +1794,12 @@
                         "type": "str",
                         "default_value": "\"markdown\"",
                         "doc": "Define the way the messages are processed when they are displayed:\n<ul><li>&quot;raw&quot; no processing</li><li>&quot;pre&quot;: keeps spaces and new lines</li><li>&quot;markdown&quot; or &quot;md&quot;: basic support for Markdown.</li></ul>"
+                    },
+                    {
+                        "name": "max_file_size",
+                        "type": "int",
+                        "default_value": "1024 * 1024",
+                        "doc": "The maximum allowable file size, in bytes, for files uploaded to a chat message.\nThe default is 1 MB."
                     }
                 ]
             }
@@ -1799,7 +1836,7 @@
                     }
                 ]
             }
-        ]
+        ]                                       
     ],
     "blocks": [
         [

+ 4 - 3
taipy/gui_core/_GuiCoreLib.py

@@ -65,7 +65,7 @@ class _GuiCore(ElementLibrary):
     __DATANODE_SELECTOR_SORT_VAR = "__tpgc_dn_sort"
     __DATANODE_SELECTOR_ERROR_VAR = "__tpgc_dn_error"
 
-    __elts = {
+    __elements = {
         "scenario_selector": Element(
             "value",
             {
@@ -210,6 +210,7 @@ class _GuiCore(ElementLibrary):
                 "show_owner": ElementProperty(PropertyType.boolean, True),
                 "show_edit_date": ElementProperty(PropertyType.boolean, False),
                 "show_expiration_date": ElementProperty(PropertyType.boolean, False),
+                "show_custom_properties": ElementProperty(PropertyType.boolean, True),
                 "show_properties": ElementProperty(PropertyType.boolean, True),
                 "show_history": ElementProperty(PropertyType.boolean, True),
                 "show_data": ElementProperty(PropertyType.boolean, True),
@@ -313,10 +314,10 @@ class _GuiCore(ElementLibrary):
         return _GuiCore.__LIB_NAME
 
     def get_elements(self) -> t.Dict[str, Element]:
-        return _GuiCore.__elts
+        return _GuiCore.__elements
 
     def get_scripts(self) -> t.List[str]:
-        return ["lib/taipy-gui-core.js"]
+        return ["lib/*.js"]
 
     def on_init(self, gui: Gui) -> t.Optional[t.Tuple[str, t.Any]]:
         self.ctx = _GuiCoreContext(gui)

+ 7 - 5
taipy/gui_core/_context.py

@@ -516,7 +516,11 @@ class _GuiCoreContext(CoreEventConsumerBase):
             finally:
                 self.scenario_refresh(scenario_id)
                 if (scenario or user_scenario) and (sel_scenario_var := args[1] if isinstance(args[1], str) else None):
-                    self.gui._update_var(sel_scenario_var, scenario or user_scenario, on_change=args[2])
+                    self.gui._update_var(
+                        sel_scenario_var[6:] if sel_scenario_var.startswith("_TpLv_") else sel_scenario_var,
+                        scenario or user_scenario,
+                        on_change=args[2],
+                    )
         if scenario:
             if not (reason := is_editable(scenario)):
                 state.assign(error_var, f"Scenario {scenario_id or name} is not editable: {_get_reason(reason)}.")
@@ -1124,10 +1128,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
         if id and is_readable(t.cast(DataNodeId, id)) and (dn := core_get(id)) and isinstance(dn, DataNode):
             try:
                 return [
-                        (k, f"{v}")
-                        for k, v in dn._get_user_properties().items()
-                        if k != _GuiCoreContext.__PROP_ENTITY_NAME
-                    ]
+                    (k, f"{v}") for k, v in dn._get_user_properties().items() if k != _GuiCoreContext.__PROP_ENTITY_NAME
+                ]
             except Exception:
                 return None
         return None

+ 28 - 22
taipy/gui_core/viselements.json

@@ -387,6 +387,24 @@
                         "type": "dynamic(DataNode|list[DataNode])",
                         "doc": "The data node to display and edit.<br/>If the value is a list, it must have a single element otherwise nothing is shown."
                     },
+                    {
+                        "name": "show_data",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "If False, the data node value tab is not visible."
+                    },
+                    {
+                        "name": "show_properties",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "If False, the data node properties tab is not visible."
+                    },
+                    {
+                        "name": "show_history",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "If False, the data node history tab is not visible."
+                    },
                     {
                         "name": "active",
                         "type": "dynamic(bool)",
@@ -418,40 +436,28 @@
                         "doc": "If False, the data node owner label is not visible."
                     },
                     {
-                        "name": "show_edit_date",
-                        "type": "bool",
-                        "default_value": "False",
-                        "doc": "If False, the data node edition date is not visible."
-                    },
-                    {
-                        "name": "show_expiration_date",
+                        "name": "show_owner_label",
                         "type": "bool",
                         "default_value": "False",
-                        "doc": "If False, the data node expiration date is not visible."
-                    },
-                    {
-                        "name": "show_properties",
-                        "type": "bool",
-                        "default_value": "True",
-                        "doc": "If False, the data node properties are not visible."
+                        "doc": "If True, the data node owner label is added to the datanode label at the top of the block."
                     },
                     {
-                        "name": "show_history",
+                        "name": "show_custom_properties",
                         "type": "bool",
                         "default_value": "True",
-                        "doc": "If False, the data node history is not visible."
+                        "doc": "If False, the custom properties for this data node properties are not visible in the Properties tab."
                     },
                     {
-                        "name": "show_data",
+                        "name": "show_edit_date",
                         "type": "bool",
-                        "default_value": "True",
-                        "doc": "If False, the data node value is not visible."
+                        "default_value": "False",
+                        "doc": "If False, the data node edition date is not visible."
                     },
                     {
-                        "name": "show_owner_label",
+                        "name": "show_expiration_date",
                         "type": "bool",
                         "default_value": "False",
-                        "doc": "If True, the data node owner label is added to the datanode label at the top of the block."
+                        "doc": "If False, the data node expiration date is not visible."
                     },
                     {
                         "name": "chart_config",
@@ -583,7 +589,7 @@
                     {
                         "name": "class_name",
                         "type": "dynamic(str)",
-                        "doc": "The list of CSS class names associated with the generated HTML Element.<br/>These class names will be added to the default <code>taipy_gui_core-&lt;element_type&gt;</code>."
+                        "doc": "The list of CSS class names associated with the generated HTML Element.<br/>These class names will be added to the default <code>taipy_gui_core-[element_type]</code>."
                     }
                 ]
             }

+ 3 - 2
tests/gui/actions/test_download.py

@@ -10,8 +10,9 @@
 # specific language governing permissions and limitations under the License.
 
 import inspect
+import typing as t
 
-from flask import g
+from flask import Flask, g
 
 from taipy.gui import Gui, Markdown, State, download
 
@@ -30,7 +31,7 @@ def test_download(gui: Gui, helpers):
     gui.run(run_server=False)
     flask_client = gui._server.test_client()
     # WS client and emit
-    ws_client = gui._server._ws.test_client(gui._server.get_flask())
+    ws_client = gui._server._ws.test_client(t.cast(Flask, gui._server.get_flask()))
     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}")

+ 38 - 0
tests/gui/builder/control/test_metric.py

@@ -0,0 +1,38 @@
+# 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_metric_builder_none(gui: Gui, helpers):
+    with tgb.Page(frame=None) as page:
+        tgb.metric(type=None, value=42)
+    expected_list = ["<Metric", 'type="None"', 'value={42.0}']
+    helpers.test_control_builder(gui, page, expected_list)
+
+def test_metric_builder_none_lowercase(gui: Gui, helpers):
+    with tgb.Page(frame=None) as page:
+        tgb.metric(type="none", value=42)
+    expected_list = ["<Metric", 'type="none"', 'value={42.0}']
+    helpers.test_control_builder(gui, page, expected_list)
+
+def test_metric_builder_circular(gui: Gui, helpers):
+    with tgb.Page(frame=None) as page:
+        tgb.metric(type="circular", value=42)
+    expected_list = ["<Metric", 'type="circular"', 'value={42.0}']
+    helpers.test_control_builder(gui, page, expected_list)
+
+def test_metric_builder_linear(gui: Gui, helpers):
+    with tgb.Page(frame=None) as page:
+        tgb.metric(type="linear", value=42)
+    expected_list = ["<Metric", 'type="linear"', 'value={42.0}']
+    helpers.test_control_builder(gui, page, expected_list)

+ 53 - 0
tests/gui/control/test_metric.py

@@ -0,0 +1,53 @@
+# 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 taipy.gui import Gui
+
+
+def test_metric_md_none(gui: Gui, helpers):
+    md_string = "<|metric|type=None|value=42|>"
+    expected_list = ["<Metric", 'type="None"', 'value={42.0}']
+    helpers.test_control_md(gui, md_string, expected_list)
+
+def test_metric_md_none_lowercase(gui: Gui, helpers):
+    md_string = "<|metric|type=none|value=42|>"
+    expected_list = ["<Metric", 'type="none"', 'value={42.0}']
+    helpers.test_control_md(gui, md_string, expected_list)
+
+def test_metric_md_circular(gui: Gui, helpers):
+    md_string = "<|metric|type=circular|value=42|>"
+    expected_list = ["<Metric", 'type="circular"', 'value={42.0}']
+    helpers.test_control_md(gui, md_string, expected_list)
+
+def test_metric_md_linear(gui: Gui, helpers):
+    md_string = "<|metric|type=linear|value=42|>"
+    expected_list = ["<Metric", 'type="linear"', 'value={42.0}']
+    helpers.test_control_md(gui, md_string, expected_list)
+
+def test_metric_html_none(gui: Gui, helpers):
+    html_string = '<taipy:metric type="None" value="42" />'
+    expected_list = ["<Metric", 'type="None"', 'value={42.0}']
+    helpers.test_control_html(gui, html_string, expected_list)
+
+def test_metric_html_none_lowercase(gui: Gui, helpers):
+    html_string = '<taipy:metric type="none" value="42" />'
+    expected_list = ["<Metric", 'type="none"', 'value={42.0}']
+    helpers.test_control_html(gui, html_string, expected_list)
+
+def test_metric_html_circular(gui: Gui, helpers):
+    html_string = '<taipy:metric type="circular" value="42" />'
+    expected_list = ["<Metric", 'type="circular"', 'value={42.0}']
+    helpers.test_control_html(gui, html_string, expected_list)
+
+def test_metric_html_linear(gui: Gui, helpers):
+    html_string = '<taipy:metric type="linear" value="42" />'
+    expected_list = ["<Metric", 'type="linear"', 'value={42.0}']
+    helpers.test_control_html(gui, html_string, expected_list)

+ 1 - 1
tests/gui/gui_specific/test_cli.py

@@ -125,7 +125,7 @@ def test_upload_folder(gui: Gui):
 def test_client_url(gui: Gui):
     with patch("sys.argv", ["prog"]):
         gui.run(run_server=False)
-        assert gui._config.config.get("client_url") == "http://localhost:{port}"
+        assert gui._config.config.get("client_url") is None
 
 
 def test_client_url_1(gui: Gui):

+ 1 - 1
tests/gui/helpers.py

@@ -165,4 +165,4 @@ class Helpers:
 
     @staticmethod
     def get_taipy_warnings(warns: t.List[warnings.WarningMessage]) -> t.List[warnings.WarningMessage]:
-        return [w for w in warns if w.category is TaipyGuiWarning]
+        return [w for w in warns if issubclass(w.category, TaipyGuiWarning)]

+ 106 - 0
tests/gui/mock/test_mock_state.py

@@ -0,0 +1,106 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+from unittest.mock import Mock
+
+from taipy.gui import Gui, State
+from taipy.gui.mock.mock_state import MockState
+from taipy.gui.utils import _MapDict
+
+
+def test_gui():
+    gui = Gui("")
+    ms = MockState(gui)
+    assert ms.get_gui() is gui
+    assert ms._gui is gui
+
+
+def test_read_attr():
+    gui = Gui("")
+    ms = MockState(gui, a=1)
+    assert ms is not None
+    assert ms.a == 1
+    assert ms.b is None
+
+
+def test_read_context():
+    ms = MockState(Gui(""), a=1)
+    assert ms["b"] is not None
+    assert ms["b"].a == 1
+
+
+def test_write_attr():
+    ms = MockState(Gui(""), a=1)
+    ms.a = 2
+    assert ms.a == 2
+    ms.b = 3
+    assert ms.b == 3
+    ms.a += 1
+    assert ms.a == 3
+
+def test_dict():
+    ms = MockState(Gui(""))
+    a_dict = {"a": 1}
+    ms.d = a_dict
+    assert isinstance(ms.d, _MapDict)
+    assert ms.d._dict is a_dict
+
+
+def test_write_context():
+    ms = MockState(Gui(""), a=1)
+    ms["page"].a = 2
+    assert ms["page"].a == 2
+    ms["page"].b = 3
+    assert ms["page"].b == 3
+
+def test_assign():
+    ms = MockState(Gui(""), a=1)
+    ms.assign("a", 2)
+    assert ms.a == 2
+    ms.assign("b", 1)
+    assert ms.b == 1
+
+def test_refresh():
+    ms = MockState(Gui(""), a=1)
+    ms.refresh("a")
+    assert ms.a == 1
+    ms.a = 2
+    ms.refresh("a")
+    assert ms.a == 2
+
+def test_context_manager():
+    with MockState(Gui(""), a=1) as ms:
+        assert ms is not None
+        ms.a = 2
+    assert ms.a == 2
+
+def test_broadcast():
+    ms = MockState(Gui(""), a=1)
+    ms.broadcast("a", 2)
+
+def test_set_favicon():
+    gui = Gui("")
+    gui.set_favicon = Mock()
+    ms = MockState(gui, a=1)
+    ms.set_favicon("a_path")
+    gui.set_favicon.assert_called_once()
+
+def test_callback():
+    def on_action(state: State):
+        state.assign("a", 2)
+
+    ms = MockState(Gui(""), a=1)
+    on_action(ms)
+    assert ms.a == 2
+
+def test_false():
+    ms = MockState(Gui(""), a=False)
+    assert ms.a is False

+ 4 - 3
tests/rest/test_datanode.py

@@ -33,8 +33,9 @@ def test_delete_datanode(client):
     rep = client.get(user_url)
     assert rep.status_code == 404
 
-    with mock.patch("taipy.core.data._data_manager._DataManager._delete"), mock.patch(
-        "taipy.core.data._data_manager._DataManager._get"
+    with (
+        mock.patch("taipy.core.data._data_manager._DataManager._delete"),
+        mock.patch("taipy.core.data._data_manager._DataManager._get"),
     ):
         # test get_datanode
         rep = client.delete(url_for("api.datanode_by_id", datanode_id="foo"))
@@ -63,7 +64,7 @@ def test_get_all_datanodes(client, default_datanode_config_list):
     for ds in range(10):
         with mock.patch("taipy.rest.api.resources.datanode.DataNodeList.fetch_config") as config_mock:
             config_mock.return_value = default_datanode_config_list[ds]
-            datanodes_url = url_for("api.datanodes", config_id=config_mock.name)
+            datanodes_url = url_for("api.datanodes", config_id=default_datanode_config_list[ds].name)
             client.post(datanodes_url)
 
     rep = client.get(datanodes_url)

+ 4 - 3
tests/rest/test_scenario.py

@@ -34,8 +34,9 @@ def test_delete_scenario(client):
     rep = client.get(user_url)
     assert rep.status_code == 404
 
-    with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._delete"), mock.patch(
-        "taipy.core.scenario._scenario_manager._ScenarioManager._get"
+    with (
+        mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._delete"),
+        mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._get"),
     ):
         # test get_scenario
         rep = client.delete(url_for("api.scenario_by_id", scenario_id="foo"))
@@ -64,7 +65,7 @@ def test_get_all_scenarios(client, default_sequence, default_scenario_config_lis
     for ds in range(10):
         with mock.patch("taipy.rest.api.resources.scenario.ScenarioList.fetch_config") as config_mock:
             config_mock.return_value = default_scenario_config_list[ds]
-            scenarios_url = url_for("api.scenarios", config_id=config_mock.name)
+            scenarios_url = url_for("api.scenarios", config_id=default_scenario_config_list[ds].name)
             client.post(scenarios_url)
 
     rep = client.get(scenarios_url)

+ 4 - 3
tests/rest/test_sequence.py

@@ -36,8 +36,9 @@ def test_delete_sequence(client):
     rep = client.get(user_url)
     assert rep.status_code == 404
 
-    with mock.patch("taipy.core.sequence._sequence_manager._SequenceManager._delete"), mock.patch(
-        "taipy.core.sequence._sequence_manager._SequenceManager._get"
+    with (
+        mock.patch("taipy.core.sequence._sequence_manager._SequenceManager._delete"),
+        mock.patch("taipy.core.sequence._sequence_manager._SequenceManager._get"),
     ):
         # test get_sequence
         rep = client.delete(url_for("api.sequence_by_id", sequence_id="foo"))
@@ -73,7 +74,7 @@ def test_get_all_sequences(client, default_scenario_config_list):
     for ds in range(10):
         with mock.patch("taipy.rest.api.resources.scenario.ScenarioList.fetch_config") as config_mock:
             config_mock.return_value = default_scenario_config_list[ds]
-            scenario_url = url_for("api.scenarios", config_id=config_mock.name)
+            scenario_url = url_for("api.scenarios", config_id=default_scenario_config_list[ds].name)
             client.post(scenario_url)
 
     sequences_url = url_for("api.sequences")

+ 4 - 3
tests/rest/test_task.py

@@ -34,8 +34,9 @@ def test_delete_task(client):
     rep = client.get(user_url)
     assert rep.status_code == 404
 
-    with mock.patch("taipy.core.task._task_manager._TaskManager._delete"), mock.patch(
-        "taipy.core.task._task_manager._TaskManager._get"
+    with (
+        mock.patch("taipy.core.task._task_manager._TaskManager._delete"),
+        mock.patch("taipy.core.task._task_manager._TaskManager._get"),
     ):
         # test get_task
         rep = client.delete(url_for("api.task_by_id", task_id="foo"))
@@ -64,7 +65,7 @@ def test_get_all_tasks(client, task_data, default_task_config_list):
     for ds in range(10):
         with mock.patch("taipy.rest.api.resources.task.TaskList.fetch_config") as config_mock:
             config_mock.return_value = default_task_config_list[ds]
-            tasks_url = url_for("api.tasks", config_id=config_mock.name)
+            tasks_url = url_for("api.tasks", config_id=default_task_config_list[ds].name)
             client.post(tasks_url)
 
     rep = client.get(tasks_url)

+ 1 - 1
tools/packages/pipfiles/Pipfile3.10.max

@@ -42,7 +42,7 @@ types-tzlocal = "*"
 python_version = "3"
 
 [pipenv]
-allow_prereleases = true
+allow_prereleases = false
 
 [dev-packages.moto]
 extras = [ "s3",]

+ 1 - 1
tools/packages/pipfiles/Pipfile3.11.max

@@ -42,7 +42,7 @@ types-tzlocal = "*"
 python_version = "3"
 
 [pipenv]
-allow_prereleases = true
+allow_prereleases = false
 
 [dev-packages.moto]
 extras = [ "s3",]

+ 1 - 1
tools/packages/pipfiles/Pipfile3.12.max

@@ -42,7 +42,7 @@ types-tzlocal = "*"
 python_version = "3"
 
 [pipenv]
-allow_prereleases = true
+allow_prereleases = false
 
 [dev-packages.moto]
 extras = [ "s3",]

+ 1 - 1
tools/packages/pipfiles/Pipfile3.9.max

@@ -42,7 +42,7 @@ types-tzlocal = "*"
 python_version = "3"
 
 [pipenv]
-allow_prereleases = true
+allow_prereleases = false
 
 [dev-packages.moto]
 extras = [ "s3",]

+ 97 - 0
tools/release/bump_version.py

@@ -0,0 +1,97 @@
+# 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 json
+import os
+import re
+from dataclasses import asdict, dataclass
+from typing import Optional
+
+
+@dataclass
+class Version:
+    major: str
+    minor: str
+    patch: str
+    ext: Optional[str] = None
+
+    def bump_ext_version(self) -> None:
+        if not self.ext:
+            return
+        reg = re.compile(r"[0-9]+$")
+        num = reg.findall(self.ext)[0]
+
+        self.ext = self.ext.replace(num, str(int(num) + 1))
+
+    def validate_suffix(self, suffix="dev"):
+        if suffix not in self.ext:
+            raise Exception(f"Version does not contain suffix {suffix}")
+
+    @property
+    def name(self) -> str:
+        """returns a string representation of a version"""
+        return f"{self.major}.{self.minor}.{self.patch}"
+
+    @property
+    def dev_name(self) -> str:
+        """returns a string representation of a version"""
+        return f"{self.name}.{self.ext}"
+
+    def __str__(self) -> str:
+        """returns a string representation of a version"""
+        version_str = f"{self.major}.{self.minor}.{self.patch}"
+        if self.ext:
+            version_str = f"{version_str}.{self.ext}"
+        return version_str
+
+
+def __load_version_from_path(base_path: str) -> Version:
+    """Load version.json file from base path."""
+    with open(os.path.join(base_path, "version.json")) as version_file:
+        data = json.load(version_file)
+        return Version(**data)
+
+
+def __write_version_to_path(base_path: str, version: Version) -> None:
+    with open(os.path.join(base_path, "version.json"), "w") as version_file:
+        json.dump(asdict(version), version_file)
+
+
+def extract_version(base_path: str) -> Version:
+    """
+    Load version.json file from base path and return the version string.
+    """
+    return __load_version_from_path(base_path)
+
+
+def bump_ext_version(version: Version, _base_path: str) -> None:
+    version.bump_ext_version()
+    __write_version_to_path(_base_path, version)
+
+
+
+if __name__ == "__main__":
+    paths = (
+         [
+            f"taipy{os.sep}common",
+            f"taipy{os.sep}core",
+            f"taipy{os.sep}rest",
+            f"taipy{os.sep}gui",
+            f"taipy{os.sep}templates",
+            "taipy",
+        ]
+    )
+
+    for _path in paths:
+        _version = extract_version(_path)
+        bump_ext_version(_version, _path)
+    print(f"NEW_VERSION={_version.dev_name}") # noqa T201 # type: ignore[reportPossiblyUnboundVariable]
+

+ 3 - 2
tools/release/setup_version.py

@@ -77,12 +77,13 @@ def __setup_dev_version(version: Version, _base_path: str, name: Optional[str] =
     version.validate_suffix()
 
     name = f"{name}_VERSION" if name else "VERSION"
+
     print(f"{name}={version.dev_name}")  # noqa: T201
 
-    version.bump_ext_version()
 
+def bump_ext_version(version: Version, _base_path: str) -> None:
+    version.bump_ext_version()
     __write_version_to_path(_base_path, version)
-    print(f"NEW_{name}={version.dev_name}")  # noqa: T201
 
 
 def __setup_prod_version(version: Version, target_version: str, branch_name: str, name: str = None) -> None:

Some files were not shown because too many files changed in this diff