Browse Source

New example for demoing page modules. (#2461)

* New example for demoing page modules.
+ Slight doc improvements
+ Allow for permanent notifications
+ Allow multiple notifications with the same id - all closed simultaneously.
Fabien Lelaquais 2 months ago
parent
commit
e036b8b1df

+ 36 - 0
doc/gui/examples/grocery_store.py

@@ -0,0 +1,36 @@
+# Copyright 2021-2025 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.
+# -----------------------------------------------------------------------------------------
+# Entry point for the example on Page Modules.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from grocery_store.sales import SalesPage
+from grocery_store.stock import page as StockPage
+
+from taipy.gui import Gui
+
+# Define sample data for grocery store sales and stock
+data = {
+    "Items": ["Apples", "Bananas", "Oranges", "Grapes", "Strawberries"],
+    "Purchase": [1.36, 0.73, 1.09, 2.27, 2.73],
+    "Price $": [1.50, 0.80, 1.20, 2.50, 3.00],
+    "Price €": [1.38, 0.74, 1.10, 2.30, 2.76],
+    "Sales Q1": [120, 200, 90, 50, 75],
+    "Sales Q2": [140, 180, 110, 60, 85],
+    "Sales Q3": [100, 190, 95, 55, 80],
+    "Stock":  [500, 600, 400, 300, 250]
+}
+
+# Initialize and run the GUI application with Sales and Stock pages
+Gui(pages={ "sales": SalesPage(), "stock": StockPage }).run(title="Grocery Store")

+ 0 - 0
doc/gui/examples/grocery_store/__init__.py


+ 53 - 0
doc/gui/examples/grocery_store/sales.py

@@ -0,0 +1,53 @@
+# Copyright 2021-2025 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.
+# -----------------------------------------------------------------------------------------
+# Example on Page Modules.
+# Sales page.
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Markdown, Page
+
+
+# SalesPage inherits from taipy.gui.Page
+class SalesPage(Page):
+    # Available quarters for sales data selection
+    quarters: list[str] = ["Q1", "Q2", "Q3"]
+
+    def __init__(self) -> None:
+        self.quarter = "Q1"  # Default selected quarter
+        self.currency = "$"  # Default currency
+        super().__init__()
+
+    @staticmethod
+    def compute_total(quarter: str, currency: str, data: dict[str, list[float]]) -> float:
+        """Compute the total sales revenue for the selected quarter and currency."""
+        # Sales data for the chosen quarter
+        sold = data[f"Sales {quarter}"]
+        # Prices in the chosen currency
+        price = data[f"Price {currency}"]
+        # Compute and return total revenue
+        return sum(s * p for s, p in zip(sold, price))
+
+    def create_page(self):
+        """Create and return the page content."""
+        return Markdown("""
+# Sales
+
+<|1 4|layout
+Select quarter: <|{quarter}|selector|lov=Q1;Q2;Q3|>
+
+Total: <|{SalesPage.compute_total(quarter, currency, data)}|format=%.02f|><br/>
+Currency: <|{currency}|toggle|lov=$;€|>
+|>
+
+<|{data}|table|columns=Items;Sales Q1;Sales Q2;Sales Q3|>
+
+[Goto Stock](stock)
+""")

+ 34 - 0
doc/gui/examples/grocery_store/stock.py

@@ -0,0 +1,34 @@
+# Copyright 2021-2025 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.
+# -----------------------------------------------------------------------------------------
+# Example on Page Modules.
+# Stock page.
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Markdown
+
+# Whether stock details are displayed or not
+show_details = False
+
+# Compute and return the total stock value based on purchase price and stock quantity
+def compute_stock_value(data: dict[str, list[float]]) -> float:
+    return sum([v * n for v, n in zip(data["Purchase"], data["Stock"])])
+
+# Define the Stock page as a Markdown page
+page = Markdown("""# Stock
+
+Stock value: $<|{compute_stock_value(data)}|>
+
+<|Stock details|expandable|expanded={show_details}|
+<|{data}|table|columns=Items;Stock|>
+|>
+
+[Goto Sales](sales)
+""")

+ 4 - 4
frontend/taipy-gui/base/src/wsAdapter.ts

@@ -15,8 +15,8 @@ interface MultipleUpdatePayload {
     payload: { value: unknown };
 }
 
-interface AlertMessage extends WsMessage {
-    atype: string;
+interface NotificationMessage extends WsMessage {
+    nType: string;
     message: string;
 }
 
@@ -97,8 +97,8 @@ export class TaipyWsAdapter extends WsAdapter {
                 const payload = message.payload as [string, string][];
                 taipyApp.routes = payload;
             } else if (message.type === "AL") {
-                const payload = message as AlertMessage;
-                taipyApp.onNotifyEvent(payload.atype, payload.message);
+                const payload = message as NotificationMessage;
+                taipyApp.onNotifyEvent(payload.nType, payload.message);
             } else if (message.type === "ACK") {
                 const { id } = message as unknown as Record<string, string>;
                 taipyApp._ackList = taipyApp._ackList.filter((v) => v !== id);

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

@@ -337,13 +337,14 @@ const Chat = (props: ChatProps) => {
             } else {
                 dispatch(
                     createNotificationAction({
-                        atype: "info",
+                        nType: "info",
                         message:
                             file.size > maxFileSize
                                 ? `Image size is limited to ${maxFileSize / 1024} KB`
                                 : "Only image file are authorized",
                         system: false,
                         duration: 3000,
+                        snackbarId: "Chat warning"
                     })
                 );
                 setSelectedFile(null);

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

@@ -24,7 +24,7 @@ describe("Expandable Component", () => {
     it("renders", async () => {
         const { getByText } = render(<Expandable title="foo">bar</Expandable>);
         const elt = getByText("foo");
-        expect(elt.tagName).toBe("DIV");
+        expect(elt.tagName).toBe("SPAN");
     });
     it("displays the right info for string", async () => {
         const { getByText } = render(

+ 4 - 4
frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx

@@ -186,11 +186,11 @@ describe("FileSelector Component", () => {
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
 
-        // Check if the alert action has been dispatched
+        // Check if the notification action has been dispatched
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
                 type: "SET_NOTIFICATION",
-                atype: "success",
+                nType: "success",
                 duration: 3000,
                 message: "mocked response",
                 system: false,
@@ -222,11 +222,11 @@ describe("FileSelector Component", () => {
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
 
-        // Check if the alert action has been dispatched
+        // Check if the notification action has been dispatched
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
                 type: "SET_NOTIFICATION",
-                atype: "error",
+                nType: "error",
                 duration: 3000,
                 message: "Upload failed",
                 system: false,

+ 4 - 2
frontend/taipy-gui/src/components/Taipy/FileSelector.tsx

@@ -128,10 +128,11 @@ const FileSelector = (props: FileSelectorProps) => {
                         notify &&
                             dispatch(
                                 createNotificationAction({
-                                    atype: "success",
+                                    nType: "success",
                                     message: value,
                                     system: false,
                                     duration: 3000,
+                                    snackbarId: "FileSelector action"
                                 })
                             );
                         const fileInput = document.getElementById(inputId) as HTMLInputElement;
@@ -142,10 +143,11 @@ const FileSelector = (props: FileSelectorProps) => {
                         notify &&
                             dispatch(
                                 createNotificationAction({
-                                    atype: "error",
+                                    nType: "error",
                                     message: reason,
                                     system: false,
                                     duration: 3000,
+                                    snackbarId: "FileSelector failure"
                                 })
                             );
                         const fileInput = document.getElementById(inputId) as HTMLInputElement;

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

@@ -178,7 +178,7 @@ describe("Metric Component", () => {
         const title = "Test Title";
         const { container } = render(<Metric title={title} />);
         await waitFor(() => {
-            const titleElement = container.querySelector(".gtitle");
+            const titleElement = container.querySelector(".g-gtitle");
             if (!titleElement) {
                 throw new Error("Title element not found");
             }

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

@@ -190,11 +190,9 @@ const Metric = (props: MetricProps) => {
         if (template) {
             layout.template = template;
         }
-
         if (props.title) {
-            layout.title = props.title;
+            layout.title = { text: props.title };
         }
-
         return layout as Partial<Layout>;
     }, [
         props.title,

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

@@ -21,15 +21,17 @@ import { NotificationMessage } from "../../context/taipyReducers";
 import userEvent from "@testing-library/user-event";
 
 const defaultMessage = "message";
-const defaultNotifications: NotificationMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
-const getNotificationsWithType = (aType: string) => [{ ...defaultNotifications[0], atype: aType }];
+const defaultNotifications: NotificationMessage[] = [
+    { nType: "success", message: defaultMessage, system: true, duration: 3000, snackbarId: "nId" },
+];
+const getNotificationsWithType = (nType: string) => [{ ...defaultNotifications[0], nType }];
 
 class myNotification {
     static requestPermission = jest.fn(() => Promise.resolve("granted"));
     static permission = "granted";
 }
 
-describe("Alert Component", () => {
+describe("Notifications", () => {
     beforeAll(() => {
         globalThis.Notification = myNotification as unknown as jest.Mocked<typeof Notification>;
     });
@@ -40,43 +42,43 @@ describe("Alert Component", () => {
         const { getByText } = render(
             <SnackbarProvider>
                 <TaipyNotification notifications={defaultNotifications} />
-            </SnackbarProvider>,
+            </SnackbarProvider>
         );
         const elt = getByText(defaultMessage);
         expect(elt.tagName).toBe("DIV");
     });
-    it("displays a success alert", async () => {
+    it("displays a success notification", async () => {
         const { getByText } = render(
             <SnackbarProvider>
                 <TaipyNotification notifications={defaultNotifications} />
-            </SnackbarProvider>,
+            </SnackbarProvider>
         );
         const elt = getByText(defaultMessage);
         expect(elt.closest(".notistack-MuiContent-success")).toBeInTheDocument();
     });
-    it("displays an error alert", async () => {
+    it("displays an error notification", async () => {
         const { getByText } = render(
             <SnackbarProvider>
                 <TaipyNotification notifications={getNotificationsWithType("error")} />
-            </SnackbarProvider>,
+            </SnackbarProvider>
         );
         const elt = getByText(defaultMessage);
         expect(elt.closest(".notistack-MuiContent-error")).toBeInTheDocument();
     });
-    it("displays a warning alert", async () => {
+    it("displays a warning notification", async () => {
         const { getByText } = render(
             <SnackbarProvider>
                 <TaipyNotification notifications={getNotificationsWithType("warning")} />
-            </SnackbarProvider>,
+            </SnackbarProvider>
         );
         const elt = getByText(defaultMessage);
         expect(elt.closest(".notistack-MuiContent-warning")).toBeInTheDocument();
     });
-    it("displays an info alert", async () => {
+    it("displays an info notification", async () => {
         const { getByText } = render(
             <SnackbarProvider>
                 <TaipyNotification notifications={getNotificationsWithType("info")} />
-            </SnackbarProvider>,
+            </SnackbarProvider>
         );
         const elt = getByText(defaultMessage);
         expect(elt.closest(".notistack-MuiContent-info")).toBeInTheDocument();
@@ -86,13 +88,19 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
-        const alerts: NotificationMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        const notifications: NotificationMessage[] = [
+            {
+                nType: "success",
+                message: "This is a system notification",
+                system: true,
+                duration: 3000,
+                snackbarId: "nId",
+            },
         ];
         render(
             <SnackbarProvider>
-                <TaipyNotification notifications={alerts} />
-            </SnackbarProvider>,
+                <TaipyNotification notifications={notifications} />
+            </SnackbarProvider>
         );
         const linkElement = document.querySelector("link[rel='icon']");
         if (linkElement) {
@@ -103,46 +111,56 @@ describe("Alert Component", () => {
         document.head.removeChild(link);
     });
 
-    it("closes alert on close button click", async () => {
-        const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
+    it("closes notification on close button click", async () => {
+        const notifications = [
+            { nType: "success", message: "Test Notification", duration: 3000, system: false, snackbarId: "nId" },
+        ];
         render(
             <SnackbarProvider>
-                <TaipyNotification notifications={alerts} />
-            </SnackbarProvider>,
+                <TaipyNotification notifications={notifications} />
+            </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();
+            const notificationMessage = screen.queryByText("Test Notification");
+            expect(notificationMessage).not.toBeInTheDocument();
         });
     });
 
-    it("Alert disappears when alert type is empty", async () => {
-        const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
+    it("Notification disappears when notification type is empty", async () => {
+        const baseNotification = {
+            nType: "success",
+            message: "Test Notification",
+            duration: 3000,
+            system: false,
+            notificationId: "nId",
+            snackbarId: "nId",
+        };
+        const notifications = [ baseNotification ];
         const { rerender } = render(
             <SnackbarProvider>
-                <TaipyNotification notifications={alerts} />
-            </SnackbarProvider>,
+                <TaipyNotification notifications={notifications} />
+            </SnackbarProvider>
         );
         await screen.findByRole("button", { name: /close/i });
-        const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
+        const newNotifications = [ { ...baseNotification, nType: "" }];
         rerender(
             <SnackbarProvider>
-                <TaipyNotification notifications={newAlerts} />
-            </SnackbarProvider>,
+                <TaipyNotification notifications={newNotifications} />
+            </SnackbarProvider>
         );
         await waitFor(() => {
-            const alertMessage = screen.queryByText("Test Alert");
-            expect(alertMessage).not.toBeInTheDocument();
+            const notificationMessage = screen.queryByText("Test Notification");
+            expect(notificationMessage).not.toBeInTheDocument();
         });
     });
 
-    it("does nothing when alert is undefined", async () => {
+    it("does nothing when notification is undefined", async () => {
         render(
             <SnackbarProvider>
                 <TaipyNotification notifications={[]} />
-            </SnackbarProvider>,
+            </SnackbarProvider>
         );
         expect(Notification.requestPermission).not.toHaveBeenCalled();
     });
@@ -152,13 +170,19 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
-        const alerts: NotificationMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        const notifications: NotificationMessage[] = [
+            {
+                nType: "success",
+                message: "This is a system notification",
+                system: true,
+                duration: 3000,
+                snackbarId: "nId",
+            },
         ];
         render(
             <SnackbarProvider>
-                <TaipyNotification notifications={alerts} />
-            </SnackbarProvider>,
+                <TaipyNotification notifications={notifications} />
+            </SnackbarProvider>
         );
         const linkElement = document.querySelector("link[rel='icon']");
         expect(linkElement?.getAttribute("href")).toBe("/test-icon.png");
@@ -169,13 +193,19 @@ describe("Alert Component", () => {
         const link = document.createElement("link");
         link.rel = "icon";
         document.head.appendChild(link);
-        const alerts: NotificationMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        const notifications: NotificationMessage[] = [
+            {
+                nType: "success",
+                message: "This is a system notification",
+                system: true,
+                duration: 3000,
+                snackbarId: "nId",
+            },
         ];
         render(
             <SnackbarProvider>
-                <TaipyNotification notifications={alerts} />
-            </SnackbarProvider>,
+                <TaipyNotification notifications={notifications} />
+            </SnackbarProvider>
         );
         const linkElement = document.querySelector("link[rel='icon']");
         expect(linkElement?.getAttribute("href") || "/favicon.png").toBe("/favicon.png");
@@ -187,13 +217,19 @@ describe("Alert Component", () => {
         link.rel = "shortcut icon";
         link.href = "/test-shortcut-icon.png";
         document.head.appendChild(link);
-        const alerts: NotificationMessage[] = [
-            { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
+        const notifications: NotificationMessage[] = [
+            {
+                nType: "success",
+                message: "This is a system notification",
+                system: true,
+                duration: 3000,
+                snackbarId: "nId",
+            },
         ];
         render(
             <SnackbarProvider>
-                <TaipyNotification notifications={alerts} />
-            </SnackbarProvider>,
+                <TaipyNotification notifications={notifications} />
+            </SnackbarProvider>
         );
         const linkElement = document.querySelector("link[rel='shortcut icon']");
         expect(linkElement?.getAttribute("href")).toBe("/test-shortcut-icon.png");

+ 41 - 18
frontend/taipy-gui/src/components/Taipy/Notification.tsx

@@ -11,39 +11,49 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useCallback, useEffect, useMemo } from "react";
-import { SnackbarKey, useSnackbar, VariantType } from "notistack";
+import React, { useCallback, useEffect, useMemo, useRef, SyntheticEvent } from "react";
+import { SnackbarKey, useSnackbar, VariantType, CloseReason } from "notistack";
 import IconButton from "@mui/material/IconButton";
 import CloseIcon from "@mui/icons-material/Close";
 
-import { NotificationMessage, createDeleteAlertAction } from "../../context/taipyReducers";
+import { NotificationMessage, createDeleteNotificationAction } from "../../context/taipyReducers";
 import { useDispatch } from "../../utils/hooks";
 
 interface NotificationProps {
     notifications: NotificationMessage[];
 }
 
-const TaipyNotification = ({ notifications }: NotificationProps) => {
-    const notification = notifications.length ? notifications[0] : undefined;
+const TaipyNotification = ({ notifications: notificationProps }: NotificationProps) => {
+    const notification = notificationProps.length ? notificationProps[0] : undefined;
     const { enqueueSnackbar, closeSnackbar } = useSnackbar();
+    const snackbarIds = useRef<Record<string, string>>({});
     const dispatch = useDispatch();
 
-    const resetNotification = useCallback(
-        (key: SnackbarKey) => () => {
-            closeSnackbar(key);
+    const closeNotifications = useCallback(
+        (ids: string[]) => {
+            ids.forEach((id) => closeSnackbar(id));
         },
         [closeSnackbar]
     );
 
     const notificationAction = useCallback(
         (key: SnackbarKey) => (
-            <IconButton size="small" aria-label="close" color="inherit" onClick={resetNotification(key)}>
+            <IconButton
+                size="small"
+                aria-label="close"
+                color="inherit"
+                onClick={() => closeNotifications([key as string])}
+            >
                 <CloseIcon fontSize="small" />
             </IconButton>
         ),
-        [resetNotification]
+        [closeNotifications]
     );
 
+    const notificationClosed = (event: SyntheticEvent | null, reason: CloseReason, key?: SnackbarKey) => {
+        snackbarIds.current = Object.fromEntries(Object.entries(snackbarIds.current).filter(([id]) => id !== key));
+    };
+
     const faviconUrl = useMemo(() => {
         const nodeList = document.getElementsByTagName("link");
         for (let i = 0; i < nodeList.length; i++) {
@@ -56,22 +66,35 @@ const TaipyNotification = ({ notifications }: NotificationProps) => {
 
     useEffect(() => {
         if (notification) {
-            const notificationId = notification.notificationId || "";
-            if (notification.atype === "") {
-                closeSnackbar(notificationId);
+            const notificationId = notification.notificationId;
+            if (notification.nType === "") {
+                if (notificationId) {
+                    closeNotifications(
+                        Object.entries(snackbarIds.current)
+                            .filter(([, id]) => notificationId === id)
+                            .map(([snackbarId]) => snackbarId)
+                    );
+                }
             } else {
+                if (notificationId) {
+                    snackbarIds.current = {
+                        ...snackbarIds.current,
+                        [notification.snackbarId]: notificationId,
+                    };
+                }
                 enqueueSnackbar(notification.message, {
-                    variant: notification.atype as VariantType,
+                    variant: notification.nType as VariantType,
                     action: notificationAction,
-                    autoHideDuration: notification.duration,
-                    key: notificationId,
+                    onClose: notificationClosed,
+                    key: notification.snackbarId,
+                    autoHideDuration: notification.duration || null,
                 });
                 notification.system &&
                     new Notification(document.title || "Taipy", { body: notification.message, icon: faviconUrl });
             }
-            dispatch(createDeleteAlertAction(notificationId));
+            dispatch(createDeleteNotificationAction(notification.snackbarId));
         }
-    }, [notification, enqueueSnackbar, closeSnackbar, notificationAction, faviconUrl, dispatch]);
+    }, [notification, enqueueSnackbar, closeNotifications, notificationAction, faviconUrl, dispatch]);
 
     useEffect(() => {
         notification?.system && window.Notification && Notification.requestPermission();

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

@@ -142,7 +142,7 @@ const SortRow = (props: SortRowProps) => {
         <Grid container size={12} alignItems="center">
             <Grid size={6}>
                 <FormControl margin="dense">
-                    <InputLabel>Column</InputLabel>
+                    <InputLabel>{fieldHeader}</InputLabel>
                     <Tooltip title={fieldHeaderTooltip} placement="top">
                         <Select
                             value={colId || ""}

+ 31 - 22
frontend/taipy-gui/src/context/taipyReducers.spec.ts

@@ -90,7 +90,7 @@ describe("reducer", () => {
         expect(
             taipyReducer({ ...INITIAL_STATE }, {
                 type: "SET_NOTIFICATION",
-                atype: "i",
+                nType: "i",
                 message: "message",
                 system: "system",
             } as TaipyBaseAction).notifications
@@ -203,19 +203,19 @@ describe("reducer", () => {
         ).toBeUndefined();
     });
     it("creates a notification action", () => {
-        expect(createNotificationAction({ atype: "I", message: "message" } as NotificationMessage).type).toBe(
+        expect(createNotificationAction({ nType: "I", message: "message" } as NotificationMessage).type).toBe(
             "SET_NOTIFICATION"
         );
-        expect(createNotificationAction({ atype: "err", message: "message" } as NotificationMessage).atype).toBe(
+        expect(createNotificationAction({ nType: "err", message: "message" } as NotificationMessage).nType).toBe(
             "error"
         );
-        expect(createNotificationAction({ atype: "Wa", message: "message" } as NotificationMessage).atype).toBe(
+        expect(createNotificationAction({ nType: "Wa", message: "message" } as NotificationMessage).nType).toBe(
             "warning"
         );
-        expect(createNotificationAction({ atype: "sUc", message: "message" } as NotificationMessage).atype).toBe(
+        expect(createNotificationAction({ nType: "sUc", message: "message" } as NotificationMessage).nType).toBe(
             "success"
         );
-        expect(createNotificationAction({ atype: "  ", message: "message" } as NotificationMessage).atype).toBe("");
+        expect(createNotificationAction({ nType: "  ", message: "message" } as NotificationMessage).nType).toBe("");
     });
 });
 
@@ -581,7 +581,7 @@ describe("taipyReducer function", () => {
     it("should handle SET_NOTIFICATION action", () => {
         const action = {
             type: Types.SetNotification,
-            atype: "error",
+            nType: "error",
             message: "some error message",
             system: true,
             duration: 3000,
@@ -589,11 +589,12 @@ describe("taipyReducer function", () => {
         };
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         expect(newState.notifications).toContainEqual({
-            atype: action.atype,
+            nType: action.nType,
             message: action.message,
             system: action.system,
             duration: action.duration,
             notificationId: action.notificationId,
+            snackbarId: action.notificationId,
         });
     });
     it("should handle DELETE_NOTIFICATION action", () => {
@@ -603,30 +604,33 @@ describe("taipyReducer function", () => {
             ...INITIAL_STATE,
             notifications: [
                 {
-                    atype: "error",
+                    nType: "error",
                     message: "First Notification",
                     system: true,
                     duration: 5000,
                     notificationId: notificationId1,
+                    snackbarId: notificationId1
                 },
                 {
-                    atype: "warning",
+                    nType: "warning",
                     message: "Second Notification",
                     system: false,
                     duration: 3000,
                     notificationId: notificationId2,
+                    snackbarId: notificationId2
                 },
             ],
         };
-        const action = { type: Types.DeleteNotification, notificationId: notificationId1 };
+        const action = { type: Types.DeleteNotification, snackbarId: notificationId1 };
         const newState = taipyReducer(initialState, action);
         expect(newState.notifications).toEqual([
             {
-                atype: "warning",
+                nType: "warning",
                 message: "Second Notification",
                 system: false,
                 duration: 3000,
                 notificationId: notificationId2,
+                snackbarId: notificationId2
             },
         ]);
     });
@@ -638,18 +642,20 @@ describe("taipyReducer function", () => {
             ...INITIAL_STATE,
             notifications: [
                 {
-                    atype: "error",
+                    nType: "error",
                     message: "First Notification",
                     system: true,
                     duration: 5000,
                     notificationId: notificationId1,
+                    snackbarId: notificationId1
                 },
                 {
-                    atype: "warning",
+                    nType: "warning",
                     message: "Second Notification",
                     system: false,
                     duration: 3000,
                     notificationId: notificationId2,
+                    snackbarId: notificationId2
                 },
             ],
         };
@@ -672,29 +678,32 @@ describe("taipyReducer function", () => {
             notifications: [
                 {
                     message: "Notification1",
-                    atype: "type1",
+                    nType: "type1",
                     system: true,
                     duration: 5000,
                     notificationId: notificationId1,
+                    snackbarId: notificationId1
                 },
                 {
                     message: "Notification2",
-                    atype: "type2",
+                    nType: "type2",
                     system: false,
                     duration: 3000,
                     notificationId: notificationId2,
+                    snackbarId: notificationId2
                 },
             ],
         };
-        const action = { type: Types.DeleteNotification, notificationId: notificationId1 };
+        const action = { type: Types.DeleteNotification, snackbarId: notificationId1 };
         const newState = taipyReducer(initialState, action);
         expect(newState.notifications).toEqual([
             {
                 message: "Notification2",
-                atype: "type2",
+                nType: "type2",
                 system: false,
                 duration: 3000,
                 notificationId: notificationId2,
+                snackbarId: notificationId2
             },
         ]);
     });
@@ -847,7 +856,7 @@ describe("addRows function", () => {
     });
 });
 
-describe("retreiveBlockUi function", () => {
+describe("retrieveBlockUi function", () => {
     it("should retrieve block message from localStorage", () => {
         const mockBlockMessage = { action: "testAction", noCancel: false, close: false, message: "testMessage" };
         Storage.prototype.getItem = jest.fn(() => JSON.stringify(mockBlockMessage));
@@ -928,10 +937,10 @@ describe("messageToAction function", () => {
         };
         expect(result).toEqual(expected);
     });
-    it('should call createAlertAction if message type is "AL"', () => {
+    it('should call createNotificationAction if message type is "AL"', () => {
         const message: WsMessage & Partial<NotificationMessage> = {
             type: "AL",
-            atype: "I",
+            nType: "I",
             name: "someName",
             payload: {},
             propagate: true,
@@ -1133,7 +1142,7 @@ describe("initializeWebSocket function", () => {
                 mockSocket,
                 "ID",
                 "TaipyClientId",
-                "mockId",
+                { "id": "mockId" },
                 "mockId",
                 undefined,
                 false,

+ 23 - 18
frontend/taipy-gui/src/context/taipyReducers.ts

@@ -89,11 +89,12 @@ export interface NamePayload {
 }
 
 export interface NotificationMessage {
-    atype: string;
+    nType: string;
     message: string;
     system: boolean;
     duration: number;
     notificationId?: string;
+    snackbarId: string;
 }
 
 interface TaipyAction extends NamePayload, TaipyBaseAction {
@@ -111,8 +112,8 @@ interface TaipyMultipleMessageAction extends TaipyBaseAction {
 
 interface TaipyNotificationAction extends TaipyBaseAction, NotificationMessage {}
 
-interface TaipyDeleteAlertAction extends TaipyBaseAction {
-    notificationId: string;
+interface TaipyDeleteNotificationAction extends TaipyBaseAction {
+    snackbarId: string;
 }
 
 export const BLOCK_CLOSE = { action: "", message: "", close: true, noCancel: false } as BlockMessage;
@@ -317,7 +318,7 @@ export const initializeWebSocket = (socket: Socket | undefined, dispatch: Dispat
         });
         // try to reconnect on connect_error
         socket.on("connect_error", (error) => {
-            if ((error as unknown as Record<string, unknown>).type === "TransportError") {
+            if (error && (error as unknown as Record<string, unknown>).type === "TransportError") {
                 lastReasonServer = true;
             }
             setTimeout(() => socket.connect(), 500);
@@ -407,11 +408,12 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta
                 notifications: [
                     ...state.notifications,
                     {
-                        atype: notificationAction.atype,
+                        nType: notificationAction.nType,
                         message: notificationAction.message,
                         system: notificationAction.system,
                         duration: notificationAction.duration,
-                        notificationId: notificationAction.notificationId || nanoid(),
+                        notificationId: notificationAction.notificationId,
+                        snackbarId: notificationAction.nType ? nanoid() : notificationAction.nType
                     },
                 ],
             };
@@ -420,7 +422,7 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta
             return {
                 ...state,
                 notifications: state.notifications.filter(
-                    (notification) => notification.notificationId !== deleteNotificationAction.notificationId
+                    (notification) => notification.snackbarId !== deleteNotificationAction.snackbarId
                 ),
             };
         case Types.SetBlock:
@@ -833,11 +835,11 @@ export const createTimeZoneAction = (timeZone: string, fromBackend = false): Tai
     payload: { timeZone: timeZone, fromBackend: fromBackend },
 });
 
-const getNotificationType = (aType: string) => {
-    aType = aType.trim();
-    if (aType) {
-        aType = aType.charAt(0).toLowerCase();
-        switch (aType) {
+const getNotificationType = (nType: string) => {
+    nType = nType.trim();
+    if (nType) {
+        nType = nType.charAt(0).toLowerCase();
+        switch (nType) {
             case "e":
                 return "error";
             case "w":
@@ -848,22 +850,25 @@ const getNotificationType = (aType: string) => {
                 return "info";
         }
     }
-    return aType;
+    return nType;
 };
 
 export const createNotificationAction = (notification: NotificationMessage): TaipyNotificationAction => ({
     type: Types.SetNotification,
-    atype: getNotificationType(notification.atype),
+    nType: getNotificationType(notification.nType),
     message: notification.message,
     system: notification.system,
     duration: notification.duration,
     notificationId: notification.notificationId,
+    snackbarId: notification.snackbarId
 });
 
-export const createDeleteAlertAction = (notificationId: string): TaipyDeleteAlertAction => ({
-    type: Types.DeleteNotification,
-    notificationId,
-});
+export const createDeleteNotificationAction = (snackbarId: string): TaipyDeleteNotificationAction => {
+    return {
+        type: Types.DeleteNotification,
+        snackbarId,
+    }
+}
 
 export const createBlockAction = (block: BlockMessage): TaipyBlockAction => ({
     type: Types.SetBlock,

+ 8 - 10
taipy/gui/gui.py

@@ -21,7 +21,6 @@ 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
@@ -1452,12 +1451,12 @@ class Gui:
             send_back_only=True,
         )
 
-    def __send_ws_alert(
+    def __send_ws_notification(
         self, type: str, message: str, system_notification: bool, duration: int, notification_id: t.Optional[str] = None
     ) -> None:
         payload = {
             "type": _WsType.ALERT.value,
-            "atype": type,
+            "nType": type,
             "message": message,
             "system": system_notification,
             "duration": duration,
@@ -2317,7 +2316,9 @@ class Gui:
                 self._bind(encoded_var_name, bind_locals[var_name])
             else:
                 _warn(
-                    f"Variable '{var_name}' is not available in either the '{self._get_locals_context()}' or '__main__' modules."  # noqa: E501
+                    f"Variable '{var_name}' is not available in the '__main__' module."
+                    if self._get_locals_context() == "__main__"
+                    else f"Variable '{var_name}' is not available in either the '{self._get_locals_context()}' or '__main__' modules."  # noqa: E501
                 )
         return encoded_var_name
 
@@ -2404,10 +2405,7 @@ class Gui:
         duration: t.Optional[int] = None,
         notification_id: t.Optional[str] = None,
     ):
-        if not notification_id:
-            notification_id = str(uuid.uuid4())
-
-        self.__send_ws_alert(
+        self.__send_ws_notification(
             notification_type,
             message,
             self._get_config("system_notification", False) if system_notification is None else system_notification,
@@ -2421,8 +2419,8 @@ class Gui:
         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
+            self.__send_ws_notification(
+                type="",  # Empty string indicates closing
                 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

+ 35 - 19
taipy/gui/gui_actions.py

@@ -64,50 +64,66 @@ def download(
 
 def notify(
     state: State,
-    notification_type: str = "I",
+    notification_type: str = "info",
     message: str = "",
     system_notification: t.Optional[bool] = None,
     duration: t.Optional[int] = None,
-    notification_id: str = "",
-):
+    id: str = "",
+) -> None:
     """Send a notification to the user interface.
 
     Arguments:
         state (State^): The current user state as received in any callback.
         notification_type: The notification type. This can be one of "success", "info",
-            "warning", or "error".<br/>
-            To remove the last notification, set this parameter to the empty string.
+            "warning", or "error".
         message: The text message to display.
         system_notification: If True, the system will also show the notification.<br/>
             If not specified or set to None, this parameter will use the value of
             *configuration[system_notification]*.
-        duration: The time, in milliseconds, during which the notification is shown.
+        duration: The time, in milliseconds, that the notification is displayed.<br/>
             If not specified or set to None, this parameter will use the value of
-            *configuration[notification_duration]*.
+            *configuration[notification_duration]*.<br/>
+            If *duration* is 0, the notification remains visible indefinitely until closed. If *id*
+            is set to a non-empty string, the application can call `close_notification(id)^` to
+            close the notification. The user can always manually close the notification.
+        id: An optional identifier for this notification, so the application can close it explicitly
+            using `close_notification()^` before the *duration* delay has passed.
 
     Note that you can also call this function with *notification_type* set to the first letter
-    or the alert type (i.e. setting *notification_type* to "i" is equivalent to setting it to
+    or the notification type (i.e. setting *notification_type* to "i" is equivalent to setting it to
     "info").
 
-    If *system_notification* is set to True, then the browser requests the system
-    to display a notification as well. They usually appear in small windows that
-    fly out of the system tray.<br/>
-    The first time your browser is requested to show such a system notification for
-    Taipy applications, you may be prompted to authorize the browser to do so. Please
-    refer to your browser documentation for details on how to allow or prevent this
-    feature.
+    If *system_notification* is set to True, then the browser requests the system to display a
+    notification as well. They usually appear in small windows that fly out of the system tray.<br/>
+    When a Taipy application requests a system notification for the first time, the browser may
+    prompt the user for permission. The  browser documentation will describe how to allow or prevent
+    this feature.<br/>
+    If the user denies system notification permissions, the system notifications will not be
+    displayed, but the in-app notification will still function.
     """
     if state and isinstance(state._gui, Gui):
-        return state._gui._notify(notification_type, message, system_notification, duration, notification_id)
+        return state._gui._notify(notification_type, message, system_notification, duration, 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."""
+def close_notification(state: State, id: str) -> None:
+    """Close a specific notification.
+
+    This function closes a persistent notification by using the same identifier that was provided to
+    `notify()^`.<br/>
+    If multiple notifications were created with the same identifier, they will all be closed
+    simultaneously.
+
+    If no notification with this identifier exists, no action is taken.
+
+    Arguments:
+        state (State^): The current user state as received in any callback.
+        id: The identifier of the notification(s) that must be closed.
+    """
     if state and isinstance(state._gui, Gui):
         # Send the close command with the notification_id
-        state._gui._close_notification(notification_id)
+        state._gui._close_notification(id)
     else:
         _warn("'close_notification()' must be called in the context of a callback.")
 

+ 1 - 1
taipy/gui/viselements.json

@@ -1679,7 +1679,7 @@
                         "default_property": true,
                         "type": "dynamic(str)",
                         "default_value": "\"\"",
-                        "doc": "The message displayed in the notification. Can be a dynamic string."
+                        "doc": "The message displayed in the notification."
                     },
                     {
                         "name": "severity",

+ 1 - 1
tests/gui/actions/test_notify.py

@@ -36,4 +36,4 @@ def test_notify(gui: Gui, helpers):
         notify(gui._Gui__state, "Info", "Message")  # type: ignore[attr-defined]
 
     received_messages = ws_client.get_received()
-    helpers.assert_outward_ws_simple_message(received_messages[0], "AL", {"atype": "Info", "message": "Message"})
+    helpers.assert_outward_ws_simple_message(received_messages[0], "AL", {"nType": "Info", "message": "Message"})