Pārlūkot izejas kodu

Merge branch 'docs/tabular-data-example' of github.com:Avaiga/taipy into docs/tabular-data-example

namnguyen 6 mēneši atpakaļ
vecāks
revīzija
056c6312e8
63 mainītis faili ar 1959 papildinājumiem un 1146 dzēšanām
  1. 17 1
      .github/workflows/build-and-release.yml
  2. 1 1
      Pipfile
  3. 2 2
      README.md
  4. 28 0
      doc/gui/examples/Alert.py
  5. 1 0
      doc/gui/examples/charts/matplotlib/__init__.py
  6. 44 0
      doc/gui/examples/charts/matplotlib/builder.py
  7. 43 0
      doc/gui/examples/charts/matplotlib/markdown.py
  8. 28 0
      doc/gui/examples/controls/date_range_with_time_analog_picker.py
  9. 26 0
      doc/gui/examples/controls/date_with_time_analog_picker.py
  10. 26 0
      doc/gui/examples/controls/menu_inactive.py
  11. 26 0
      doc/gui/examples/controls/menu_inactive_options.py
  12. 26 0
      doc/gui/examples/controls/menu_label.py
  13. 30 0
      doc/gui/examples/controls/menu_on_action.py
  14. 27 0
      doc/gui/examples/controls/menu_selected.py
  15. 26 0
      doc/gui/examples/controls/menu_simple.py
  16. 38 0
      doc/gui/examples/controls/slider_labels_dictionary.py
  17. 2 2
      frontend/taipy-gui/src/components/Router.tsx
  18. 26 176
      frontend/taipy-gui/src/components/Taipy/Alert.spec.tsx
  19. 32 66
      frontend/taipy-gui/src/components/Taipy/Alert.tsx
  20. 14 19
      frontend/taipy-gui/src/components/Taipy/Menu.spec.tsx
  21. 34 16
      frontend/taipy-gui/src/components/Taipy/Menu.tsx
  22. 1 0
      frontend/taipy-gui/src/components/Taipy/MenuCtl.spec.tsx
  23. 33 29
      frontend/taipy-gui/src/components/Taipy/MenuCtl.tsx
  24. 12 2
      frontend/taipy-gui/src/components/Taipy/Metric.spec.tsx
  25. 1 1
      frontend/taipy-gui/src/components/Taipy/Metric.tsx
  26. 202 0
      frontend/taipy-gui/src/components/Taipy/Notification.spec.tsx
  27. 81 0
      frontend/taipy-gui/src/components/Taipy/Notification.tsx
  28. 3 1
      frontend/taipy-gui/src/components/Taipy/index.ts
  29. 3 5
      frontend/taipy-gui/src/components/Taipy/lovUtils.tsx
  30. 32 6
      frontend/taipy-gui/src/context/taipyReducers.spec.ts
  31. 15 5
      frontend/taipy-gui/src/context/taipyReducers.ts
  32. 1 0
      frontend/taipy-gui/src/utils/lov.ts
  33. 161 539
      frontend/taipy/package-lock.json
  34. 20 11
      frontend/taipy/src/DataNodeViewer.tsx
  35. 53 11
      taipy/gui/_gui_cli.py
  36. 11 1
      taipy/gui/_page.py
  37. 5 2
      taipy/gui/_renderers/builder.py
  38. 21 4
      taipy/gui/_renderers/factory.py
  39. 13 4
      taipy/gui/_warnings.py
  40. 7 1
      taipy/gui/config.py
  41. 47 11
      taipy/gui/gui.py
  42. 11 1
      taipy/gui/gui_actions.py
  43. 10 0
      taipy/gui/mock/__init__.py
  44. 62 0
      taipy/gui/mock/mock_state.py
  45. 109 94
      taipy/gui/state.py
  46. 13 3
      taipy/gui/utils/_evaluator.py
  47. 75 38
      taipy/gui/viselements.json
  48. 1 0
      taipy/gui_core/_GuiCoreLib.py
  49. 7 5
      taipy/gui_core/_context.py
  50. 33 27
      taipy/gui_core/viselements.json
  51. 3 2
      tests/gui/actions/test_download.py
  52. 38 0
      tests/gui/builder/control/test_metric.py
  53. 53 0
      tests/gui/control/test_metric.py
  54. 24 0
      tests/gui/gui_specific/test_cli.py
  55. 1 1
      tests/gui/helpers.py
  56. 106 0
      tests/gui/mock/test_mock_state.py
  57. 90 53
      tools/gui/generate_pyi.py
  58. 1 1
      tools/packages/pipfiles/Pipfile3.10.max
  59. 1 1
      tools/packages/pipfiles/Pipfile3.11.max
  60. 1 1
      tools/packages/pipfiles/Pipfile3.12.max
  61. 1 1
      tools/packages/pipfiles/Pipfile3.9.max
  62. 97 0
      tools/release/bump_version.py
  63. 3 2
      tools/release/setup_version.py

