Skip to content

Feature: #2471 notification on_close #2519

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions frontend/taipy-gui/src/components/Taipy/Notification.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import "@testing-library/jest-dom";
import { SnackbarProvider } from "notistack";

import TaipyNotification from "./Notification";
import { NotificationMessage } from "../../context/taipyReducers";
import { NotificationMessage, TaipyState, INITIAL_STATE } from "../../context/taipyReducers";
import userEvent from "@testing-library/user-event";
import { TaipyContext } from "../../context/taipyContext";
import * as hooks from "../../utils/hooks";

const defaultMessage = "message";
const defaultNotifications: NotificationMessage[] = [
Expand All @@ -38,6 +40,9 @@ describe("Notifications", () => {
beforeEach(() => {
jest.clearAllMocks();
});

const mockDispatch = jest.fn();

it("renders", async () => {
const { getByText } = render(
<SnackbarProvider>
Expand Down Expand Up @@ -137,14 +142,14 @@ describe("Notifications", () => {
notificationId: "nId",
snackbarId: "nId",
};
const notifications = [ baseNotification ];
const notifications = [baseNotification];
const { rerender } = render(
<SnackbarProvider>
<TaipyNotification notifications={notifications} />
</SnackbarProvider>
);
await screen.findByRole("button", { name: /close/i });
const newNotifications = [ { ...baseNotification, nType: "" }];
const newNotifications = [{ ...baseNotification, nType: "" }];
rerender(
<SnackbarProvider>
<TaipyNotification notifications={newNotifications} />
Expand Down Expand Up @@ -235,4 +240,52 @@ describe("Notifications", () => {
expect(linkElement?.getAttribute("href")).toBe("/test-shortcut-icon.png");
document.head.removeChild(link);
});

it("dispatches the correct actions when a notification closes due to timeout", async () => {
jest.spyOn(hooks, "useModule").mockReturnValue("testModule");
const mockDispatch = jest.fn();
const state: TaipyState = INITIAL_STATE;

const notification = {
notificationId: "test-id",
snackbarId: "test-snackbar",
message: "Test message",
nType: "info",
duration: 100,
onClose: "onCloseCallback",
system: false,
};

render(
<SnackbarProvider>
<TaipyContext.Provider value={{ state, dispatch: mockDispatch }}>
<TaipyNotification notifications={[notification]} />
</TaipyContext.Provider>
</SnackbarProvider>
);

await waitFor(() => {
const notificationElement = screen.queryByText("Test message");
expect(notificationElement).not.toBeInTheDocument();
});

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "DELETE_NOTIFICATION",
snackbarId: "test-snackbar",
})
);

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "SEND_ACTION_ACTION",
context: "testModule",
name: "test-id",
payload: {
action: "onCloseCallback",
args: ["timeout"],
},
})
);
});
});
24 changes: 17 additions & 7 deletions frontend/taipy-gui/src/components/Taipy/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { SnackbarKey, useSnackbar, VariantType, CloseReason } from "notistack";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";

import { NotificationMessage, createDeleteNotificationAction } from "../../context/taipyReducers";
import { useDispatch } from "../../utils/hooks";
import { NotificationMessage, createDeleteNotificationAction, createSendActionNameAction } from "../../context/taipyReducers";
import { useDispatch, useModule } from "../../utils/hooks";

