diff --git a/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx b/src/components/Notifications/ToastNotification/ToastNotification.stories.tsx index f6b37eea1..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 }, []); @@ -120,6 +127,19 @@ const PreloadedList = () => { + ); diff --git a/src/components/Notifications/ToastNotification/ToastNotification.test.tsx b/src/components/Notifications/ToastNotification/ToastNotification.test.tsx index 6c7d6408e..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 } from "@testing-library/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"; @@ -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,133 @@ 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); + 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 () => { 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..0f591d81b 100644 --- a/src/components/Notifications/ToastNotification/ToastNotificationList.tsx +++ b/src/components/Notifications/ToastNotification/ToastNotificationList.tsx @@ -96,17 +96,25 @@ const ToastNotificationList: FC = ({ }); }; + 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 = () => { if (hasFilters) { - const notificationsToClear = notifications.filter((notification) => - filters.has(notification.type), - ); - onDismiss(notificationsToClear); + onDismiss(dismissableNotificationsInView); setFilters(new Set()); return; } - - onDismiss(notifications); + onDismiss(dismissableNotifications); }; const getSeverityFilters = () => { @@ -149,18 +157,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; @@ -221,9 +222,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" @@ -256,14 +261,16 @@ const ToastNotificationList: FC = ({ {notificationEls}
  • {getSeverityFilters()} - + {dismissableNotificationsInView.length > 0 && ( + + )}
  • , 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,