From dcf1921c4cce7a4d93a054d1b16ebfec3663cb5f Mon Sep 17 00:00:00 2001 From: Gal Kremer Date: Tue, 16 Jun 2026 20:44:32 -0400 Subject: [PATCH] CONSOLE-5361: Add toast overflow and notification drawer integration --- .../CHANGELOG-core.md | 3 + .../console-dynamic-plugin-sdk/docs/api.md | 3 +- .../release-notes/4.23.md | 19 +- .../src/api/core-api.ts | 3 +- .../src/extensions/console-types.ts | 15 + .../toast/NotificationHistoryContext.tsx | 6 + .../src/components/toast/ToastProvider.tsx | 288 +++++++++---- .../toast/__tests__/ToastProvider.spec.tsx | 381 +++++++++++++++++- .../toast/__tests__/toastDisplayUtils.spec.ts | 71 ++++ .../__tests__/toastNotificationUtils.spec.ts | 65 +++ .../src/components/toast/toastDisplayUtils.ts | 42 ++ .../toast/toastNotificationUtils.ts | 36 ++ .../src/components/toast/types.ts | 24 ++ .../toast/useNotificationHistory.ts | 4 + .../ToastNotificationDrawerItems.tsx | 136 +++++++ frontend/public/components/app.tsx | 6 +- .../components/masthead/masthead-toolbar.tsx | 21 +- .../public/components/notification-drawer.tsx | 116 +++++- .../toast/ConnectedToastProvider.tsx | 32 ++ frontend/public/locales/en/public.json | 12 +- frontend/public/locales/es/public.json | 1 + frontend/public/locales/fr/public.json | 1 + frontend/public/locales/ja/public.json | 1 + frontend/public/locales/ko/public.json | 1 + frontend/public/locales/zh/public.json | 1 + 25 files changed, 1202 insertions(+), 86 deletions(-) create mode 100644 frontend/packages/console-shared/src/components/toast/NotificationHistoryContext.tsx create mode 100644 frontend/packages/console-shared/src/components/toast/__tests__/toastDisplayUtils.spec.ts create mode 100644 frontend/packages/console-shared/src/components/toast/__tests__/toastNotificationUtils.spec.ts create mode 100644 frontend/packages/console-shared/src/components/toast/toastDisplayUtils.ts create mode 100644 frontend/packages/console-shared/src/components/toast/toastNotificationUtils.ts create mode 100644 frontend/packages/console-shared/src/components/toast/types.ts create mode 100644 frontend/packages/console-shared/src/components/toast/useNotificationHistory.ts create mode 100644 frontend/public/components/ToastNotificationDrawerItems.tsx create mode 100644 frontend/public/components/toast/ConnectedToastProvider.tsx 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": "容量",