+ 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 - 2
README.md

@@ -40,10 +40,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")

+ 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")

+ 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")

+ 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 - 66
frontend/taipy-gui/src/components/Taipy/Alert.tsx

@@ -11,74 +11,40 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useCallback, useEffect, useMemo, useRef } 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 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 lastKey = useRef<SnackbarKey>("");
-    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) {
-            if (alert.atype === "") {
-                if (lastKey.current) {
-                    closeSnackbar(lastKey.current);
-                    lastKey.current = "";
-                }
-            } else {
-                lastKey.current = enqueueSnackbar(alert.message, {
-                    variant: alert.atype as VariantType,
-                    action: notifAction,
-                    autoHideDuration: alert.duration,
-                });
-                alert.system && new Notification(document.title || "Taipy", { body: alert.message, icon: faviconUrl });
-            }
-            dispatch(createDeleteAlertAction());
-        }
-    }, [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 = ({

+ 32 - 6
frontend/taipy-gui/src/context/taipyReducers.spec.ts

@@ -50,6 +50,7 @@ import { Socket } from "socket.io-client";
 import { Dispatch } from "react";
 import { parseData } from "../utils/dataFormat";
 import * as wsUtils from "./wsUtils";
+import { nanoid } from 'nanoid';
 
 jest.mock("./utils", () => ({
     ...jest.requireActual("./utils"),
@@ -575,6 +576,7 @@ describe("taipyReducer function", () => {
             message: "some error message",
             system: true,
             duration: 3000,
+            notificationId: nanoid(),
         };
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         expect(newState.alerts).toContainEqual({
@@ -582,19 +584,37 @@ describe("taipyReducer function", () => {
             message: action.message,
             system: action.system,
             duration: action.duration,
+            notificationId: action.notificationId,
         });
     });
     it("should handle DELETE_ALERT action", () => {
+        const notificationId1 = "id-1234";
+        const notificationId2 = "id-5678";
         const initialState = {
             ...INITIAL_STATE,
             alerts: [
-                { atype: "error", message: "First Alert", system: true, duration: 5000 },
-                { atype: "warning", message: "Second Alert", system: false, duration: 3000 },
+                { atype: "error", message: "First Alert", system: true, duration: 5000, notificationId: notificationId1 },
+                { atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 },
             ],
         };
-        const action = { type: Types.DeleteAlert };
+        const action = { type: Types.DeleteAlert, notificationId: notificationId1 };
+        const newState = taipyReducer(initialState, action);
+        expect(newState.alerts).toEqual([{ atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 }]);
+    });
+    it('should not modify state if DELETE_ALERT does not match any notificationId', () => {
+        const notificationId1 = "id-1234";
+        const notificationId2 = "id-5678";
+        const nonExistentId = "000000";
+        const initialState = {
+            ...INITIAL_STATE,
+            alerts: [
+                { atype: "error", message: "First Alert", system: true, duration: 5000, notificationId: notificationId1 },
+                { atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 },
+            ],
+        };
+        const action = { type: Types.DeleteAlert, notificationId: nonExistentId };
         const newState = taipyReducer(initialState, action);
-        expect(newState.alerts).toEqual([{ atype: "warning", message: "Second Alert", system: false, duration: 3000 }]);
+        expect(newState).toEqual(initialState);
     });
     it("should not modify state if no alerts are present", () => {
         const initialState = { ...INITIAL_STATE, alerts: [] };
@@ -602,7 +622,10 @@ describe("taipyReducer function", () => {
         const newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
     });
-    it("should handle DELETE_ALERT action", () => {
+    it("should handle DELETE_ALERT action even when no notificationId is passed", () => {
+        const notificationId1 = "id-1234";
+        const notificationId2 = "id-5678";
+
         const initialState = {
             ...INITIAL_STATE,
             alerts: [
@@ -611,16 +634,18 @@ describe("taipyReducer function", () => {
                     atype: "type1",
                     system: true,
                     duration: 5000,
+                    notificationId: notificationId1,
                 },
                 {
                     message: "alert2",
                     atype: "type2",
                     system: false,
                     duration: 3000,
+                    notificationId: notificationId2,
                 },
             ],
         };
-        const action = { type: Types.DeleteAlert };
+        const action = { type: Types.DeleteAlert, notificationId: notificationId1 };
         const newState = taipyReducer(initialState, action);
         expect(newState.alerts).toEqual([
             {
@@ -628,6 +653,7 @@ describe("taipyReducer function", () => {
                 atype: "type2",
                 system: false,
                 duration: 3000,
+                notificationId: notificationId2,
             },
         ]);
     });

+ 15 - 5
frontend/taipy-gui/src/context/taipyReducers.ts

@@ -16,6 +16,7 @@ import { createTheme, Theme } from "@mui/material/styles";
 import merge from "lodash/merge";
 import { Dispatch } from "react";
 import { io, Socket } from "socket.io-client";
+import { nanoid } from 'nanoid';
 
 import { FilterDesc } from "../components/Taipy/tableUtils";
 import { stylekitModeThemes, stylekitTheme } from "../themes/stylekit";
@@ -91,6 +92,7 @@ export interface AlertMessage {
     message: string;
     system: boolean;
     duration: number;
+    notificationId?: string;
 }
 
 interface TaipyAction extends NamePayload, TaipyBaseAction {
@@ -108,6 +110,10 @@ interface TaipyMultipleMessageAction extends TaipyBaseAction {
 
 interface TaipyAlertAction extends TaipyBaseAction, AlertMessage {}
 
+interface TaipyDeleteAlertAction extends TaipyBaseAction {
+    notificationId: string;
+}
+
 export const BLOCK_CLOSE = { action: "", message: "", close: true, noCancel: false } as BlockMessage;
 
 export interface BlockMessage {
@@ -379,14 +385,16 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta
                         message: alertAction.message,
                         system: alertAction.system,
                         duration: alertAction.duration,
+                        notificationId: alertAction.notificationId || nanoid(),
                     },
                 ],
             };
         case Types.DeleteAlert:
-            if (state.alerts.length) {
-                return { ...state, alerts: state.alerts.filter((_, i) => i) };
-            }
-            return state;
+            const deleteAlertAction = action as unknown as TaipyAlertAction;
+            return {
+                ...state,
+                alerts: state.alerts.filter(alert => alert.notificationId !== deleteAlertAction.notificationId),
+            };
         case Types.SetBlock:
             const blockAction = action as unknown as TaipyBlockAction;
             if (blockAction.close) {
@@ -818,10 +826,12 @@ export const createAlertAction = (alert: AlertMessage): TaipyAlertAction => ({
     message: alert.message,
     system: alert.system,
     duration: alert.duration,
+    notificationId: alert.notificationId,
 });
 
-export const createDeleteAlertAction = (): TaipyBaseAction => ({
+export const createDeleteAlertAction = (notificationId: string): TaipyDeleteAlertAction => ({
     type: Types.DeleteAlert,
+    notificationId,
 });
 
 export const createBlockAction = (block: BlockMessage): TaipyBlockAction => ({

+ 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[];
 }

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 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}

+ 53 - 11
taipy/gui/_gui_cli.py

@@ -43,7 +43,7 @@ class _GuiCLI(_AbstractCLI):
             "nargs": "?",
             "default": "",
             "const": "",
-            "help": "Specify client url",
+            "help": "Specify client URL",
         },
         ("--ngrok-token",): {
             "dest": "taipy_ngrok_token",
@@ -81,21 +81,55 @@ class _GuiCLI(_AbstractCLI):
         "--no-reloader": {"dest": "taipy_no_reloader", "help": "No reload on code changes", "action": "store_true"},
     }
 
+    __BROWSER_ARGS: Dict[str, Dict] = {
+        "--run-browser": {
+            "dest": "taipy_run_browser",
+            "help": "Open a new tab in the system browser",
+            "action": "store_true",
+        },
+        "--no-run-browser": {
+            "dest": "taipy_no_run_browser",
+            "help": "Don't open a new tab for the application",
+            "action": "store_true",
+        },
+    }
+
+    __DARK_LIGHT_MODE_ARGS: Dict[str, Dict] = {
+        "--dark-mode": {
+            "dest": "taipy_dark_mode",
+            "help": "Apply dark mode to the GUI application",
+            "action": "store_true",
+        },
+        "--light-mode": {
+            "dest": "taipy_light_mode",
+            "help": "Apply light mode to the GUI application",
+            "action": "store_true",
+        },
+    }
+
     @classmethod
     def create_parser(cls):
         gui_parser = _TaipyParser._add_groupparser("Taipy GUI", "Optional arguments for Taipy GUI service")
 
         for args, arg_dict in cls.__GUI_ARGS.items():
-            taipy_arg = (args[0], cls.__add_taipy_prefix(args[0]), *args[1:])
-            gui_parser.add_argument(*taipy_arg, **arg_dict)
+            arg = (args[0], cls.__add_taipy_prefix(args[0]), *args[1:])
+            gui_parser.add_argument(*arg, **arg_dict)
 
         debug_group = gui_parser.add_mutually_exclusive_group()
-        for debug_arg, debug_arg_dict in cls.__DEBUG_ARGS.items():
-            debug_group.add_argument(debug_arg, cls.__add_taipy_prefix(debug_arg), **debug_arg_dict)
+        for arg, arg_dict in cls.__DEBUG_ARGS.items():
+            debug_group.add_argument(arg, cls.__add_taipy_prefix(arg), **arg_dict)
 
         reloader_group = gui_parser.add_mutually_exclusive_group()
-        for reloader_arg, reloader_arg_dict in cls.__RELOADER_ARGS.items():
-            reloader_group.add_argument(reloader_arg, cls.__add_taipy_prefix(reloader_arg), **reloader_arg_dict)
+        for arg, arg_dict in cls.__RELOADER_ARGS.items():
+            reloader_group.add_argument(arg, cls.__add_taipy_prefix(arg), **arg_dict)
+
+        browser_group = gui_parser.add_mutually_exclusive_group()
+        for arg, arg_dict in cls.__BROWSER_ARGS.items():
+            browser_group.add_argument(arg, cls.__add_taipy_prefix(arg), **arg_dict)
+
+        dark_light_mode_group = gui_parser.add_mutually_exclusive_group()
+        for arg, arg_dict in cls.__DARK_LIGHT_MODE_ARGS.items():
+            dark_light_mode_group.add_argument(arg, cls.__add_taipy_prefix(arg), **arg_dict)
 
         if (hook_cli_arg := _Hooks()._get_cli_args()) is not None:
             hook_group = gui_parser.add_mutually_exclusive_group()
@@ -109,12 +143,20 @@ class _GuiCLI(_AbstractCLI):
             run_parser.add_argument(*args, **arg_dict)
 
         debug_group = run_parser.add_mutually_exclusive_group()
-        for debug_arg, debug_arg_dict in cls.__DEBUG_ARGS.items():
-            debug_group.add_argument(debug_arg, **debug_arg_dict)
+        for arg, arg_dict in cls.__DEBUG_ARGS.items():
+            debug_group.add_argument(arg, **arg_dict)
 
         reloader_group = run_parser.add_mutually_exclusive_group()
-        for reloader_arg, reloader_arg_dict in cls.__RELOADER_ARGS.items():
-            reloader_group.add_argument(reloader_arg, **reloader_arg_dict)
+        for arg, arg_dict in cls.__RELOADER_ARGS.items():
+            reloader_group.add_argument(arg, **arg_dict)
+
+        browser_group = run_parser.add_mutually_exclusive_group()
+        for arg, arg_dict in cls.__BROWSER_ARGS.items():
+            browser_group.add_argument(arg, **arg_dict)
+
+        dark_light_mode_group = run_parser.add_mutually_exclusive_group()
+        for arg, arg_dict in cls.__DARK_LIGHT_MODE_ARGS.items():
+            dark_light_mode_group.add_argument(arg, **arg_dict)
 
         if (hook_cli_arg := _Hooks()._get_cli_args()) is not None:
             hook_group = run_parser.add_mutually_exclusive_group()

+ 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,
     )

+ 7 - 1
taipy/gui/config.py

@@ -214,6 +214,12 @@ class _Config(object):
             config["use_reloader"] = True
         if args.taipy_no_reloader:
             config["use_reloader"] = False
+        if args.taipy_run_browser:
+            config["run_browser"] = True
+        if args.taipy_no_run_browser:
+            config["run_browser"] = False
+        if args.taipy_dark_mode or args.taipy_light_mode:
+            config["dark_mode"] = not args.taipy_light_mode
         if args.taipy_ngrok_token:
             config["ngrok_token"] = args.taipy_ngrok_token
         if args.taipy_webapp_path:
@@ -250,7 +256,7 @@ class _Config(object):
                         config[key] = value if config.get(key) is None else type(config.get(key))(value)  # type: ignore[reportCallIssue]
                 except Exception as e:
                     _warn(
-                        f"Invalid keyword arguments value in Gui.run {key} - {value}. Unable to parse value to the correct type",  # noqa: E501
+                        f"Invalid keyword arguments value in Gui.run(): {key} - {value}. Unable to parse value to the correct type",  # noqa: E501
                         e,
                     )
         # Load config from env file

+ 47 - 11
taipy/gui/gui.py

@@ -21,6 +21,7 @@ import sys
 import tempfile
 import time
 import typing as t
+import uuid
 import warnings
 from importlib import metadata, util
 from importlib.util import find_spec
@@ -72,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,
@@ -1330,15 +1331,26 @@ class Gui:
             send_back_only=True,
         )
 
-    def __send_ws_alert(self, type: str, message: str, system_notification: bool, duration: int) -> None:
+    def __send_ws_alert(
+            self, type: str,
+            message: str,
+            system_notification: bool,
+            duration: int,
+            notification_id: t.Optional[str] = None
+        ) -> None:
+        payload = {
+            "type": _WsType.ALERT.value,
+            "atype": type,
+            "message": message,
+            "system": system_notification,
+            "duration": duration,
+        }
+
+        if notification_id:
+            payload["notificationId"] = notification_id
+
         self.__send_ws(
-            {
-                "type": _WsType.ALERT.value,
-                "atype": type,
-                "message": message,
-                "system": system_notification,
-                "duration": duration,
-            }
+            payload,
         )
 
     def __send_ws_partial(self, partial: str):
@@ -2242,13 +2254,33 @@ class Gui:
         message: str = "",
         system_notification: t.Optional[bool] = None,
         duration: t.Optional[int] = None,
+        notification_id: t.Optional[str] = None,
     ):
+        if not notification_id:
+            notification_id = str(uuid.uuid4())
+
         self.__send_ws_alert(
             notification_type,
             message,
             self._get_config("system_notification", False) if system_notification is None else system_notification,
             self._get_config("notification_duration", 3000) if duration is None else duration,
+            notification_id,
         )
+        return notification_id
+
+    def _close_notification(
+        self,
+        notification_id: str,
+    ):
+        if notification_id:
+            self.__send_ws_alert(
+                type="",  # Since you're closing, set type to an empty string or a predefined "close" type
+                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
+            )
+
 
     def _hold_actions(
         self,
@@ -2260,7 +2292,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__
@@ -2777,7 +2811,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

+ 11 - 1
taipy/gui/gui_actions.py

@@ -67,6 +67,7 @@ def notify(
     message: str = "",
     system_notification: t.Optional[bool] = None,
     duration: t.Optional[int] = None,
+    notification_id: str = "",
 ):
     """Send a notification to the user interface.
 
@@ -96,11 +97,20 @@ def notify(
     feature.
     """
     if state and isinstance(state._gui, Gui):
-        state._gui._notify(notification_type, message, system_notification, duration)
+        return state._gui._notify(notification_type, message, system_notification, duration, notification_id)
     else:
         _warn("'notify()' must be called in the context of a callback.")
 
 
+def close_notification(state: State, notification_id: str):
+    """Close a specific notification by ID."""
+    if state and isinstance(state._gui, Gui):
+        # Send the close command with the notification_id
+        state._gui._close_notification(notification_id)
+    else:
+        _warn("'close_notification()' must be called in the context of a callback.")
+
+
 def hold_control(
     state: State,
     callback: t.Optional[t.Union[str, t.Callable]] = None,

+ 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

+ 109 - 94
taipy/gui/state.py

@@ -11,6 +11,7 @@
 
 import inspect
 import typing as t
+from abc import ABC, abstractmethod
 from contextlib import nullcontext
 from operator import attrgetter
 from pathlib import Path
@@ -25,7 +26,7 @@ if t.TYPE_CHECKING:
     from .gui import Gui
 
 
-class State:
+class State(ABC):
     """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": [
         [

+ 1 - 0
taipy/gui_core/_GuiCoreLib.py

@@ -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),

+ 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

+ 33 - 27
taipy/gui_core/viselements.json

@@ -100,7 +100,7 @@
                     },
                     {
                         "name": "filter",
-                        "type": "bool|str|ScenarioFilter|list[str|ScenarioFilter]",
+                        "type": "bool|str|taipy.gui_core.filters.ScenarioFilter|list[str|taipy.gui_core.filters.ScenarioFilter]",
                         "default_value": "\"*\"",
                         "doc": "TODO: a list of <code>Scenario^</code> attributes to filter on. If False, do not allow filter."
                     },
@@ -112,7 +112,7 @@
                     },
                     {
                         "name": "sort",
-                        "type": "bool|str|ScenarioFilter|list[str|ScenarioFilter]",
+                        "type": "bool|str|taipy.gui_core.filters.ScenarioFilter|list[str|taipy.gui_core.filters.ScenarioFilter]",
                         "default_value": "\"*\"",
                         "doc": "TODO: a list of <code>Scenario^</code> attributes to sort on. If False, do not allow sort."
                     }
@@ -355,7 +355,7 @@
                     },
                     {
                         "name": "filter",
-                        "type": "bool|str|DataNodeFilter|list[str|DataNodeFilter]",
+                        "type": "bool|str|taipy.gui_core.filters.DataNodeFilter|list[str|taipy.gui_core.filters.DataNodeFilter]",
                         "default_value": "\"*\"",
                         "doc": "TODO: a list of <code>DataNode^</code> attributes to filter on. If False, do not allow filter."
                     },
@@ -367,7 +367,7 @@
                     },
                     {
                         "name": "sort",
-                        "type": "bool|str|DataNodeFilter|list[str|DataNodeFilter]",
+                        "type": "bool|str|taipy.gui_core.filters.DataNodeFilter|list[str|taipy.gui_core.filters.DataNodeFilter]",
                         "default_value": "\"*\"",
                         "doc": "TODO: a list of <code>DataNode^</code> attributes to sort on. If False, do not allow sort."
                     }
@@ -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",
@@ -548,7 +554,7 @@
                     },
                     {
                         "name": "on_details",
-                        "type": "Union[str, Callback, bool]",
+                        "type": "Union[str, Callable, bool]",
                         "doc": "The name of a function that is triggered when the details icon is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the id of the control.</li>\n<li>payload (<code>dict</code>): a dictionary that contains the Job Id in the value for key <i>args<i>.</li>\n</ul></br>If False, the icon is not shown.",
                         "signature": [
                             [
@@ -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)

+ 24 - 0
tests/gui/gui_specific/test_cli.py

@@ -80,6 +80,30 @@ def test_taipy_no_reload(gui: Gui):
         assert gui._config.config.get("use_reloader") is False
 
 
+def test_taipy_run_browser(gui: Gui):
+    with patch("sys.argv", ["prog", "--run-browser"]):
+        gui.run(run_server=False, use_reloader=False)
+        assert gui._config.config.get("run_browser") is True
+
+
+def test_taipy_no_run_browser(gui: Gui):
+    with patch("sys.argv", ["prog", "--no-run-browser"]):
+        gui.run(run_server=False, use_reloader=True)
+        assert gui._config.config.get("run_browser") is False
+
+
+def test_taipy_dark_mode(gui: Gui):
+    with patch("sys.argv", ["prog", "--dark-mode"]):
+        gui.run(run_server=False)
+        assert gui._config.config.get("dark_mode") is True
+
+
+def test_taipy_light_mode(gui: Gui):
+    with patch("sys.argv", ["prog", "--light-mode"]):
+        gui.run(run_server=False)
+        assert gui._config.config.get("dark_mode") is False
+
+
 def test_ngrok_token(gui: Gui):
     with patch("sys.argv", ["prog", "--ngrok-token", "token"]):
         gui.run(run_server=False)

+ 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

+ 90 - 53
tools/gui/generate_pyi.py

@@ -71,27 +71,44 @@ taipy_doc_url = f"https://docs.taipy.io/en/{current_version}/manuals/userman/gui
 
 builder_py_file = "./taipy/gui/builder/__init__.py"
 builder_pyi_file = f"{builder_py_file}i"
+controls: Dict[str, List] = {}
+blocks: Dict[str, List] = {}
+undocumented: Dict[str, List] = {}
 with open("./taipy/gui/viselements.json", "r") as file:
-    viselements = json.load(file)
+    viselements: Dict[str, List] = json.load(file)
+    controls[""] = viselements.get("controls", [])
+    blocks[""] = viselements.get("blocks", [])
+    undocumented[""] = viselements.get("undocumented", [])
+with open("./taipy/gui_core/viselements.json", "r") as file:
+    core_viselements: Dict[str, List] = json.load(file)
+    controls['if find_spec("taipy.core"):'] = core_viselements.get("controls", [])
+    blocks['if find_spec("taipy.core"):'] = core_viselements.get("blocks", [])
+    undocumented['if find_spec("taipy.core"):'] = core_viselements.get("undocumented", [])
+
 os.system(f"pipenv run stubgen {builder_py_file} --no-import --parse-only --export-less -o ./")
 
 with open(builder_pyi_file, "a") as file:
     file.write("from datetime import datetime\n")
+    file.write("from importlib.util import find_spec\n")
     file.write("from typing import Any, Callable, Optional, Union\n")
     file.write("\n")
     file.write("from .. import Icon\n")
     file.write("from ._element import _Block, _Control\n")
+    file.write('if find_spec("taipy.core"):\n')
+    file.write("\tfrom taipy.core import Cycle, DataNode, Job, Scenario\n")
 
 
-def resolve_inherit(name: str, properties, inherits, viselements) -> List[Dict[str, Any]]:
+def resolve_inherit(
+    name: str, properties, inherits, blocks: List, controls: List, undocumented: List
+) -> List[Dict[str, Any]]:
     if not inherits:
         return properties
     for inherit_name in inherits:
-        inherited_desc = next((e for e in viselements["undocumented"] if e[0] == inherit_name), None)
+        inherited_desc = next((e for e in undocumented if e[0] == inherit_name), None)
         if inherited_desc is None:
-            inherited_desc = next((e for e in viselements["blocks"] if e[0] == inherit_name), None)
+            inherited_desc = next((e for e in blocks if e[0] == inherit_name), None)
         if inherited_desc is None:
-            inherited_desc = next((e for e in viselements["controls"] if e[0] == inherit_name), None)
+            inherited_desc = next((e for e in controls if e[0] == inherit_name), None)
         if inherited_desc is None:
             raise RuntimeError(f"Element type '{name}' inherits from unknown element type '{inherit_name}'")
         inherited_desc = inherited_desc[1]
@@ -109,11 +126,13 @@ def resolve_inherit(name: str, properties, inherits, viselements) -> List[Dict[s
                 override(prop_desc, inherit_prop, "signature")
             else:
                 properties.append(inherit_prop)
-            properties = resolve_inherit(inherit_name, properties, inherited_desc.get("inherits", None), viselements)
+            properties = resolve_inherit(
+                inherit_name, properties, inherited_desc.get("inherits", None), blocks, controls, undocumented
+            )
     return properties
 
 
-def format_as_parameter(property):
+def format_as_parameter(property: Dict[str, str]):
     name = property["name"]
     if match := __RE_INDEXED_PROPERTY.match(name):
         name = f"{match.group(1)}__{match.group(3)}"
@@ -130,8 +149,8 @@ def format_as_parameter(property):
         property["dynamic"] = ""
     if type == "Callback" or type == "Function":
         type = "Callable"
-    elif re.match(r"plotly\.", type) or re.match(r"taipy\.", type):
-        type = f"\"{type}\""
+    else:
+        type = re.sub(r"((plotly|taipy)\.[\w\.]*)", r'"\1"', type)
     default_value = property.get("default_value", None)
     if default_value is None or default_value == "None":
         default_value = " = None"
@@ -159,7 +178,7 @@ def build_doc(name: str, desc: Dict[str, Any]):
         doc = doc.replace("[element_type]", name)
     # This won't work for Scenario Management and Block elements
     doc = re.sub(r"(href=\")\.\.((?:.*?)\")", r"\1" + taipy_doc_url + name + r"/../..\2", doc)
-    doc = re.sub(r"<tt>([\w_]+)</tt>", r"`\1`", doc) # <tt> not processed properly by markdownify()
+    doc = re.sub(r"<tt>([\w_]+)</tt>", r"`\1`", doc)  # <tt> not processed properly by markdownify()
     doc = "\n  ".join(markdownify(doc).split("\n"))
     # <, >, `, [, -, _ and * prefixed with a \
     doc = doc.replace("  \n", "  \\n").replace("\\<", "<").replace("\\>", ">").replace("\\`", "`")
@@ -172,56 +191,74 @@ def build_doc(name: str, desc: Dict[str, Any]):
     return f"{desc['name']}{desc['dynamic']}{desc['indexed']}\\n  {doc}\\n\\n"
 
 
-element_template = """
+def element_template(name: str, base_class: str, n: str, properties_decl: str, properties_doc: str, ind: str):
+    return f"""
 
-class {{name}}(_{{base_class}}):
-    _ELEMENT_NAME: str
-    def __init__(self, {{properties_decl}}) -> None:
-        \"\"\"Creates a{{n}} {{name}} element.\\n\\nParameters\\n----------\\n\\n{{properties_doc}}\"\"\"  # noqa: E501
-        ...
+{ind}class {name}(_{base_class}):
+{ind}    _ELEMENT_NAME: str
+{ind}    def __init__(self, {properties_decl}) -> None:
+{ind}        \"\"\"Creates a{n} {name} element.\\n\\nParameters\\n----------\\n\\n{properties_doc}\"\"\"  # noqa: E501
+{ind}        ...
 """
 
 
-def generate_elements(category: str, base_class: str):
-    for element in viselements[category]:
-        name = element[0]
-        desc = element[1]
-        properties_doc = ""
-        property_list: List[Dict[str, Any]] = []
-        property_names: List[str] = []
-        properties = resolve_inherit(name, desc["properties"], desc.get("inherits", None), viselements)
-        # Remove hidden properties
-        properties = [p for p in properties if not p.get("hide", False)]
-        # Generate function parameters
-        properties_decl = [format_as_parameter(p) for p in properties]
-        # Generate properties doc
-        for property in properties:
-            if "default_property" in property and property["default_property"] is True:
-                property_list.insert(0, property)
-                property_names.insert(0, property["name"])
-                continue
-            property_list.append(property)
-            property_names.append(property["name"])
-        # Append properties doc to element doc (once ordered)
-        for property in property_list:
-            property_doc = build_doc(name, property)
-            properties_doc += property_doc
-        if len(properties_decl) > 1:
-            properties_decl.insert(1, "*")
-        # Append element to __init__.pyi
-        with open(builder_pyi_file, "a") as file:
-            n = "n" if name[0] in ["a", "e", "i", "o"] else ""
-            file.write(
-                element_template.replace("{{name}}", name)
-                .replace("{{n}}", n)
-                .replace("{{base_class}}", base_class)
-                .replace("{{properties_decl}}", ", ".join(properties_decl))
-                .replace("{{properties_doc}}", properties_doc)
+def generate_elements(elements_by_prefix: Dict[str, List], base_class: str):
+    for prefix, elements in elements_by_prefix.items():
+        if not elements:
+            continue
+        indent = ""
+        if prefix:
+            indent = "    "
+            with open(builder_pyi_file, "a") as file:
+                file.write(prefix + "\n")
+        for element in elements:
+            name = element[0]
+            desc = element[1]
+            properties_doc = ""
+            property_list: List[Dict[str, Any]] = []
+            property_names: List[str] = []
+            properties = resolve_inherit(
+                name,
+                desc["properties"],
+                desc.get("inherits", None),
+                blocks.get(prefix, []),
+                controls.get(prefix, []),
+                undocumented.get(prefix, []),
             )
+            # Remove hidden properties
+            properties = [p for p in properties if not p.get("hide", False)]
+            # Generate function parameters
+            properties_decl = [format_as_parameter(p) for p in properties]
+            # Generate properties doc
+            for property in properties:
+                if "default_property" in property and property["default_property"] is True:
+                    property_list.insert(0, property)
+                    property_names.insert(0, property["name"])
+                    continue
+                property_list.append(property)
+                property_names.append(property["name"])
+            # Append properties doc to element doc (once ordered)
+            for property in property_list:
+                property_doc = build_doc(name, property)
+                properties_doc += property_doc
+            if len(properties_decl) > 1:
+                properties_decl.insert(1, "*")
+            # Append element to __init__.pyi
+            with open(builder_pyi_file, "a") as file:
+                file.write(
+                    element_template(
+                        name,
+                        base_class,
+                        "n" if name[0] in ["a", "e", "i", "o"] else "",
+                        ", ".join(properties_decl),
+                        properties_doc,
+                        indent,
+                    )
+                )
 
 
-generate_elements("controls", "Control")
-generate_elements("blocks", "Block")
+generate_elements(controls, "Control")
+generate_elements(blocks, "Block")
 
 os.system(f"pipenv run isort {gui_pyi_file}")
 os.system(f"pipenv run black {gui_pyi_file}")

+ 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:

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels