diff --git a/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md b/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md
index 19f0cdb83b4..1fca05939dd 100644
--- a/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md
+++ b/frontend/packages/console-dynamic-plugin-sdk/CHANGELOG-core.md
@@ -13,6 +13,7 @@ table in [Console dynamic plugins README](./README.md).
## 4.23.0-prerelease.3 - TBD
- Added `@openshift/api-types` as a dependency and replaced `K8sResourceCommon` and depdendent types with imports from that package ([CONSOLE-5355], [#16585])
+- Add optional `drawerGroup`, `skipOverflow`, and `persistInDrawer` fields to `ToastOptions` ([CONSOLE-5361], [#16636])
## 4.23.0-prerelease.2 - 2026-05-27
@@ -231,6 +232,7 @@ table in [Console dynamic plugins README](./README.md).
[CONSOLE-5108]: https://issues.redhat.com/browse/CONSOLE-5108
[CONSOLE-5273]: https://issues.redhat.com/browse/CONSOLE-5273
[CONSOLE-5355]: https://issues.redhat.com/browse/CONSOLE-5355
+[CONSOLE-5361]: https://issues.redhat.com/browse/CONSOLE-5361
[OCPBUGS-19048]: https://issues.redhat.com/browse/OCPBUGS-19048
[OCPBUGS-30077]: https://issues.redhat.com/browse/OCPBUGS-30077
[OCPBUGS-31355]: https://issues.redhat.com/browse/OCPBUGS-31355
@@ -316,3 +318,4 @@ table in [Console dynamic plugins README](./README.md).
[#16400]: https://github.com/openshift/console/pull/16400
[#16491]: https://github.com/openshift/console/pull/16491
[#16585]: https://github.com/openshift/console/pull/16585
+[#16636]: https://github.com/openshift/console/pull/16636
diff --git a/frontend/packages/console-dynamic-plugin-sdk/docs/api.md b/frontend/packages/console-dynamic-plugin-sdk/docs/api.md
index dcc0f8a05cf..e9871fd2964 100644
--- a/frontend/packages/console-dynamic-plugin-sdk/docs/api.md
+++ b/frontend/packages/console-dynamic-plugin-sdk/docs/api.md
@@ -3183,7 +3183,8 @@ const Component: React.FC = (props) => {
addToast({
title: 'Success',
variant: 'success',
- content: 'Operation completed successfully.'
+ content: 'Operation completed successfully.',
+ drawerGroup: 'Operations',
});
};
return ;
diff --git a/frontend/packages/console-dynamic-plugin-sdk/release-notes/4.23.md b/frontend/packages/console-dynamic-plugin-sdk/release-notes/4.23.md
index cd24b63cb1a..1c73bf6ad7e 100644
--- a/frontend/packages/console-dynamic-plugin-sdk/release-notes/4.23.md
+++ b/frontend/packages/console-dynamic-plugin-sdk/release-notes/4.23.md
@@ -55,6 +55,23 @@ The UI displays tabs in priority order from highest to lowest. Default built-in
## Changes to shared modules and API
-This release introduces no changes to shared modules.
+- Console caps visible toast alerts (default 3) with an overflow link to the notification drawer. Toast history is persisted in the drawer with read/unread state and management actions. ([CONSOLE-5361])
+- `ToastOptions` accepts optional `drawerGroup` string to group toast notifications into named sections in the notification drawer. When omitted, toasts appear in the built-in default group, displayed as **Other Alerts** (translated by Console). Custom `drawerGroup` values are shown as-is; plugins are responsible for translating their own group names if needed. ([CONSOLE-5361])
+- `ToastOptions` accepts optional `skipOverflow` (default `false`) to exclude a toast from the visible toast cap and overflow link. ([CONSOLE-5361])
+- `ToastOptions` accepts optional `persistInDrawer` (default `true`) to show a toast on screen only without persisting it in the notification drawer when set to `false`. Setting `persistInDrawer` to `false` implies `skipOverflow: true`. ([CONSOLE-5361])
+
+### Example: grouping toasts in the notification drawer
+
+```tsx
+const { addToast } = useToast();
+
+addToast({
+ title: 'Upload complete',
+ variant: 'success',
+ content: 'disk.img uploaded successfully.',
+ drawerGroup: 'Uploads',
+});
+```
[CONSOLE-4946]: https://issues.redhat.com/browse/CONSOLE-4946
+[CONSOLE-5361]: https://issues.redhat.com/browse/CONSOLE-5361
diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/api/core-api.ts b/frontend/packages/console-dynamic-plugin-sdk/src/api/core-api.ts
index a22178030d4..b9c9a2b25a8 100644
--- a/frontend/packages/console-dynamic-plugin-sdk/src/api/core-api.ts
+++ b/frontend/packages/console-dynamic-plugin-sdk/src/api/core-api.ts
@@ -994,7 +994,8 @@ export const useActivePerspective: UseActivePerspective = require('@console/dyna
* addToast({
* title: 'Success',
* variant: 'success',
- * content: 'Operation completed successfully.'
+ * content: 'Operation completed successfully.',
+ * drawerGroup: 'Operations',
* });
* };
* return ;
diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/console-types.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/console-types.ts
index ded237ee619..b54769a05fb 100644
--- a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/console-types.ts
+++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/console-types.ts
@@ -652,6 +652,21 @@ export type ToastOptions = {
onRemove?: (id: string) => void;
/** Callback to run when toast is dismissed with close button */
onClose?: () => void;
+ /** Optional group name for the notification drawer section. Omit to use the built-in default group (displayed as "Other Alerts"). Custom values are shown as-is and are not translated by Console. */
+ drawerGroup?: string;
+ /**
+ * When `true`, the toast is excluded from the visible toast cap and overflow link.
+ * Defaults to `false`.
+ * When `persistInDrawer` is `false`, this is always treated as `true`.
+ */
+ skipOverflow?: boolean;
+ /**
+ * When `false`, the toast is shown on screen only and is not persisted in the notification drawer.
+ * Implies `skipOverflow: true` — ephemeral toasts must remain visible because they are not
+ * recoverable from the drawer when hidden by overflow.
+ * Defaults to `true`.
+ */
+ persistInDrawer?: boolean;
};
export type ToastContextValues = {
diff --git a/frontend/packages/console-shared/src/components/toast/NotificationHistoryContext.tsx b/frontend/packages/console-shared/src/components/toast/NotificationHistoryContext.tsx
new file mode 100644
index 00000000000..7524b9b08ff
--- /dev/null
+++ b/frontend/packages/console-shared/src/components/toast/NotificationHistoryContext.tsx
@@ -0,0 +1,6 @@
+import { createContext } from 'react';
+import type { NotificationHistoryContextValues } from './types';
+
+export const NotificationHistoryContext = createContext(
+ {} as NotificationHistoryContextValues,
+);
diff --git a/frontend/packages/console-shared/src/components/toast/ToastProvider.tsx b/frontend/packages/console-shared/src/components/toast/ToastProvider.tsx
index b8516701e74..b2ccb9c290f 100644
--- a/frontend/packages/console-shared/src/components/toast/ToastProvider.tsx
+++ b/frontend/packages/console-shared/src/components/toast/ToastProvider.tsx
@@ -1,25 +1,58 @@
import type { FC, ReactNode } from 'react';
-import { useState, useCallback, useMemo } from 'react';
-import { Alert, AlertGroup, AlertActionCloseButton, AlertActionLink } from '@patternfly/react-core';
+import { useState, useCallback, useMemo, useRef } from 'react';
+import {
+ Alert,
+ AlertGroup,
+ AlertActionCloseButton,
+ AlertActionLink,
+ AlertVariant,
+} from '@patternfly/react-core';
+import { useTranslation } from 'react-i18next';
import type {
ToastOptions,
ToastContextValues,
} from '@console/dynamic-plugin-sdk/src/extensions/console-types';
+import { NotificationHistoryContext } from './NotificationHistoryContext';
import { ToastContext } from './ToastContext';
+import { getOverflowCount, getVisibleToasts, normalizeToastOptions } from './toastDisplayUtils';
+import type { ToastNotification } from './types';
+import {
+ DEFAULT_MAX_DISPLAYED_TOASTS,
+ DEFAULT_MAX_NOTIFICATION_HISTORY,
+ DEFAULT_TOAST_DRAWER_GROUP,
+} from './types';
interface ToastProviderProps {
children?: ReactNode;
+ isNotificationDrawerExpanded?: boolean;
+ maxDisplayed?: number;
+ onNotificationDrawerOpen?: () => void;
}
/** Stable reference to append toast alerts to the document body */
const appendTo = () => document.body;
-export const ToastProvider: FC = ({ children }) => {
- const [toasts, setToasts] = useState([]);
+const toToastNotification = (toast: ToastOptions & { id: string }): ToastNotification => ({
+ ...toast,
+ timestamp: Date.now(),
+ isRead: false,
+ drawerGroup: toast.drawerGroup || DEFAULT_TOAST_DRAWER_GROUP,
+});
+
+export const ToastProvider: FC = ({
+ children,
+ isNotificationDrawerExpanded = false,
+ maxDisplayed = DEFAULT_MAX_DISPLAYED_TOASTS,
+ onNotificationDrawerOpen,
+}) => {
+ const { t } = useTranslation();
+ const [toasts, setToasts] = useState<(ToastOptions & { id: string })[]>([]);
+ const [notifications, setNotifications] = useState([]);
+ const toastIdCounterRef = useRef(0);
const removeToast = useCallback((id: string) => {
setToasts((state) => {
- const index = state.findIndex((t) => t.id === id);
+ const index = state.findIndex((toast) => toast.id === id);
if (index !== -1) {
const toast = state[index];
if (toast.onRemove) {
@@ -29,27 +62,111 @@ export const ToastProvider: FC = ({ children }) => {
}
return state;
});
+ setNotifications((state) => state.filter((notification) => notification.id !== id));
+ }, []);
+
+ const clearVisibleToasts = useCallback(() => {
+ setToasts([]);
}, []);
- const addToast = useMemo(() => {
- let counter = 0;
- return (toast: ToastOptions) => {
- const clone: ToastOptions = {
- id: `toast-${++counter}`,
+ const addToast = useCallback(
+ (toast: ToastOptions) => {
+ const clone: ToastOptions & { id: string } = normalizeToastOptions({
+ id: toast.id || `toast-${++toastIdCounterRef.current}`,
...toast,
- };
- setToasts((state) => {
- const index = state.findIndex((t) => t.id === clone.id);
- if (index !== -1) {
- return [...state.slice(0, index), clone, ...state.slice(index + 1, state.length)];
+ });
+
+ setNotifications((state) => {
+ if (clone.persistInDrawer === false) {
+ return state;
}
- return [...state, clone];
+ const notification = toToastNotification(clone);
+ const next = [notification, ...state.filter((item) => item.id !== notification.id)];
+ return next.slice(0, DEFAULT_MAX_NOTIFICATION_HISTORY);
});
+
+ if (!isNotificationDrawerExpanded) {
+ setToasts((state) => {
+ const index = state.findIndex((item) => item.id === clone.id);
+ if (index !== -1) {
+ return [clone, ...state.slice(0, index), ...state.slice(index + 1, state.length)];
+ }
+ return [clone, ...state];
+ });
+ }
+
return clone.id;
- };
+ },
+ [isNotificationDrawerExpanded],
+ );
+
+ const markNotificationRead = useCallback((id: string) => {
+ setNotifications((state) =>
+ state.map((notification) =>
+ notification.id === id ? { ...notification, isRead: true } : notification,
+ ),
+ );
+ }, []);
+
+ const markNotificationUnread = useCallback((id: string) => {
+ setNotifications((state) =>
+ state.map((notification) =>
+ notification.id === id ? { ...notification, isRead: false } : notification,
+ ),
+ );
+ }, []);
+
+ const clearNotification = useCallback(
+ (id: string) => {
+ setNotifications((state) => {
+ const notification = state.find((item) => item.id === id);
+ notification?.onClose?.();
+ return state.filter((item) => item.id !== id);
+ });
+ removeToast(id);
+ },
+ [removeToast],
+ );
+
+ const clearAllNotifications = useCallback(() => {
+ setNotifications((state) => {
+ state.forEach((notification) => notification.onClose?.());
+ return [];
+ });
+ clearVisibleToasts();
+ }, [clearVisibleToasts]);
+
+ const markAllNotificationsRead = useCallback(() => {
+ setNotifications((state) => state.map((notification) => ({ ...notification, isRead: true })));
}, []);
- const controller = useMemo(
+ const unreadCount = useMemo(
+ () => notifications.filter((notification) => !notification.isRead).length,
+ [notifications],
+ );
+
+ const hasUnreadDangerNotifications = useMemo(
+ () =>
+ notifications.some(
+ (notification) => !notification.isRead && notification.variant === AlertVariant.danger,
+ ),
+ [notifications],
+ );
+
+ const overflowMessage = useMemo(() => {
+ const overflow = getOverflowCount(toasts, maxDisplayed);
+ if (overflow > 0) {
+ return t('View {{count}} more notification(s)', { count: overflow });
+ }
+ return '';
+ }, [maxDisplayed, t, toasts]);
+
+ const onOverflowClick = useCallback(() => {
+ clearVisibleToasts();
+ onNotificationDrawerOpen?.();
+ }, [clearVisibleToasts, onNotificationDrawerOpen]);
+
+ const toastController = useMemo(
() => ({
addToast,
removeToast,
@@ -57,57 +174,92 @@ export const ToastProvider: FC = ({ children }) => {
[addToast, removeToast],
);
+ const notificationHistoryController = useMemo(
+ () => ({
+ notifications,
+ unreadCount,
+ hasUnreadDangerNotifications,
+ markNotificationRead,
+ markNotificationUnread,
+ clearNotification,
+ clearAllNotifications,
+ markAllNotificationsRead,
+ }),
+ [
+ clearAllNotifications,
+ clearNotification,
+ hasUnreadDangerNotifications,
+ markAllNotificationsRead,
+ markNotificationRead,
+ markNotificationUnread,
+ notifications,
+ unreadCount,
+ ],
+ );
+
+ const visibleToasts = useMemo(() => getVisibleToasts(toasts, maxDisplayed), [
+ maxDisplayed,
+ toasts,
+ ]);
+
return (
-
- {children}
- {toasts.length ? (
-
- {toasts.map((toast) => (
- removeToast(toast.id)}
- data-test={toast.dataTest || `${toast.title} alert`}
- actionClose={
- toast.dismissible ? (
- {
- toast.onClose && toast.onClose();
- removeToast(toast.id);
- }}
- />
- ) : undefined
- }
- actionLinks={
- toast.actions?.length > 0 ? (
- <>
- {toast.actions.map((action) => (
- {
- if (action.dismiss) {
- removeToast(toast.id);
- }
- action.callback();
- }}
- component={action.component}
- data-test={action.dataTest || 'toast-action'}
- >
- {action.label}
-
- ))}
- >
- ) : undefined
- }
- >
- {toast.content}
-
- ))}
-
- ) : null}
+
+
+ {children}
+ {!isNotificationDrawerExpanded && toasts.length ? (
+
+ {visibleToasts.map((toast) => (
+ removeToast(toast.id)}
+ data-test={toast.dataTest || `${toast.title} alert`}
+ actionClose={
+ toast.dismissible ? (
+ {
+ toast.onClose && toast.onClose();
+ removeToast(toast.id);
+ }}
+ />
+ ) : undefined
+ }
+ actionLinks={
+ toast.actions?.length > 0 ? (
+ <>
+ {toast.actions.map((action) => (
+ {
+ if (action.dismiss) {
+ removeToast(toast.id);
+ }
+ action.callback();
+ }}
+ component={action.component}
+ data-test={action.dataTest || 'toast-action'}
+ >
+ {action.label}
+
+ ))}
+ >
+ ) : undefined
+ }
+ >
+ {toast.content}
+
+ ))}
+
+ ) : null}
+
);
};
diff --git a/frontend/packages/console-shared/src/components/toast/__tests__/ToastProvider.spec.tsx b/frontend/packages/console-shared/src/components/toast/__tests__/ToastProvider.spec.tsx
index e561f4751f2..8536ff05a91 100644
--- a/frontend/packages/console-shared/src/components/toast/__tests__/ToastProvider.spec.tsx
+++ b/frontend/packages/console-shared/src/components/toast/__tests__/ToastProvider.spec.tsx
@@ -4,14 +4,18 @@ import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ToastContextValues } from '@console/dynamic-plugin-sdk/src/extensions/console-types';
import { renderWithProviders } from '../../../test-utils/unit-test-utils';
+import { NotificationHistoryContext } from '../NotificationHistoryContext';
import { ToastContext } from '../ToastContext';
import { ToastProvider } from '../ToastProvider';
+import type { NotificationHistoryContextValues } from '../types';
describe('ToastProvider', () => {
let toastContext: ToastContextValues;
+ let notificationHistoryContext: NotificationHistoryContextValues;
const TestComponent = () => {
toastContext = useContext(ToastContext);
+ notificationHistoryContext = useContext(NotificationHistoryContext);
return null;
};
@@ -24,6 +28,7 @@ describe('ToastProvider', () => {
expect(typeof toastContext.addToast).toBe('function');
expect(typeof toastContext.removeToast).toBe('function');
+ expect(typeof notificationHistoryContext.clearNotification).toBe('function');
});
it('should add and remove alerts', async () => {
@@ -59,6 +64,7 @@ describe('ToastProvider', () => {
expect(screen.getByText('description 1')).toBeVisible();
expect(screen.getByText('description 2')).toBeVisible();
+ expect(notificationHistoryContext.notifications).toHaveLength(2);
act(() => {
toastContext.removeToast(id1);
@@ -69,6 +75,377 @@ describe('ToastProvider', () => {
expect(screen.queryByText('test success')).not.toBeInTheDocument();
expect(screen.queryByText('test danger')).not.toBeInTheDocument();
});
+ expect(notificationHistoryContext.notifications).toHaveLength(0);
+ });
+
+ it('should cap visible toasts and show overflow message', async () => {
+ const onNotificationDrawerOpen = jest.fn();
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ title: 'toast 1',
+ variant: AlertVariant.info,
+ content: 'description 1',
+ });
+ toastContext.addToast({
+ title: 'toast 2',
+ variant: AlertVariant.info,
+ content: 'description 2',
+ });
+ toastContext.addToast({
+ title: 'toast 3',
+ variant: AlertVariant.info,
+ content: 'description 3',
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('toast 3')).toBeVisible();
+ expect(screen.getByText('toast 2')).toBeVisible();
+ expect(screen.queryByText('toast 1')).not.toBeInTheDocument();
+ expect(screen.getByText('View 1 more notification(s)')).toBeVisible();
+ });
+
+ const user = userEvent.setup();
+ await user.click(screen.getByText('View 1 more notification(s)'));
+ expect(onNotificationDrawerOpen).toHaveBeenCalledTimes(1);
+ });
+
+ it('should keep incrementing generated toast ids when notification drawer expansion changes', async () => {
+ const { rerender } = renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ title: 'toast 1',
+ variant: AlertVariant.info,
+ content: 'description 1',
+ });
+ toastContext.addToast({
+ title: 'toast 2',
+ variant: AlertVariant.info,
+ content: 'description 2',
+ });
+ });
+
+ await waitFor(() => {
+ expect(notificationHistoryContext.notifications).toHaveLength(2);
+ });
+
+ rerender(
+
+
+ ,
+ );
+
+ let thirdToastId: string;
+ act(() => {
+ thirdToastId = toastContext.addToast({
+ title: 'toast 3',
+ variant: AlertVariant.warning,
+ content: 'description 3',
+ });
+ });
+
+ await waitFor(() => {
+ expect(thirdToastId).toBe('toast-3');
+ expect(notificationHistoryContext.notifications).toHaveLength(3);
+ expect(notificationHistoryContext.notifications.map(({ id }) => id)).toEqual([
+ 'toast-3',
+ 'toast-2',
+ 'toast-1',
+ ]);
+ expect(notificationHistoryContext.notifications.map(({ title }) => title)).toEqual([
+ 'toast 3',
+ 'toast 2',
+ 'toast 1',
+ ]);
+ });
+ });
+
+ it('should clear notification history and invoke onClose when cleared from the drawer', async () => {
+ const onClose = jest.fn();
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ id: 'toast-history',
+ title: 'history toast',
+ variant: AlertVariant.success,
+ content: 'history description',
+ onClose,
+ });
+ });
+
+ await waitFor(() => {
+ expect(notificationHistoryContext.notifications).toHaveLength(1);
+ });
+
+ act(() => {
+ notificationHistoryContext.clearNotification('toast-history');
+ });
+
+ await waitFor(() => {
+ expect(notificationHistoryContext.notifications).toHaveLength(0);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('should invoke onClose for all notifications when clearing the drawer', async () => {
+ const onCloseOne = jest.fn();
+ const onCloseTwo = jest.fn();
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ id: 'toast-one',
+ onClose: onCloseOne,
+ title: 'toast one',
+ variant: AlertVariant.info,
+ content: 'description 1',
+ });
+ toastContext.addToast({
+ id: 'toast-two',
+ onClose: onCloseTwo,
+ title: 'toast two',
+ variant: AlertVariant.info,
+ content: 'description 2',
+ });
+ });
+
+ await waitFor(() => {
+ expect(notificationHistoryContext.notifications).toHaveLength(2);
+ });
+
+ act(() => {
+ notificationHistoryContext.clearAllNotifications();
+ });
+
+ await waitFor(() => {
+ expect(notificationHistoryContext.notifications).toHaveLength(0);
+ expect(onCloseOne).toHaveBeenCalledTimes(1);
+ expect(onCloseTwo).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('should replace notification history when a toast is removed and replaced', async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ id: 'upload-progress',
+ title: 'Uploading',
+ variant: AlertVariant.info,
+ content: 'in progress',
+ });
+ });
+
+ await waitFor(() => {
+ expect(notificationHistoryContext.notifications).toHaveLength(1);
+ });
+
+ act(() => {
+ toastContext.removeToast('upload-progress');
+ toastContext.addToast({
+ id: 'upload-complete',
+ title: 'Upload complete',
+ variant: AlertVariant.success,
+ content: 'done',
+ });
+ });
+
+ await waitFor(() => {
+ expect(notificationHistoryContext.notifications).toHaveLength(1);
+ expect(notificationHistoryContext.notifications[0].id).toBe('upload-complete');
+ });
+ });
+
+ it('should not show toast when notification drawer is expanded', async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ title: 'drawer toast',
+ variant: AlertVariant.info,
+ content: 'drawer description',
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('drawer toast')).not.toBeInTheDocument();
+ expect(notificationHistoryContext.notifications).toHaveLength(1);
+ });
+ });
+
+ it('should hide visible toasts when notification drawer is opened', async () => {
+ const { rerender } = renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ title: 'visible toast',
+ variant: AlertVariant.info,
+ content: 'visible description',
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('visible toast')).toBeInTheDocument();
+ });
+
+ rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText('visible toast')).not.toBeInTheDocument();
+ expect(notificationHistoryContext.notifications).toHaveLength(1);
+ });
+ });
+
+ it('should not persist toast in drawer when persistInDrawer is false', async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ title: 'ephemeral toast',
+ variant: AlertVariant.success,
+ content: 'toast only',
+ persistInDrawer: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('ephemeral toast')).toBeVisible();
+ expect(notificationHistoryContext.notifications).toHaveLength(0);
+ });
+ });
+
+ it('should force skipOverflow when persistInDrawer is false', async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ for (let index = 1; index <= 4; index += 1) {
+ toastContext.addToast({
+ id: `ephemeral-${index}`,
+ title: `ephemeral toast ${index}`,
+ variant: AlertVariant.info,
+ content: `description ${index}`,
+ persistInDrawer: false,
+ skipOverflow: false,
+ });
+ }
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('ephemeral toast 1')).toBeVisible();
+ expect(screen.getByText('ephemeral toast 2')).toBeVisible();
+ expect(screen.getByText('ephemeral toast 3')).toBeVisible();
+ expect(screen.getByText('ephemeral toast 4')).toBeVisible();
+ expect(screen.queryByText(/View .* more notification/)).not.toBeInTheDocument();
+ expect(notificationHistoryContext.notifications).toHaveLength(0);
+ });
+ });
+
+ it('should show all ephemeral toasts without overflow link', async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ for (let index = 1; index <= 4; index += 1) {
+ toastContext.addToast({
+ id: `ephemeral-${index}`,
+ title: `ephemeral toast ${index}`,
+ variant: AlertVariant.info,
+ content: `description ${index}`,
+ persistInDrawer: false,
+ skipOverflow: true,
+ });
+ }
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('ephemeral toast 1')).toBeVisible();
+ expect(screen.getByText('ephemeral toast 2')).toBeVisible();
+ expect(screen.getByText('ephemeral toast 3')).toBeVisible();
+ expect(screen.getByText('ephemeral toast 4')).toBeVisible();
+ expect(screen.queryByText(/View .* more notification/)).not.toBeInTheDocument();
+ });
+ });
+
+ it('should always show skipOverflow toasts without triggering overflow', async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ act(() => {
+ toastContext.addToast({
+ id: 'always-visible',
+ title: 'always visible toast',
+ variant: AlertVariant.info,
+ content: 'always visible',
+ skipOverflow: true,
+ });
+ toastContext.addToast({
+ title: 'capped toast 1',
+ variant: AlertVariant.info,
+ content: 'capped 1',
+ });
+ toastContext.addToast({
+ title: 'capped toast 2',
+ variant: AlertVariant.info,
+ content: 'capped 2',
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('always visible toast')).toBeVisible();
+ expect(screen.getByText('capped toast 2')).toBeVisible();
+ expect(screen.queryByText('capped toast 1')).not.toBeInTheDocument();
+ expect(screen.getByText('View 1 more notification(s)')).toBeVisible();
+ });
});
it('should dismiss toast on action', async () => {
@@ -207,6 +584,8 @@ describe('ToastProvider', () => {
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
- expect(toastClose).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(toastClose).toHaveBeenCalled();
+ });
});
});
diff --git a/frontend/packages/console-shared/src/components/toast/__tests__/toastDisplayUtils.spec.ts b/frontend/packages/console-shared/src/components/toast/__tests__/toastDisplayUtils.spec.ts
new file mode 100644
index 00000000000..5d741e61257
--- /dev/null
+++ b/frontend/packages/console-shared/src/components/toast/__tests__/toastDisplayUtils.spec.ts
@@ -0,0 +1,71 @@
+import { AlertVariant } from '@patternfly/react-core';
+import type { ToastOptions } from '@console/dynamic-plugin-sdk/src/extensions/console-types';
+import { getOverflowCount, getVisibleToasts, normalizeToastOptions } from '../toastDisplayUtils';
+
+const toast = (id: string, options: Partial = {}) =>
+ ({
+ id,
+ title: id,
+ variant: AlertVariant.info,
+ content: id,
+ ...options,
+ } as ToastOptions & { id: string });
+
+describe('toastDisplayUtils', () => {
+ it('should cap only toasts that participate in overflow', () => {
+ const toasts = [
+ toast('skip-1', { skipOverflow: true }),
+ toast('capped-1'),
+ toast('capped-2'),
+ toast('capped-3'),
+ ];
+
+ expect(getVisibleToasts(toasts, 2).map(({ id }) => id)).toEqual([
+ 'skip-1',
+ 'capped-1',
+ 'capped-2',
+ ]);
+ expect(getOverflowCount(toasts, 2)).toBe(1);
+ });
+
+ it('should not show overflow when only skipOverflow toasts exceed the cap', () => {
+ const toasts = [
+ toast('skip-1', { skipOverflow: true }),
+ toast('skip-2', { skipOverflow: true }),
+ ];
+
+ expect(getVisibleToasts(toasts, 1)).toHaveLength(2);
+ expect(getOverflowCount(toasts, 1)).toBe(0);
+ });
+
+ it('should force skipOverflow when persistInDrawer is false', () => {
+ const normalized = normalizeToastOptions({
+ title: 'ephemeral',
+ variant: AlertVariant.info,
+ content: 'toast only',
+ persistInDrawer: false,
+ skipOverflow: false,
+ });
+
+ expect(normalized.skipOverflow).toBe(true);
+ });
+
+ it('should treat negative maxDisplayed as zero for overflow calculations', () => {
+ const toasts = [toast('capped-1'), toast('capped-2'), toast('capped-3')];
+
+ expect(getVisibleToasts(toasts, -1)).toHaveLength(0);
+ expect(getOverflowCount(toasts, -1)).toBe(3);
+ });
+
+ it('should show all normalized ephemeral toasts without overflow', () => {
+ const toasts = [
+ normalizeToastOptions(toast('ephemeral-1', { persistInDrawer: false, skipOverflow: false })),
+ normalizeToastOptions(toast('ephemeral-2', { persistInDrawer: false, skipOverflow: false })),
+ normalizeToastOptions(toast('ephemeral-3', { persistInDrawer: false, skipOverflow: false })),
+ normalizeToastOptions(toast('ephemeral-4', { persistInDrawer: false, skipOverflow: false })),
+ ];
+
+ expect(getVisibleToasts(toasts, 3)).toHaveLength(4);
+ expect(getOverflowCount(toasts, 3)).toBe(0);
+ });
+});
diff --git a/frontend/packages/console-shared/src/components/toast/__tests__/toastNotificationUtils.spec.ts b/frontend/packages/console-shared/src/components/toast/__tests__/toastNotificationUtils.spec.ts
new file mode 100644
index 00000000000..ffebf4cb199
--- /dev/null
+++ b/frontend/packages/console-shared/src/components/toast/__tests__/toastNotificationUtils.spec.ts
@@ -0,0 +1,65 @@
+import { NotificationBadgeVariant } from '@patternfly/react-core';
+import type { TFunction } from 'i18next';
+import {
+ getCustomToastDrawerGroups,
+ getNotificationsVariant,
+ getToastDrawerGroupTitle,
+ getToastNotificationsForGroup,
+ groupToastNotifications,
+} from '../toastNotificationUtils';
+import type { ToastNotification } from '../types';
+import { DEFAULT_TOAST_DRAWER_GROUP } from '../types';
+
+const t = ((key: string) => key) as TFunction;
+
+const notification = (id: string, drawerGroup?: string): ToastNotification =>
+ ({
+ id,
+ title: id,
+ variant: 'info',
+ content: id,
+ timestamp: 0,
+ isRead: false,
+ drawerGroup: drawerGroup || DEFAULT_TOAST_DRAWER_GROUP,
+ } as ToastNotification);
+
+describe('toastNotificationUtils', () => {
+ it('should group notifications by drawer group', () => {
+ const grouped = groupToastNotifications([
+ notification('toast-1'),
+ notification('toast-2', 'Uploads'),
+ notification('toast-3', 'Uploads'),
+ ]);
+
+ expect(grouped[DEFAULT_TOAST_DRAWER_GROUP]).toHaveLength(1);
+ expect(grouped.Uploads).toHaveLength(2);
+ });
+
+ it('should return notifications for a group', () => {
+ const grouped = groupToastNotifications([notification('toast-1', 'Uploads')]);
+
+ expect(getToastNotificationsForGroup(grouped, 'Uploads')).toHaveLength(1);
+ expect(getToastNotificationsForGroup(grouped, 'Missing')).toEqual([]);
+ });
+
+ it('should return custom drawer groups excluding the default group', () => {
+ const grouped = groupToastNotifications([
+ notification('toast-1'),
+ notification('toast-2', 'Uploads'),
+ notification('toast-3', 'Operations'),
+ ]);
+
+ expect(getCustomToastDrawerGroups(grouped)).toEqual(['Uploads', 'Operations']);
+ });
+
+ it('should translate the default drawer group title', () => {
+ expect(getToastDrawerGroupTitle(DEFAULT_TOAST_DRAWER_GROUP, t)).toBe('Other Alerts');
+ expect(getToastDrawerGroupTitle('Uploads', t)).toBe('Uploads');
+ });
+
+ it('should resolve notification badge variant from toast unread state', () => {
+ expect(getNotificationsVariant(0, true)).toBe(NotificationBadgeVariant.attention);
+ expect(getNotificationsVariant(2, false)).toBe(NotificationBadgeVariant.unread);
+ expect(getNotificationsVariant(0, false)).toBe(NotificationBadgeVariant.plain);
+ });
+});
diff --git a/frontend/packages/console-shared/src/components/toast/toastDisplayUtils.ts b/frontend/packages/console-shared/src/components/toast/toastDisplayUtils.ts
new file mode 100644
index 00000000000..a3fe7da3356
--- /dev/null
+++ b/frontend/packages/console-shared/src/components/toast/toastDisplayUtils.ts
@@ -0,0 +1,42 @@
+import type { ToastOptions } from '@console/dynamic-plugin-sdk/src/extensions/console-types';
+
+/**
+ * Enforces toast option constraints. Ephemeral toasts (`persistInDrawer: false`) must
+ * bypass the overflow cap; otherwise they could be hidden with no drawer entry.
+ */
+export const normalizeToastOptions = (toast: T): T => {
+ if (toast.persistInDrawer === false) {
+ return { ...toast, skipOverflow: true };
+ }
+ return toast;
+};
+
+const participatesInOverflowCap = (toast: ToastOptions): boolean => toast.skipOverflow !== true;
+
+const getCappedToasts = (toasts: (ToastOptions & { id: string })[]) =>
+ toasts.filter((toast) => participatesInOverflowCap(toast));
+
+export const getVisibleToasts = (
+ toasts: (ToastOptions & { id: string })[],
+ maxDisplayed: number,
+): (ToastOptions & { id: string })[] => {
+ const limit = Math.max(0, maxDisplayed);
+ const visibleCappedIds = new Set(
+ getCappedToasts(toasts)
+ .slice(0, limit)
+ .map((toast) => toast.id),
+ );
+
+ return toasts.filter(
+ (toast) => !participatesInOverflowCap(toast) || visibleCappedIds.has(toast.id),
+ );
+};
+
+export const getOverflowCount = (
+ toasts: (ToastOptions & { id: string })[],
+ maxDisplayed: number,
+): number => {
+ const limit = Math.max(0, maxDisplayed);
+ const cappedLength = getCappedToasts(toasts).length;
+ return Math.max(0, cappedLength - limit);
+};
diff --git a/frontend/packages/console-shared/src/components/toast/toastNotificationUtils.ts b/frontend/packages/console-shared/src/components/toast/toastNotificationUtils.ts
new file mode 100644
index 00000000000..f8253b796da
--- /dev/null
+++ b/frontend/packages/console-shared/src/components/toast/toastNotificationUtils.ts
@@ -0,0 +1,36 @@
+import { NotificationBadgeVariant } from '@patternfly/react-core';
+import type { TFunction } from 'i18next';
+import type { ToastNotification } from './types';
+import { DEFAULT_TOAST_DRAWER_GROUP } from './types';
+
+export const getNotificationsVariant = (
+ toastUnreadCount: number,
+ hasUnreadDangerNotifications: boolean,
+): NotificationBadgeVariant =>
+ hasUnreadDangerNotifications
+ ? NotificationBadgeVariant.attention
+ : toastUnreadCount > 0
+ ? NotificationBadgeVariant.unread
+ : NotificationBadgeVariant.plain;
+
+export const getToastDrawerGroupTitle = (groupName: string, t: TFunction): string =>
+ groupName === DEFAULT_TOAST_DRAWER_GROUP ? t('Other Alerts') : groupName;
+
+export const groupToastNotifications = (
+ notifications: ToastNotification[],
+): Record =>
+ notifications.reduce>((groups, notification) => {
+ const groupName = notification.drawerGroup || DEFAULT_TOAST_DRAWER_GROUP;
+ groups[groupName] = groups[groupName] ? [...groups[groupName], notification] : [notification];
+ return groups;
+ }, {});
+
+export const getToastNotificationsForGroup = (
+ groupedNotifications: Record,
+ groupName: string,
+): ToastNotification[] => groupedNotifications[groupName] || [];
+
+export const getCustomToastDrawerGroups = (
+ groupedNotifications: Record,
+): string[] =>
+ Object.keys(groupedNotifications).filter((groupName) => groupName !== DEFAULT_TOAST_DRAWER_GROUP);
diff --git a/frontend/packages/console-shared/src/components/toast/types.ts b/frontend/packages/console-shared/src/components/toast/types.ts
new file mode 100644
index 00000000000..cdf8d895d25
--- /dev/null
+++ b/frontend/packages/console-shared/src/components/toast/types.ts
@@ -0,0 +1,24 @@
+import type { ToastOptions } from '@console/dynamic-plugin-sdk/src/extensions/console-types';
+
+/** Internal grouping key for the default notification drawer section. Use `getToastDrawerGroupTitle` for display. */
+export const DEFAULT_TOAST_DRAWER_GROUP = 'default';
+export const DEFAULT_MAX_DISPLAYED_TOASTS = 3;
+export const DEFAULT_MAX_NOTIFICATION_HISTORY = 100;
+
+export type ToastNotification = ToastOptions & {
+ id: string;
+ timestamp: number;
+ isRead: boolean;
+ drawerGroup: string;
+};
+
+export type NotificationHistoryContextValues = {
+ notifications: ToastNotification[];
+ unreadCount: number;
+ hasUnreadDangerNotifications: boolean;
+ markNotificationRead: (id: string) => void;
+ markNotificationUnread: (id: string) => void;
+ clearNotification: (id: string) => void;
+ clearAllNotifications: () => void;
+ markAllNotificationsRead: () => void;
+};
diff --git a/frontend/packages/console-shared/src/components/toast/useNotificationHistory.ts b/frontend/packages/console-shared/src/components/toast/useNotificationHistory.ts
new file mode 100644
index 00000000000..7182b795d65
--- /dev/null
+++ b/frontend/packages/console-shared/src/components/toast/useNotificationHistory.ts
@@ -0,0 +1,4 @@
+import { useContext } from 'react';
+import { NotificationHistoryContext } from './NotificationHistoryContext';
+
+export const useNotificationHistory = () => useContext(NotificationHistoryContext);
diff --git a/frontend/public/components/ToastNotificationDrawerItems.tsx b/frontend/public/components/ToastNotificationDrawerItems.tsx
new file mode 100644
index 00000000000..d2c33e38f8b
--- /dev/null
+++ b/frontend/public/components/ToastNotificationDrawerItems.tsx
@@ -0,0 +1,136 @@
+import type { FC, Ref } from 'react';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ AlertVariant,
+ Dropdown,
+ DropdownItem,
+ DropdownList,
+ MenuToggle,
+ MenuToggleElement,
+ NotificationDrawerListItem,
+ NotificationDrawerListItemBody,
+ NotificationDrawerListItemHeader,
+} from '@patternfly/react-core';
+import { RhUiEllipsisVerticalIcon } from '@patternfly/react-icons';
+import { Timestamp } from '@console/shared/src/components/datetime/Timestamp';
+import { ToastNotification } from '@console/shared/src/components/toast/types';
+
+type ToastNotificationDrawerItemProps = {
+ notification: ToastNotification;
+ onClear: (id: string) => void;
+ onMarkRead: (id: string) => void;
+ onMarkUnread: (id: string) => void;
+};
+
+const mapToastVariant = (
+ variant: ToastNotification['variant'],
+): 'custom' | 'success' | 'danger' | 'warning' | 'info' => {
+ switch (variant) {
+ case AlertVariant.success:
+ return 'success';
+ case AlertVariant.danger:
+ return 'danger';
+ case AlertVariant.warning:
+ return 'warning';
+ case AlertVariant.info:
+ return 'info';
+ default:
+ return 'custom';
+ }
+};
+
+const ToastNotificationDrawerItem: FC = ({
+ notification,
+ onClear,
+ onMarkRead,
+ onMarkUnread,
+}) => {
+ const { t } = useTranslation('public');
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const drawerVariant = mapToastVariant(notification.variant);
+
+ return (
+ onMarkRead(notification.id)}
+ >
+
+ setIsDropdownOpen(open)}
+ onSelect={() => setIsDropdownOpen(false)}
+ popperProps={{ position: 'right' }}
+ toggle={(toggleRef: Ref) => (
+ {
+ event.stopPropagation();
+ setIsDropdownOpen(!isDropdownOpen);
+ }}
+ aria-label={t('Notification actions')}
+ icon={}
+ />
+ )}
+ >
+
+ {
+ event.stopPropagation();
+ if (notification.isRead) {
+ onMarkUnread(notification.id);
+ } else {
+ onMarkRead(notification.id);
+ }
+ }}
+ >
+ {notification.isRead ? t('Mark as unread') : t('Mark as read')}
+
+ {
+ event.stopPropagation();
+ onClear(notification.id);
+ }}
+ >
+ {t('Clear')}
+
+
+
+
+ }
+ >
+ {notification.content}
+
+
+ );
+};
+
+type ToastNotificationDrawerItemsProps = {
+ notifications: ToastNotification[];
+ onClear: (id: string) => void;
+ onMarkRead: (id: string) => void;
+ onMarkUnread: (id: string) => void;
+};
+
+export const ToastNotificationDrawerItems: FC = ({
+ notifications,
+ onClear,
+ onMarkRead,
+ onMarkUnread,
+}) => (
+ <>
+ {notifications.map((notification) => (
+
+ ))}
+ >
+);
diff --git a/frontend/public/components/app.tsx b/frontend/public/components/app.tsx
index 9c151cb0dc8..ecf8a88fac7 100644
--- a/frontend/public/components/app.tsx
+++ b/frontend/public/components/app.tsx
@@ -49,7 +49,7 @@ import { GuidedTour } from '@console/app/src/components/tour';
import { QuickStartDrawer } from '@console/app/src/components/quick-starts/QuickStartDrawer';
import { ModalProvider } from '@console/dynamic-plugin-sdk/src/app/modal-support/ModalProvider';
import { OverlayProvider } from '@console/dynamic-plugin-sdk/src/app/modal-support/OverlayProvider';
-import { ToastProvider } from '@console/shared/src/components/toast/ToastProvider';
+import { ConnectedToastProvider } from './toast/ConnectedToastProvider';
import { SyncModalLaunchers } from '@console/shared/src/utils/error-modal-handler';
import { useTelemetry } from '@console/shared/src/hooks/useTelemetry';
import { useDebounceCallback } from '@console/shared/src/hooks/useDebounceCallback';
@@ -498,11 +498,11 @@ graphQLReady.onReady(() => {
-
+
-
+
diff --git a/frontend/public/components/masthead/masthead-toolbar.tsx b/frontend/public/components/masthead/masthead-toolbar.tsx
index e561e7f7a02..a31b6217e3c 100644
--- a/frontend/public/components/masthead/masthead-toolbar.tsx
+++ b/frontend/public/components/masthead/masthead-toolbar.tsx
@@ -32,6 +32,8 @@ import { useCopyLoginCommands } from '@console/shared/src/hooks/useCopyLoginComm
import { useFlag } from '@console/shared/src/hooks/useFlag';
import { useTelemetry } from '@console/shared/src/hooks/useTelemetry';
import { useUser } from '@console/shared/src/hooks/useUser';
+import { useNotificationHistory } from '@console/shared/src/components/toast/useNotificationHistory';
+import { getNotificationsVariant } from '@console/shared/src/components/toast/toastNotificationUtils';
import { YellowExclamationTriangleIcon } from '@console/shared/src/components/status/icons';
import { formatNamespacedRouteForResource } from '@console/shared/src/utils/namespace';
import { ExternalLinkButton } from '@console/shared/src/components/links/ExternalLinkButton';
@@ -184,6 +186,12 @@ const MastheadToolbarContents: FC = ({
// Use centralized user hook for user data
const { displayName, username } = useUser();
+ const { unreadCount: toastUnreadCount, hasUnreadDangerNotifications } = useNotificationHistory();
+ const notificationCount = (alertCount || 0) + toastUnreadCount;
+ const notificationBadgeVariant = getNotificationsVariant(
+ toastUnreadCount,
+ hasUnreadDangerNotifications,
+ );
const [isAppLauncherDropdownOpen, setIsAppLauncherDropdownOpen] = useState(false);
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
const [isKebabDropdownOpen, setIsKebabDropdownOpen] = useState(false);
@@ -740,6 +748,7 @@ const MastheadToolbarContents: FC = ({
const launchActions = getLaunchActions();
const alertAccess = canAccessNS && !!window.SERVER_FLAGS.prometheusBaseURL;
+ const showNotificationBadge = alertAccess || toastUnreadCount > 0;
return (
<>
@@ -776,12 +785,12 @@ const MastheadToolbarContents: FC = ({
{renderApplicationItems(launchActions)}
)}
- {alertAccess && (
+ {showNotificationBadge && (
@@ -826,12 +835,12 @@ const MastheadToolbarContents: FC = ({
visibility={{ default: isMastheadStacked ? 'visible' : 'hidden' }}
>
- {alertAccess && alertCount > 0 && (
+ {showNotificationBadge && notificationCount > 0 && (
diff --git a/frontend/public/components/notification-drawer.tsx b/frontend/public/components/notification-drawer.tsx
index eed7997721d..d8c8b3804cb 100644
--- a/frontend/public/components/notification-drawer.tsx
+++ b/frontend/public/components/notification-drawer.tsx
@@ -41,11 +41,16 @@ import {
} from '@console/shared/src/components/dashboard/status-card/alert-utils';
import {
Button,
+ Dropdown,
+ DropdownItem,
+ DropdownList,
EmptyState,
EmptyStateActions,
EmptyStateBody,
EmptyStateFooter,
EmptyStateVariant,
+ MenuToggle,
+ MenuToggleElement,
NotificationDrawer as PfNotificationDrawer,
NotificationDrawerBody,
NotificationDrawerGroup,
@@ -79,7 +84,17 @@ import { LinkifyExternal } from './utils/link';
import { LabelSelector } from '@console/internal/module/k8s/label-selector';
import { useNotificationAlerts } from '@console/shared/src/hooks/useNotificationAlerts';
import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay';
+import { DEFAULT_TOAST_DRAWER_GROUP } from '@console/shared/src/components/toast/types';
+import { useNotificationHistory } from '@console/shared/src/components/toast/useNotificationHistory';
+import {
+ getCustomToastDrawerGroups,
+ getToastDrawerGroupTitle,
+ getToastNotificationsForGroup,
+ groupToastNotifications,
+} from '@console/shared/src/components/toast/toastNotificationUtils';
+import { ToastNotificationDrawerItems } from './ToastNotificationDrawerItems';
import { NotificationTypes } from './utils/types';
+import { RhUiEllipsisVerticalIcon } from '@patternfly/react-icons';
const AlertErrorState: FC = ({ errorText }) => {
const { t } = useTranslation('public');
@@ -255,6 +270,24 @@ export const NotificationDrawer: FC = ({
[alertIds],
),
);
+ const {
+ notifications: toastNotifications,
+ unreadCount: toastUnreadCount,
+ markNotificationRead,
+ markNotificationUnread,
+ clearNotification,
+ clearAllNotifications,
+ markAllNotificationsRead,
+ } = useNotificationHistory();
+ const groupedToastNotifications = useMemo(() => groupToastNotifications(toastNotifications), [
+ toastNotifications,
+ ]);
+ const otherAlertsToastNotifications = getToastNotificationsForGroup(
+ groupedToastNotifications,
+ DEFAULT_TOAST_DRAWER_GROUP,
+ );
+ const customToastDrawerGroups = getCustomToastDrawerGroups(groupedToastNotifications);
+ const [isHeaderActionsOpen, setIsHeaderActionsOpen] = useState(false);
const toggleNotificationDrawer = () => {
dispatch(UIActions.notificationDrawerToggleExpanded());
@@ -292,9 +325,11 @@ export const NotificationDrawer: FC = ({
const hasCriticalAlerts = criticalAlerts.length > 0;
const hasNonCriticalAlerts = nonCriticalAlerts.length > 0;
+ const hasOtherAlertsToastNotifications = otherAlertsToastNotifications.length > 0;
const [isAlertExpanded, toggleAlertExpanded] = useState(hasCriticalAlerts);
const [isNonCriticalAlertExpanded, toggleNonCriticalAlertExpanded] = useState(true);
const [isClusterUpdateExpanded, toggleClusterUpdateExpanded] = useState(true);
+ const [expandedToastGroups, setExpandedToastGroups] = useState>({});
useEffect(() => {
if (hasCriticalAlerts && isDrawerExpanded) {
toggleAlertExpanded(true);
@@ -381,12 +416,12 @@ export const NotificationDrawer: FC = ({
);
const nonCriticalAlertCategory: ReactElement =
- nonCriticalAlerts.length > 0 ? (
+ hasNonCriticalAlerts || hasOtherAlertsToastNotifications ? (
{
toggleNonCriticalAlertExpanded(!isNonCriticalAlertExpanded);
}}
@@ -395,6 +430,12 @@ export const NotificationDrawer: FC = ({
isHidden={!isNonCriticalAlertExpanded}
aria-label={t('Notifications in the other alerts group')}
>
+
{nonCriticalAlerts.map((alert, i) => {
const alertVariant = NotificationTypes[getAlertSeverity(alert)];
const alertTime = getAlertTime(alert);
@@ -426,6 +467,39 @@ export const NotificationDrawer: FC = ({
) : null;
+ const toastNotificationCategories: ReactElement[] = customToastDrawerGroups.map((groupName) => {
+ const groupNotifications = getToastNotificationsForGroup(groupedToastNotifications, groupName);
+ const groupTitle = getToastDrawerGroupTitle(groupName, t);
+ const isExpanded = expandedToastGroups[groupName] ?? true;
+
+ return (
+ {
+ setExpandedToastGroups((state) => ({
+ ...state,
+ [groupName]: !isExpanded,
+ }));
+ }}
+ >
+
+
+
+
+ );
+ });
+
if (showServiceLevelNotification) {
updateList.push(
,
@@ -452,10 +526,44 @@ export const NotificationDrawer: FC = ({
return (
-
+
+ {toastNotifications.length > 0 && (
+ setIsHeaderActionsOpen(open)}
+ onSelect={() => setIsHeaderActionsOpen(false)}
+ popperProps={{ position: 'right' }}
+ toggle={(toggleRef: Ref) => (
+ setIsHeaderActionsOpen(!isHeaderActionsOpen)}
+ aria-label={t('Notification drawer actions')}
+ icon={}
+ />
+ )}
+ >
+
+ {t('Mark all read')}
+ {t('Clear all')}
+
+
+ )}
+
- {[criticalAlertCategory, nonCriticalAlertCategory, recommendationsCategory]}
+ {[
+ criticalAlertCategory,
+ nonCriticalAlertCategory,
+ ...toastNotificationCategories,
+ recommendationsCategory,
+ ]}
diff --git a/frontend/public/components/toast/ConnectedToastProvider.tsx b/frontend/public/components/toast/ConnectedToastProvider.tsx
new file mode 100644
index 00000000000..1a867fd53fe
--- /dev/null
+++ b/frontend/public/components/toast/ConnectedToastProvider.tsx
@@ -0,0 +1,32 @@
+import type { FC, ReactNode } from 'react';
+import { useCallback } from 'react';
+import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch';
+import { useConsoleSelector } from '@console/shared/src/hooks/useConsoleSelector';
+import { ToastProvider } from '@console/shared/src/components/toast/ToastProvider';
+import * as UIActions from '../../actions/ui';
+
+type ConnectedToastProviderProps = {
+ children?: ReactNode;
+};
+
+export const ConnectedToastProvider: FC = ({ children }) => {
+ const dispatch = useConsoleDispatch();
+ const isNotificationDrawerExpanded = useConsoleSelector(
+ ({ UI }) => !!UI.getIn(['notifications', 'isExpanded']),
+ );
+
+ const onNotificationDrawerOpen = useCallback(() => {
+ if (!isNotificationDrawerExpanded) {
+ dispatch(UIActions.notificationDrawerToggleExpanded());
+ }
+ }, [dispatch, isNotificationDrawerExpanded]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json
index 65468f3c1d9..a64d0170e7d 100644
--- a/frontend/public/locales/en/public.json
+++ b/frontend/public/locales/en/public.json
@@ -1119,9 +1119,14 @@
"Notifications in the critical alerts group": "Notifications in the critical alerts group",
"Other Alerts": "Other Alerts",
"Notifications in the other alerts group": "Notifications in the other alerts group",
+ "Notifications in the {{groupName}} group": "Notifications in the {{groupName}} group",
"Recommendations": "Recommendations",
"Notifications in the recommendations group": "Notifications in the recommendations group",
"Notifications": "Notifications",
+ "unread": "unread",
+ "Notification drawer actions": "Notification drawer actions",
+ "Mark all read": "Mark all read",
+ "Clear all": "Clear all",
"No PersistentVolume": "No PersistentVolume",
"PersistentVolume": "PersistentVolume",
"Capacity": "Capacity",
@@ -1418,6 +1423,10 @@
"TemplateInstance details": "TemplateInstance details",
"Parameters": "Parameters",
"Objects": "Objects",
+ "Notification actions": "Notification actions",
+ "Mark as unread": "Mark as unread",
+ "Mark as read": "Mark as read",
+ "Clear": "Clear",
"Users are automatically added the first time they log in.": "Users are automatically added the first time they log in.",
"Add identity providers (IDPs) to the OAuth configuration to allow others to log in.": "Add identity providers (IDPs) to the OAuth configuration to allow others to log in.",
"Add IDP": "Add IDP",
@@ -1466,7 +1475,6 @@
"An error occurred while uploading the file.": "An error occurred while uploading the file.",
"{{label}} filename": "{{label}} filename",
"Browse...": "Browse...",
- "Clear": "Clear",
"Non-printable file detected.": "Non-printable file detected.",
"File contains non-printable characters. Preview is not available.": "File contains non-printable characters. Preview is not available.",
"Logs": "Logs",
@@ -1720,6 +1728,8 @@
"Edit Pod count": "Edit Pod count",
"{{resourceKinds}} maintain the desired number of healthy pods.": "{{resourceKinds}} maintain the desired number of healthy pods.",
"{{label}} table": "{{label}} table",
+ "View {{count}} more notification(s)_one": "View {{count}} more notification",
+ "View {{count}} more notification(s)_other": "View {{count}} more notifications",
"unknown host": "unknown host",
"Just now": "Just now",
"{{count}} day_one": "{{count}} day",
diff --git a/frontend/public/locales/es/public.json b/frontend/public/locales/es/public.json
index 8d1c1517b11..3523f6cc340 100644
--- a/frontend/public/locales/es/public.json
+++ b/frontend/public/locales/es/public.json
@@ -1127,6 +1127,7 @@
"Recommendations": "Recomendaciones",
"Notifications in the recommendations group": "Notificaciones en el grupo de recomendaciones",
"Notifications": "Notificaciones",
+ "Clear all": "Borrar todo",
"No PersistentVolume": "Sin PersistentVolume",
"PersistentVolume": "PersistentVolume",
"Capacity": "Capacidad",
diff --git a/frontend/public/locales/fr/public.json b/frontend/public/locales/fr/public.json
index a7293548431..742d871fa9c 100644
--- a/frontend/public/locales/fr/public.json
+++ b/frontend/public/locales/fr/public.json
@@ -1127,6 +1127,7 @@
"Recommendations": "Recommandations",
"Notifications in the recommendations group": "Notifications dans le groupe de recommandations",
"Notifications": "Notifications",
+ "Clear all": "Effacer tout",
"No PersistentVolume": "Aucun volume persistant",
"PersistentVolume": "PersistentVolume",
"Capacity": "Capacité",
diff --git a/frontend/public/locales/ja/public.json b/frontend/public/locales/ja/public.json
index 4093eaf5918..0c86f2b4d1e 100644
--- a/frontend/public/locales/ja/public.json
+++ b/frontend/public/locales/ja/public.json
@@ -1127,6 +1127,7 @@
"Recommendations": "推奨項目",
"Notifications in the recommendations group": "推奨項目グループの通知",
"Notifications": "通知",
+ "Clear all": "すべてクリア",
"No PersistentVolume": "PersistentVolume がありません",
"PersistentVolume": "PersistentVolume",
"Capacity": "容量",
diff --git a/frontend/public/locales/ko/public.json b/frontend/public/locales/ko/public.json
index d04f2858846..1fee216d3cf 100644
--- a/frontend/public/locales/ko/public.json
+++ b/frontend/public/locales/ko/public.json
@@ -1127,6 +1127,7 @@
"Recommendations": "권장 사항",
"Notifications in the recommendations group": "권장 사항 그룹의 알림",
"Notifications": "알림",
+ "Clear all": "모두 지우기",
"No PersistentVolume": "영구 볼륨 없음",
"PersistentVolume": "PersistentVolume",
"Capacity": "용량",
diff --git a/frontend/public/locales/zh/public.json b/frontend/public/locales/zh/public.json
index db411a2a36e..cce25637da0 100644
--- a/frontend/public/locales/zh/public.json
+++ b/frontend/public/locales/zh/public.json
@@ -1127,6 +1127,7 @@
"Recommendations": "建议",
"Notifications in the recommendations group": "建议组中的通知",
"Notifications": "通知",
+ "Clear all": "全部清除",
"No PersistentVolume": "没有持久性卷",
"PersistentVolume": "持久性卷",
"Capacity": "容量",