diff --git a/.changeset/hot-forks-train.md b/.changeset/hot-forks-train.md new file mode 100644 index 0000000000..a5300085b6 --- /dev/null +++ b/.changeset/hot-forks-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications': patch +--- + +Allow showing notifications as snackbars in the UI diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index e998d76ea4..9ffcf0d727 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -100,6 +100,7 @@ export const notificationsPlugin: BackstagePlugin< export const NotificationsSidebarItem: (props?: { webNotificationsEnabled?: boolean; titleCounterEnabled?: boolean; + snackbarEnabled?: boolean; className?: string; icon?: IconComponent; text?: string; diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index 8c3a90c092..0c724e3d02 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -43,6 +43,7 @@ "@material-ui/lab": "^4.0.0-alpha.61", "@types/react": "^16.13.1 || ^17.0.0", "lodash": "^4.17.21", + "notistack": "^3.0.1", "react-relative-time": "^0.0.9", "react-use": "^17.2.4" }, diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index 6daea5ee34..9851ff8a66 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -13,22 +13,72 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useNotificationsApi } from '../../hooks'; import { SidebarItem } from '@backstage/core-components'; import NotificationsIcon from '@material-ui/icons/Notifications'; import { IconComponent, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { rootRouteRef } from '../../routes'; import { useSignal } from '@backstage/plugin-signals-react'; -import { NotificationSignal } from '@backstage/plugin-notifications-common'; +import { + Notification, + NotificationSignal, +} from '@backstage/plugin-notifications-common'; import { useWebNotifications } from '../../hooks/useWebNotifications'; import { useTitleCounter } from '../../hooks/useTitleCounter'; import { notificationsApiRef } from '../../api'; +import { + closeSnackbar, + enqueueSnackbar, + MaterialDesignContent, + OptionsWithExtraProps, + SnackbarKey, + SnackbarProvider, + VariantType, +} from 'notistack'; +import { SeverityIcon } from '../NotificationsTable/SeverityIcon'; +import { useNavigate } from 'react-router-dom'; +import OpenInNew from '@material-ui/icons/OpenInNew'; +import MarkAsReadIcon from '@material-ui/icons/CheckCircle'; +import IconButton from '@material-ui/core/IconButton'; +import { styled } from '@material-ui/core/styles'; + +const StyledMaterialDesignContent = styled(MaterialDesignContent)( + ({ theme }) => ({ + '&.notistack-MuiContent-low': { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + }, + '&.notistack-MuiContent-normal': { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + }, + '&.notistack-MuiContent-high': { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + }, + '&.notistack-MuiContent-critical': { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + }, + }), +); + +declare module 'notistack' { + interface VariantOverrides { + // Custom variants for the snackbar + low: true; + normal: true; + high: true; + critical: true; + } +} /** @public */ export const NotificationsSidebarItem = (props?: { webNotificationsEnabled?: boolean; titleCounterEnabled?: boolean; + snackbarEnabled?: boolean; className?: string; icon?: IconComponent; text?: string; @@ -38,14 +88,20 @@ export const NotificationsSidebarItem = (props?: { const { webNotificationsEnabled = false, titleCounterEnabled = true, + snackbarEnabled = true, icon = NotificationsIcon, text = 'Notifications', ...restProps - } = props ?? { webNotificationsEnabled: false, titleCounterEnabled: true }; + } = props ?? { + webNotificationsEnabled: false, + titleCounterEnabled: true, + snackbarEnabled: true, + }; const { loading, error, value, retry } = useNotificationsApi(api => api.getStatus(), ); + const navigate = useNavigate(); const notificationsApi = useApi(notificationsApiRef); const [unreadCount, setUnreadCount] = React.useState(0); const notificationsRoute = useRouteRef(rootRouteRef); @@ -55,6 +111,40 @@ export const NotificationsSidebarItem = (props?: { const [refresh, setRefresh] = React.useState(false); const { setNotificationCount } = useTitleCounter(); + const getSnackbarProperties = useCallback( + (notification: Notification) => { + const action = (snackBarId: SnackbarKey) => ( + <> + { + if (notification.payload.link) { + window.open(notification.payload.link, '_blank'); + } + navigate(notificationsRoute()); + closeSnackbar(snackBarId); + }} + > + + + { + notificationsApi.updateNotifications({ + ids: [notification.id], + read: true, + }); + closeSnackbar(snackBarId); + }} + > + + + + ); + + return { action }; + }, + [notificationsRoute, navigate, notificationsApi], + ); + useEffect(() => { if (refresh) { retry(); @@ -63,8 +153,11 @@ export const NotificationsSidebarItem = (props?: { }, [refresh, retry]); useEffect(() => { - const handleWebNotification = (signal: NotificationSignal) => { - if (!webNotificationsEnabled || signal.action !== 'new_notification') { + const handleNotificationSignal = (signal: NotificationSignal) => { + if ( + (!webNotificationsEnabled && !snackbarEnabled) || + signal.action !== 'new_notification' + ) { return; } @@ -74,24 +167,40 @@ export const NotificationsSidebarItem = (props?: { if (!notification) { return; } - sendWebNotification({ - id: notification.id, - title: notification.payload.title, - description: notification.payload.description ?? '', - link: notification.payload.link, - }); + if (webNotificationsEnabled) { + sendWebNotification({ + id: notification.id, + title: notification.payload.title, + description: notification.payload.description ?? '', + link: notification.payload.link, + }); + } + if (snackbarEnabled) { + const { action } = getSnackbarProperties(notification); + const snackBarText = + notification.payload.title.length > 50 + ? `${notification.payload.title.substring(0, 50)}...` + : notification.payload.title; + enqueueSnackbar(snackBarText, { + variant: notification.payload.severity, + anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, + action, + } as OptionsWithExtraProps); + } }); }; if (lastSignal && lastSignal.action) { - handleWebNotification(lastSignal); + handleNotificationSignal(lastSignal); setRefresh(true); } }, [ lastSignal, sendWebNotification, webNotificationsEnabled, + snackbarEnabled, notificationsApi, + getSnackbarProperties, ]); useEffect(() => { @@ -108,12 +217,30 @@ export const NotificationsSidebarItem = (props?: { // TODO: Figure out if the count can be added to hasNotifications return ( - + <> + {snackbarEnabled && ( + , + critical: , + high: , + low: , + }} + Components={{ + normal: StyledMaterialDesignContent, + critical: StyledMaterialDesignContent, + high: StyledMaterialDesignContent, + low: StyledMaterialDesignContent, + }} + /> + )} + + ); }; diff --git a/yarn.lock b/yarn.lock index a07e9249f7..0ee0b47946 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6071,6 +6071,7 @@ __metadata: "@types/react": ^16.13.1 || ^17.0.0 lodash: ^4.17.21 msw: ^1.0.0 + notistack: ^3.0.1 react-relative-time: ^0.0.9 react-use: ^17.2.4 peerDependencies: @@ -20124,7 +20125,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.0.2, clsx@npm:^1.0.4, clsx@npm:^1.1.1, clsx@npm:^1.2.1": +"clsx@npm:^1.0.2, clsx@npm:^1.0.4, clsx@npm:^1.1.0, clsx@npm:^1.1.1, clsx@npm:^1.2.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 @@ -25650,6 +25651,15 @@ __metadata: languageName: node linkType: hard +"goober@npm:^2.0.33": + version: 2.1.14 + resolution: "goober@npm:2.1.14" + peerDependencies: + csstype: ^3.0.10 + checksum: 78978b7192d6a1af5cfbf1fd64b661b5f53ee6c733554b1f1b2ad3e1e2c979847fc080434390647640bb8358c0b193895d0007432c0886d12001f02f8f56b5e6 + languageName: node + linkType: hard + "google-auth-library@npm:^9.0.0, google-auth-library@npm:^9.6.3": version: 9.8.0 resolution: "google-auth-library@npm:9.8.0" @@ -32361,6 +32371,19 @@ __metadata: languageName: node linkType: hard +"notistack@npm:^3.0.1": + version: 3.0.1 + resolution: "notistack@npm:3.0.1" + dependencies: + clsx: ^1.1.0 + goober: ^2.0.33 + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 421c970308690b8c8cb2e275e7f66020db7c1955e104f638e7fa562396a6b9322ff95f0e62492b07f3d36b0ef72adb4de2c2ce9803089c1c8f028d1a3b088e01 + languageName: node + linkType: hard + "npm-bundled@npm:^1.1.1": version: 1.1.2 resolution: "npm-bundled@npm:1.1.2"