From 5a6ddfd161153ed57fbf09e3c2e525100416c813 Mon Sep 17 00:00:00 2001 From: Katarzyna Rumanowska Date: Wed, 21 Oct 2026 16:36:52 +0200 Subject: [PATCH 1/3] feat(ToastNotification): add dismissible flag support --- .../ToastNotification.stories.tsx | 13 ++++ .../ToastNotification.test.tsx | 72 ++++++++++++++++++- .../ToastNotification/ToastNotification.tsx | 10 ++- .../ToastNotificationList.tsx | 20 ++++-- .../ToastNotificationProvider.tsx | 35 ++++++--- 5 files changed, 132 insertions(+), 18 deletions(-) diff --git a/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx b/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx index f6b37eea1..e1d162754 100644 --- a/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx @@ -120,6 +120,19 @@ const PreloadedList = () => { + ); diff --git a/src/components/Notifications/ToastNotification/ToastNotification.test.tsx b/src/components/Notifications/ToastNotification/ToastNotification.test.tsx index 6c7d6408e..ca4ac3ed2 100644 --- a/src/components/Notifications/ToastNotification/ToastNotification.test.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotification.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import ToastNotification from "./ToastNotification"; import ToastNotificationList from "./ToastNotificationList"; @@ -106,6 +106,19 @@ describe("ToastNotification", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + it("does not render a dismiss button when the notification is not dismissable", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: /close/i }), + ).not.toBeInTheDocument(); + }); + const notifications: ToastNotificationType[] = [ { id: "1", @@ -188,6 +201,63 @@ describe("ToastNotification", () => { expect(onDismiss).toHaveBeenCalledWith(notifications); }); + it("does not render a dismiss button for a non-dismissable notification in the list", () => { + const mixedNotifications: ToastNotificationType[] = [ + ...notifications, + { + id: "3", + type: "caution", + message: "Cannot be dismissed.", + title: "Warning", + dismissable: false, + }, + ]; + + render( + , + ); + + const warningItem = screen.getByText("Warning").closest("li"); + expect(warningItem).not.toBeNull(); + expect( + within(warningItem as HTMLElement).queryByTestId( + "notification-close-button", + ), + ).not.toBeInTheDocument(); + }); + + it("excludes non-dismissable notifications from 'Dismiss all'", async () => { + const onDismiss = jest.fn(); + const mixedNotifications: ToastNotificationType[] = [ + ...notifications, + { + id: "3", + type: "caution", + message: "Cannot be dismissed.", + title: "Warning", + dismissable: false, + }, + ]; + + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Dismiss all" })); + + expect(onDismiss).toHaveBeenCalledWith(notifications); + }); + it("calls onHoverStart on mouse enter and onHoverEnd on mouse leave", async () => { const onHoverStart = jest.fn(); const onHoverEnd = jest.fn(); diff --git a/src/components/Notifications/ToastNotification/ToastNotification.tsx b/src/components/Notifications/ToastNotification/ToastNotification.tsx index d95607dcd..d43a0d3a1 100644 --- a/src/components/Notifications/ToastNotification/ToastNotification.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotification.tsx @@ -53,9 +53,13 @@ const ToastNotification: FC = ({ title={notification.title ?? DefaultTitles[notification.type]} actions={notification.actions} severity={notification.type} - onDismiss={() => { - onDismiss([notification]); - }} + onDismiss={ + notification.dismissable === false + ? undefined + : () => { + onDismiss([notification]); + } + } className="u-no-margin--bottom" timestamp={notification.timestamp} titleElement="div" diff --git a/src/components/Notifications/ToastNotification/ToastNotificationList.tsx b/src/components/Notifications/ToastNotification/ToastNotificationList.tsx index 05745e253..5d544d873 100644 --- a/src/components/Notifications/ToastNotification/ToastNotificationList.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotificationList.tsx @@ -97,16 +97,20 @@ const ToastNotificationList: FC = ({ }; const handleGroupedDismiss = () => { + const dismissableNotifications = notifications.filter( + (notification) => notification.dismissable !== false, + ); + if (hasFilters) { - const notificationsToClear = notifications.filter((notification) => - filters.has(notification.type), + const notificationsToClear = dismissableNotifications.filter( + (notification) => filters.has(notification.type), ); onDismiss(notificationsToClear); setFilters(new Set()); return; } - onDismiss(notifications); + onDismiss(dismissableNotifications); }; const getSeverityFilters = () => { @@ -221,9 +225,13 @@ const ToastNotificationList: FC = ({ title={notification.title ?? DefaultTitles[notification.type]} actions={notification.actions} severity={notification.type} - onDismiss={() => { - handleDismissNotification(notification); - }} + onDismiss={ + notification.dismissable === false + ? undefined + : () => { + handleDismissNotification(notification); + } + } className={`u-no-margin--bottom individual-notification`} timestamp={notification.timestamp} titleElement="div" diff --git a/src/components/Notifications/ToastNotification/ToastNotificationProvider.tsx b/src/components/Notifications/ToastNotification/ToastNotificationProvider.tsx index 21b48ed73..22860da40 100644 --- a/src/components/Notifications/ToastNotification/ToastNotificationProvider.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotificationProvider.tsx @@ -21,6 +21,7 @@ const HIDE_NOTIFICATION_DELAY = 5_000; export type ToastNotificationType = NotificationType & { timestamp?: ReactNode; id: string; + dismissable?: boolean; }; interface Props { @@ -35,12 +36,14 @@ interface ToastNotificationHelper { actions?: NotificationAction[], title?: string, id?: ToastNotificationType["id"], + dismissable?: boolean, ) => ToastNotificationType; info: ( message: ReactNode, title?: string, actions?: NotificationAction[], id?: ToastNotificationType["id"], + dismissable?: boolean, ) => ToastNotificationType; failure: ( title: string, @@ -48,12 +51,14 @@ interface ToastNotificationHelper { message?: ReactNode, actions?: NotificationAction[], id?: ToastNotificationType["id"], + dismissable?: boolean, ) => ToastNotificationType; caution: ( message: ReactNode, actions?: NotificationAction[], title?: string, id?: ToastNotificationType["id"], + dismissable?: boolean, ) => ToastNotificationType; clear: (notification?: ToastNotificationType[]) => void; toggleListView: () => void; @@ -234,6 +239,7 @@ const ToastNotificationProvider: FC> = ({ const addNotification = ( notification: NotificationType & { error?: unknown } & { id?: ToastNotificationType["id"]; + dismissable?: boolean; }, ) => { const notificationToAdd = { @@ -242,6 +248,7 @@ const ToastNotificationProvider: FC> = ({ id: notification.id ?? Date.now().toString() + (Math.random() + 1).toString(36).substring(7), + dismissable: notification.dismissable ?? true, }; setNotifications((prev) => { @@ -299,14 +306,26 @@ const ToastNotificationProvider: FC> = ({ const helper: ToastNotificationHelper = { notifications, - failure: (title, error, message, actions, id) => - addNotification({ ...failure(title, error, message, actions), id }), - info: (message, title, actions, id) => - addNotification({ ...info(message, title, actions), id }), - success: (message, actions, title, id) => - addNotification({ ...success(message, title, actions), id }), - caution: (message, actions, title, id) => - addNotification({ ...caution(message, title, actions), id }), + failure: (title, error, message, actions, id, dismissable) => + addNotification({ + ...failure(title, error, message, actions), + id, + dismissable, + }), + info: (message, title, actions, id, dismissable) => + addNotification({ ...info(message, title, actions), id, dismissable }), + success: (message, actions, title, id, dismissable) => + addNotification({ + ...success(message, title, actions), + id, + dismissable, + }), + caution: (message, actions, title, id, dismissable) => + addNotification({ + ...caution(message, title, actions), + id, + dismissable, + }), clear, toggleListView, isListView: showList, From 69cdfc4c0b43a2e6c00618b8f9e36281c5b30296 Mon Sep 17 00:00:00 2001 From: Katarzyna Rumanowska Date: Thu, 22 Oct 2026 10:30:56 +0200 Subject: [PATCH 2/3] address pr comments --- .../ToastNotification.stories.tsx | 7 ++ .../ToastNotification.test.tsx | 73 +++++++++++++++++++ .../ToastNotificationList.tsx | 57 +++++++-------- 3 files changed, 107 insertions(+), 30 deletions(-) diff --git a/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx b/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx index e1d162754..f868fd12e 100644 --- a/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx @@ -71,6 +71,13 @@ const PreloadedList = () => { "Please try again.", ); toastNotify.caution("You have unsaved changes."); + toastNotify.caution( + "This cannot be dismissed until the issue is resolved.", + undefined, + "Action required", + undefined, + false, + ); toastNotify.toggleListView(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/Notifications/ToastNotification/ToastNotification.test.tsx b/src/components/Notifications/ToastNotification/ToastNotification.test.tsx index ca4ac3ed2..8ea901b5a 100644 --- a/src/components/Notifications/ToastNotification/ToastNotification.test.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotification.test.tsx @@ -256,6 +256,79 @@ describe("ToastNotification", () => { await userEvent.click(screen.getByRole("button", { name: "Dismiss all" })); expect(onDismiss).toHaveBeenCalledWith(notifications); + expect(screen.getByText("Cannot be dismissed.")).toBeInTheDocument(); + }); + + it("does not render 'Dismiss all' when no notifications are dismissable", () => { + const nonDismissableNotifications: ToastNotificationType[] = [ + { + id: "1", + type: "positive", + message: "Cannot be dismissed.", + title: "Success", + dismissable: false, + }, + { + id: "2", + type: "negative", + message: "Also cannot be dismissed.", + title: "Failure", + dismissable: false, + }, + ]; + + render( + , + ); + + expect( + screen.queryByRole("button", { name: "Dismiss all" }), + ).not.toBeInTheDocument(); + }); + + it("shows a dismiss count that excludes non-dismissable notifications within a filtered severity", async () => { + const onDismiss = jest.fn(); + const mixedCautionNotifications: ToastNotificationType[] = [ + { + id: "1", + type: "caution", + message: "Can be dismissed.", + title: "Warning", + }, + { + id: "2", + type: "caution", + message: "Cannot be dismissed.", + title: "Warning", + dismissable: false, + }, + ]; + + render( + , + ); + + await userEvent.click( + screen.getByLabelText("Filter caution notifications"), + ); + + expect( + screen.getByRole("button", { name: "Dismiss 1" }), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Dismiss 1" })); + + expect(onDismiss).toHaveBeenCalledWith([mixedCautionNotifications[0]]); }); it("calls onHoverStart on mouse enter and onHoverEnd on mouse leave", async () => { diff --git a/src/components/Notifications/ToastNotification/ToastNotificationList.tsx b/src/components/Notifications/ToastNotification/ToastNotificationList.tsx index 5d544d873..7415db59c 100644 --- a/src/components/Notifications/ToastNotification/ToastNotificationList.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotificationList.tsx @@ -96,21 +96,23 @@ const ToastNotificationList: FC = ({ }); }; - const handleGroupedDismiss = () => { - const dismissableNotifications = notifications.filter( - (notification) => notification.dismissable !== false, - ); + const dismissableNotifications = notifications.filter( + (notification) => notification.dismissable !== false, + ); + // The notifications that would actually be cleared if the dismiss button + // were clicked right now, accounting for any active severity filters. + const dismissableNotificationsInView = hasFilters + ? dismissableNotifications.filter((notification) => + filters.has(notification.type), + ) + : dismissableNotifications; + + const handleGroupedDismiss = () => { + onDismiss(dismissableNotificationsInView); if (hasFilters) { - const notificationsToClear = dismissableNotifications.filter( - (notification) => filters.has(notification.type), - ); - onDismiss(notificationsToClear); setFilters(new Set()); - return; } - - onDismiss(dismissableNotifications); }; const getSeverityFilters = () => { @@ -153,18 +155,11 @@ const ToastNotificationList: FC = ({ const getDismissText = () => { if (hasFilters) { - const validFilters = Object.keys(groupedCount) as FilterTypes[]; - let totalCount = 0; - for (const filter of validFilters) { - if (filters.has(filter)) { - totalCount += groupedCount[filter] || 0; - } - } - - const dismissText = ( - Dismiss {totalCount} + return ( + + Dismiss {dismissableNotificationsInView.length} + ); - return dismissText; } return Dismiss all; @@ -264,14 +259,16 @@ const ToastNotificationList: FC = ({ {notificationEls}
  • {getSeverityFilters()} - + {dismissableNotificationsInView.length > 0 && ( + + )}
  • , From ca363d9e9b116c0b710570aed2c1623a9b578c80 Mon Sep 17 00:00:00 2001 From: Katarzyna Rumanowska Date: Fri, 23 Oct 2026 10:43:55 +0200 Subject: [PATCH 3/3] add additional suggestions --- .../ToastNotification/ToastNotification.test.tsx | 5 +---- .../ToastNotification/ToastNotificationList.tsx | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/Notifications/ToastNotification/ToastNotification.test.tsx b/src/components/Notifications/ToastNotification/ToastNotification.test.tsx index 8ea901b5a..759a9b2ed 100644 --- a/src/components/Notifications/ToastNotification/ToastNotification.test.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotification.test.tsx @@ -1,6 +1,6 @@ -import React from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import React from "react"; import ToastNotification from "./ToastNotification"; import ToastNotificationList from "./ToastNotificationList"; import { ToastNotificationType } from "./ToastNotificationProvider"; @@ -254,7 +254,6 @@ describe("ToastNotification", () => { ); await userEvent.click(screen.getByRole("button", { name: "Dismiss all" })); - expect(onDismiss).toHaveBeenCalledWith(notifications); expect(screen.getByText("Cannot be dismissed.")).toBeInTheDocument(); }); @@ -321,13 +320,11 @@ describe("ToastNotification", () => { await userEvent.click( screen.getByLabelText("Filter caution notifications"), ); - expect( screen.getByRole("button", { name: "Dismiss 1" }), ).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Dismiss 1" })); - expect(onDismiss).toHaveBeenCalledWith([mixedCautionNotifications[0]]); }); diff --git a/src/components/Notifications/ToastNotification/ToastNotificationList.tsx b/src/components/Notifications/ToastNotification/ToastNotificationList.tsx index 7415db59c..0f591d81b 100644 --- a/src/components/Notifications/ToastNotification/ToastNotificationList.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotificationList.tsx @@ -109,10 +109,12 @@ const ToastNotificationList: FC = ({ : dismissableNotifications; const handleGroupedDismiss = () => { - onDismiss(dismissableNotificationsInView); if (hasFilters) { + onDismiss(dismissableNotificationsInView); setFilters(new Set()); + return; } + onDismiss(dismissableNotifications); }; const getSeverityFilters = () => {