interface NotificationProps {
notifications: NotificationMessage[];
Expand All @@ -28,6 +28,7 @@ const TaipyNotification = ({ notifications: notificationProps }: NotificationPro
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const snackbarIds = useRef<Record<string, string>>({});
const dispatch = useDispatch();
const module = useModule();

const closeNotifications = useCallback(
(ids: string[]) => {
Expand All @@ -50,9 +51,17 @@ const TaipyNotification = ({ notifications: notificationProps }: NotificationPro
[closeNotifications]
);

const notificationClosed = (event: SyntheticEvent | null, reason: CloseReason, key?: SnackbarKey) => {
snackbarIds.current = Object.fromEntries(Object.entries(snackbarIds.current).filter(([id]) => id !== key));
};
const notificationClosed = useCallback(
(event: SyntheticEvent | null, reason: CloseReason, key?: SnackbarKey, callback?: string) => {
if (callback) {
dispatch(createSendActionNameAction(notification?.notificationId, module, callback, reason === "timeout" ? "timeout" : "forced"));
}
snackbarIds.current = Object.fromEntries(
Object.entries(snackbarIds.current).filter(([id]) => id !== key)
);
},
[dispatch, module, notification?.notificationId]
);

const faviconUrl = useMemo(() => {
const nodeList = document.getElementsByTagName("link");
Expand Down Expand Up @@ -85,16 +94,17 @@ const TaipyNotification = ({ notifications: notificationProps }: NotificationPro
enqueueSnackbar(notification.message, {
variant: notification.nType as VariantType,
action: notificationAction,
onClose: notificationClosed,
onClose: (event, reason, key) => notificationClosed(event, reason, key, notification.onClose),
key: notification.snackbarId,
autoHideDuration: notification.duration || null,
});
notification.system &&
new Notification(document.title || "Taipy", { body: notification.message, icon: faviconUrl });
}

dispatch(createDeleteNotificationAction(notification.snackbarId));
}
}, [notification, enqueueSnackbar, closeNotifications, notificationAction, faviconUrl, dispatch]);
}, [notification, enqueueSnackbar, closeNotifications, notificationAction, faviconUrl, dispatch, notificationClosed]);

useEffect(() => {
notification?.system && window.Notification && Notification.requestPermission();
Expand Down
8 changes: 6 additions & 2 deletions frontend/taipy-gui/src/context/taipyReducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export interface NotificationMessage {
duration: number;
notificationId?: string;
snackbarId: string;
onClose?: string;
reason?: string;
}

interface TaipyAction extends NamePayload, TaipyBaseAction {
Expand Down Expand Up @@ -417,7 +419,8 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta
system: notificationAction.system,
duration: notificationAction.duration,
notificationId: notificationAction.notificationId,
snackbarId: notificationAction.nType ? nanoid() : notificationAction.nType
snackbarId: notificationAction.nType ? nanoid() : notificationAction.nType,
onClose: notificationAction?.onClose,
},
],
};
Expand Down Expand Up @@ -864,7 +867,8 @@ export const createNotificationAction = (notification: NotificationMessage): Tai
system: notification.system,
duration: notification.duration,
notificationId: notification.notificationId,
snackbarId: notification.snackbarId
snackbarId: notification.snackbarId,
onClose: notification?.onClose,
});

