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 11 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,6 @@ demo[_|-]*/
data_sources
pipelines
pickles

build.sh
.frontend/taipy-gui/dom/package-lock.json
1 change: 1 addition & 0 deletions frontend/taipy-gui/dom/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 90 additions & 1 deletion frontend/taipy-gui/src/components/Taipy/Notification.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import "@testing-library/jest-dom";
import { SnackbarProvider } from "notistack";

import TaipyNotification from "./Notification";
import { NotificationMessage } from "../../context/taipyReducers";
import { NotificationMessage, createSendActionNameAction } from "../../context/taipyReducers";
import userEvent from "@testing-library/user-event";

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

const mockDispatch = jest.fn();

it("renders", async () => {
const { getByText } = render(
<SnackbarProvider>
Expand Down Expand Up @@ -235,4 +238,90 @@ describe("Notifications", () => {
expect(linkElement?.getAttribute("href")).toBe("/test-shortcut-icon.png");
document.head.removeChild(link);
});

it("should dispatch createSendActionNameAction with 'forced' when a notification is manually closed", async () => {
const notification = {
notificationId: "test-id",
snackbarId: "test-snackbar",
message: "Test message",
nType: "info",
duration: 3000,
onClose: "onCloseCallback",
system: false,
};

const wrapper = render(
<SnackbarProvider>
<TaipyNotification notifications={[notification]} />
</SnackbarProvider>
);

const onCloseFn = wrapper.container.querySelector("div");

await waitFor(() => {
const module = "testModule";
const action = createSendActionNameAction(
"test-id",
module,
"onCloseCallback",
"forced"
);

mockDispatch(action);
});

expect(mockDispatch).toHaveBeenCalledWith(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're only testing the createSendActionNAmeAction here ?

expect.objectContaining({
type: "SEND_ACTION_ACTION",
context: "testModule",
name: "test-id",
payload: {
action: "onCloseCallback",
args: ["forced"],
}
})
);
});

it("should dispatch createSendActionNameAction with 'timeout' when a notification times out", async () => {
const notification = {
notificationId: "test-id",
snackbarId: "test-snackbar",
message: "Test message",
nType: "info",
duration: 3000,
onClose: "onCloseCallback",
system: false,
};

const module = "testModule";

const wrapper = render(
<SnackbarProvider>
<TaipyNotification notifications={[notification]} />
</SnackbarProvider>
);

await waitFor(() => {
const action = createSendActionNameAction(
"test-id",
module,
"onCloseCallback",
"timeout"
);
mockDispatch(action);
});

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
22 changes: 22 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from taipy.gui import Gui, notify

# Function to trigger a notification
def send_notification(state):
notify(state, "warning", "This is a test notification!", None, None, "3", on_notification_closed)

# Function triggered when a notification is closed (from frontend)
def on_notification_closed(state, notification_id, reason=None):
print("Notification closed from frontend")
print(f"Notification {notification_id} closed from frontend. Reason: {reason}")

# GUI page setup
page = """
# Notification Demo

Click the button to trigger a notification:

<|button|text=Send Notification|on_action=send_notification|>
"""

if __name__ == "__main__":
Gui(page).run()
32 changes: 29 additions & 3 deletions taipy/gui/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1457,14 +1457,16 @@ 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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and there too (on_close_str)

"onClose": on_close_str,
}

if notification_id:
Expand Down Expand Up @@ -2402,27 +2404,49 @@ def _download(self, content: t.Any, name: t.Optional[str] = "", on_action: t.Uni
self.__send_ws_download(
content_str, str(name), str(on_action) if on_action is not None else "", self._get_locals_context()
)

def _notify(
self,
notification_type: str = "I",
message: str = "",
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__
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lambda functions are not handled

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 +2455,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
6 changes: 4 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,11 @@ 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