export const createDeleteNotificationAction = (snackbarId: string): TaipyDeleteNotificationAction => {
Expand Down
35 changes: 34 additions & 1 deletion taipy/gui/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1457,14 +1457,23 @@ def __send_ws_download(self, content: str, name: str, on_action: str, module: st
)

def __send_ws_notification(
self, type: str, message: str, system_notification: bool, duration: int, notification_id: t.Optional[str] = None
self,
type: str,
message: str,
system_notification: bool,
duration: int,
notification_id: t.Optional[str] = None,
reason: t.Optional[str] = None,
on_close_str: t.Optional[str] = None,
) -> None:
payload = {
"type": _WsType.ALERT.value,
"nType": type,
"message": message,
"system": system_notification,
"duration": duration,
"reason": reason,
"onClose": on_close_str,
}

if notification_id:
Expand Down Expand Up @@ -2410,19 +2419,41 @@ def _notify(
system_notification: t.Optional[bool] = None,
duration: t.Optional[int] = None,
notification_id: t.Optional[str] = None,
on_close: t.Optional[t.Union[str, t.Callable[[State, str, str], None]]] = "",
):
on_close_str = None

if notification_id and on_close:
if isinstance(on_close, str):
func = self._get_user_function(on_close)
if callable(func):
on_close_str = on_close
else:
_warn(f"Notification on_close callback '{on_close}' is not a valid function.")

elif _is_function(on_close):
on_close_str = on_close.__name__
func = self._get_user_function(on_close_str)
if not callable(func):
_warn(f"Function '{on_close_str}' from on_close callable is not valid.")
else:
_warn(f"Invalid on_close value for notification {notification_id}: {on_close}")

self.__send_ws_notification(
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,
reason=None,
on_close_str=on_close_str,
)
return notification_id

def _close_notification(
self,
notification_id: str,
reason: t.Optional[str] = "user_action",
):
if notification_id:
self.__send_ws_notification(
Expand All @@ -2431,6 +2462,8 @@ def _close_notification(
system_notification=False, # System notification not needed for closing
duration=0, # No duration since it's an immediate close
notification_id=notification_id,
reason=reason,
on_close_str=None, # No need for on_close callback when closing
)

def _hold_actions(
Expand Down
8 changes: 6 additions & 2 deletions taipy/gui/gui_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def notify(
system_notification: t.Optional[bool] = None,
duration: t.Optional[int] = None,
id: str = "",
on_close: t.Optional[t.Callable[[State, str, str], None]] = None,
) -> t.Optional[str]:
"""Send a notification to the user interface.

Expand All @@ -86,6 +87,8 @@ def notify(
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.
on_close: An optional callback function that is called when the notification is closed.
The signature of this function is: `on_close(state: State, id: str, reason: string) -> None`.

Note that you can also call this function with *notification_type* set to the first letter
or the notification type (i.e. setting *notification_type* to "i" is equivalent to setting it to
Expand All @@ -100,12 +103,13 @@ def notify(
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, id) # type: ignore[attr-defined]
return state._gui._notify(notification_type, message,
system_notification, duration, id,
on_close) # type: ignore[attr-defined]
else:
_warn("'notify()' must be called in the context of a callback.")
return None


def close_notification(state: State, id: str) -> None:
"""Close a specific notification.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import pandas as pd
import pytest
from flask import g

from taipy.gui import Gui
from taipy.gui.utils import _get_module_name_from_frame, _TaipyContent
Expand Down Expand Up @@ -89,15 +90,18 @@ def test__get_valid_adapter_result(gui: Gui):
assert isinstance(res, tuple)
assert res[0] == "id"

def test_on_action_call(gui:Gui):

def test_on_action_call(gui: Gui):
an_id = "my_id"

a_non_action_payload = {"a": "b"}

def on_action(state, id, payload):
assert id == an_id
assert payload is a_non_action_payload

an_action_payload = {"action": "on_an_action"}

def on_an_action(state, id, payload):
assert id == an_id
assert payload is an_action_payload
Expand All @@ -107,5 +111,50 @@ def on_an_action(state, id, payload):

gui.run(run_server=False)
with gui.get_flask_app().app_context():
gui._Gui__on_action(an_id, a_non_action_payload) # type: ignore[attr-defined]
gui._Gui__on_action(an_id, an_action_payload) # type: ignore[attr-defined]
gui._Gui__on_action(an_id, a_non_action_payload) # type: ignore[attr-defined]
gui._Gui__on_action(an_id, an_action_payload) # type: ignore[attr-defined]


def test_notification_on_close(gui: Gui, helpers):
notification_id = "test-id"

def on_notification_closed(state, notif_id, reason=None):
assert notif_id == notification_id
assert reason == "timeout"

gui._set_frame(inspect.currentframe())
gui.run(run_server=False)

flask_client = gui._server.test_client()
ws_client = gui._server._ws.test_client(gui._server.get_flask()) # type: ignore[arg-type]

cid = helpers.create_scope_and_get_sid(gui)
flask_client.get(f"/taipy-jsx/test?client_id={cid}")

with gui.get_flask_app().test_request_context(f"/taipy-jsx/test/?client_id={cid}", data={"client_id": cid}):
g.client_id = cid

gui._notify(
notification_type="warning",
message="This is a test notification!",
system_notification=False,
duration=3000,
notification_id=notification_id,
on_close=on_notification_closed,
)

received_messages = ws_client.get_received()

helpers.assert_outward_ws_simple_message(
received_messages[0],
"AL",
{
"nType": "warning",
"message": "This is a test notification!",
"system": False,
"duration": 3000,
"notificationId": notification_id,
"reason": None,
"onClose": "on_notification_closed",
},
)