diff --git a/.changeset/useless-name-generator.md b/.changeset/useless-name-generator.md new file mode 100644 index 0000000000..f0480adee1 --- /dev/null +++ b/.changeset/useless-name-generator.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-notifications-backend': minor +'@backstage/plugin-notifications': minor +--- + +On the Notifications page, the user can trigger "Save" or "Mark as read" actions once for multiple selected notifications. diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index f9e87335ed..9f16f45248 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -229,6 +229,13 @@ export async function createRouter( }); }); + router.get('/status', async (req, res) => { + const user = await getUser(req); + const status = await store.getStatus({ user }); + res.send(status); + }); + + // Make sure this is the last "GET" handler router.get('/:id', async (req, res) => { const user = await getUser(req); const opts: NotificationGetOptions = { @@ -244,12 +251,6 @@ export async function createRouter( res.send(notifications[0]); }); - router.get('/status', async (req, res) => { - const user = await getUser(req); - const status = await store.getStatus({ user }); - res.send(status); - }); - router.post('/update', async (req, res) => { const user = await getUser(req); const { ids, read, saved } = req.body; diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 8ff9983d02..3069619eef 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -15,6 +15,7 @@ */ import React, { useEffect } from 'react'; +import throttle from 'lodash/throttle'; import { Content, PageWithHeader, @@ -34,6 +35,8 @@ import { import { GetNotificationsOptions } from '../../api'; import { NotificationSeverity } from '@backstage/plugin-notifications-common'; +const ThrottleDelayMs = 2000; + export const NotificationsPage = () => { const [refresh, setRefresh] = React.useState(false); const { lastSignal } = useSignal('notifications'); @@ -83,21 +86,26 @@ export const NotificationsPage = () => { ], ); + const throttledSetRefresh = React.useMemo( + () => throttle(setRefresh, ThrottleDelayMs), + [setRefresh], + ); + useEffect(() => { - if (refresh) { + if (refresh && !loading) { retry(); setRefresh(false); } - }, [refresh, setRefresh, retry]); + }, [refresh, setRefresh, retry, loading]); useEffect(() => { if (lastSignal && lastSignal.action) { - setRefresh(true); + throttledSetRefresh(true); } - }, [lastSignal]); + }, [lastSignal, throttledSetRefresh]); const onUpdate = () => { - setRefresh(true); + throttledSetRefresh(true); }; if (error) { diff --git a/plugins/notifications/src/components/NotificationsTable/BulkActions.tsx b/plugins/notifications/src/components/NotificationsTable/BulkActions.tsx new file mode 100644 index 0000000000..034da7c93c --- /dev/null +++ b/plugins/notifications/src/components/NotificationsTable/BulkActions.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Notification } from '@backstage/plugin-notifications-common'; +import Grid from '@material-ui/core/Grid'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import MarkAsUnreadIcon from '@material-ui/icons/Markunread' /* TODO: use Drafts and MarkAsUnread once we have mui 5 icons */; +import MarkAsReadIcon from '@material-ui/icons/CheckCircle'; +import MarkAsUnsavedIcon from '@material-ui/icons/LabelOff' /* TODO: use BookmarkRemove and BookmarkAdd once we have mui 5 icons */; +import MarkAsSavedIcon from '@material-ui/icons/Label'; + +export const BulkActions = ({ + selectedNotifications, + notifications, + onSwitchReadStatus, + onSwitchSavedStatus, +}: { + selectedNotifications: Set; + notifications: Notification[]; + onSwitchReadStatus: (ids: Notification['id'][], newStatus: boolean) => void; + onSwitchSavedStatus: (ids: Notification['id'][], newStatus: boolean) => void; +}) => { + const isDisabled = selectedNotifications.size === 0; + const bulkNotifications = notifications.filter(notification => + selectedNotifications.has(notification.id), + ); + + const isOneRead = !!bulkNotifications.find( + (notification: Notification) => !!notification.read, + ); + const isOneSaved = !!bulkNotifications.find( + (notification: Notification) => !!notification.saved, + ); + + const markAsReadText = isOneRead + ? 'Return selected among unread' + : 'Mark selected as read'; + const IconComponent = isOneRead ? MarkAsUnreadIcon : MarkAsReadIcon; + + const markAsSavedText = isOneSaved + ? 'Undo save for selected' + : 'Save selected for later'; + const SavedIconComponent = isOneSaved ? MarkAsUnsavedIcon : MarkAsSavedIcon; + + return ( + + + +
+ {/* The
here is a workaround for the Tooltip which does not work for a "disabled" child */} + { + onSwitchSavedStatus([...selectedNotifications], !isOneSaved); + }} + > + + +
+ + + + + +
+ { + onSwitchReadStatus([...selectedNotifications], !isOneRead); + }} + > + + +
+
+
+ + ); +}; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 733fd7f503..daaa937611 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useMemo } from 'react'; +import React from 'react'; import throttle from 'lodash/throttle'; // @ts-ignore import RelativeTime from 'react-relative-time'; import Box from '@material-ui/core/Box'; import Grid from '@material-ui/core/Grid'; -import IconButton from '@material-ui/core/IconButton'; -import Tooltip from '@material-ui/core/Tooltip'; +import CheckBox from '@material-ui/core/Checkbox'; import Typography from '@material-ui/core/Typography'; import { Notification } from '@backstage/plugin-notifications-common'; @@ -33,11 +32,9 @@ import { TableColumn, } from '@backstage/core-components'; -import MarkAsUnreadIcon from '@material-ui/icons/Markunread' /* TODO: use Drafts and MarkAsUnread once we have mui 5 icons */; -import MarkAsReadIcon from '@material-ui/icons/CheckCircle'; -import MarkAsUnsavedIcon from '@material-ui/icons/LabelOff' /* TODO: use BookmarkRemove and BookmarkAdd once we have mui 5 icons */; -import MarkAsSavedIcon from '@material-ui/icons/Label'; import { SeverityIcon } from './SeverityIcon'; +import { SelectAll } from './SelectAll'; +import { BulkActions } from './BulkActions'; const ThrottleDelayMs = 1000; @@ -66,161 +63,171 @@ export const NotificationsTable = ({ totalCount, }: NotificationsTableProps) => { const notificationsApi = useApi(notificationsApiRef); + const [selectedNotifications, setSelectedNotifications] = React.useState( + new Set(), + ); + + const onNotificationsSelectChange = React.useCallback( + (ids: Notification['id'][], checked: boolean) => { + let newSelect: Set; + if (checked) { + newSelect = new Set([...selectedNotifications, ...ids]); + } else { + newSelect = new Set(selectedNotifications); + ids.forEach(id => newSelect.delete(id)); + } + setSelectedNotifications(newSelect); + }, + [selectedNotifications, setSelectedNotifications], + ); const onSwitchReadStatus = React.useCallback( - (notification: Notification) => { + (ids: Notification['id'][], newStatus: boolean) => { notificationsApi .updateNotifications({ - ids: [notification.id], - read: !notification.read, + ids, + read: newStatus, }) - .then(() => onUpdate()); + .then(onUpdate); }, [notificationsApi, onUpdate], ); const onSwitchSavedStatus = React.useCallback( - (notification: Notification) => { + (ids: Notification['id'][], newStatus: boolean) => { notificationsApi .updateNotifications({ - ids: [notification.id], - saved: !notification.saved, + ids, + saved: newStatus, }) - .then(() => onUpdate()); + .then(onUpdate); }, [notificationsApi, onUpdate], ); - const throttledContainsTextHandler = useMemo( + const throttledContainsTextHandler = React.useMemo( () => throttle(setContainsText, ThrottleDelayMs), [setContainsText], ); + React.useEffect(() => { + const allShownIds = new Set(notifications.map(n => n.id)); + const intersect = [...selectedNotifications].filter(id => + allShownIds.has(id), + ); + if (selectedNotifications.size !== intersect.length) { + setSelectedNotifications(new Set(intersect)); + } + }, [notifications, selectedNotifications]); + const compactColumns = React.useMemo( (): TableColumn[] => [ { + /* selection column */ width: '1rem', + title: ( + + onNotificationsSelectChange( + notifications.map(notification => notification.id), + selectedNotifications.size !== notifications.length, + ) + } + /> + ), render: (notification: Notification) => ( - + + onNotificationsSelectChange([notification.id], checked) + } + /> ), }, { + /* compact-data column */ customFilterAndSearch: () => - true /* Keep it on backend due to pagination. If recent flickering is an issue, implement search here as well. */, + true /* Keep sorting&filtering on backend due to pagination. */, render: (notification: Notification) => { // Compact content return ( - <> - - - {notification.payload.link ? ( - - {notification.payload.title} - - ) : ( - notification.payload.title - )} - - - {notification.payload.description} - - - {notification.origin && ( - <>{notification.origin} •  - )} - {notification.payload.topic && ( - <>{notification.payload.topic} •  - )} - {notification.created && ( - - )} - - - - ); - }, - }, - // { - // // TODO: additional action links - // width: '25%', - // render: (notification: Notification) => { - // return ( - // notification.payload.link && ( - // - // {/* TODO: render additionalLinks of different titles */} - // - // - //  More info - // - // - // - // ) - // ); - // }, - // }, - { - // actions - width: '1rem', - render: (notification: Notification) => { - const markAsReadText = !!notification.read - ? 'Return among unread' - : 'Mark as read'; - const IconComponent = !!notification.read - ? MarkAsUnreadIcon - : MarkAsReadIcon; - - const markAsSavedText = !!notification.saved - ? 'Undo save' - : 'Save for later'; - - const SavedIconComponent = !!notification.saved - ? MarkAsUnsavedIcon - : MarkAsSavedIcon; - - return ( - + - - { - onSwitchSavedStatus(notification); - }} - > - - - + - - - { - onSwitchReadStatus(notification); - }} - > - - - + + + {notification.payload.link ? ( + + {notification.payload.title} + + ) : ( + notification.payload.title + )} + + + {notification.payload.description} + + + {notification.origin && ( + <>{notification.origin} •  + )} + {notification.payload.topic && ( + <>{notification.payload.topic} •  + )} + {notification.created && ( + + )} + + ); }, }, + { + /* actions column */ + width: '1rem', + title: ( + + ), + render: (notification: Notification) => ( + + ), + }, + ], + [ + onSwitchReadStatus, + onSwitchSavedStatus, + selectedNotifications, + onNotificationsSelectChange, + notifications, ], - [onSwitchReadStatus, onSwitchSavedStatus], ); return ( isLoading={isLoading} options={{ + padding: 'dense', search: true, paging: true, pageSize, - header: false, + header: true, sorting: false, }} onPageChange={onPageChange} diff --git a/plugins/notifications/src/components/NotificationsTable/SelectAll.tsx b/plugins/notifications/src/components/NotificationsTable/SelectAll.tsx new file mode 100644 index 0000000000..8f604779b7 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsTable/SelectAll.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import Checkbox from '@material-ui/core/Checkbox'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles({ + label: { + marginLeft: '0px', + maxWidth: '2rem', + '& span': { + paddingRight: '0px', + }, + }, +}); + +export const SelectAll = ({ + count, + totalCount, + onSelectAll, +}: { + count: number; + totalCount: number; + onSelectAll: () => void; +}) => { + const classes = useStyles(); + + return ( + 0 ? `(${count})` : undefined} + className={classes.label} + control={ + 0} + indeterminate={count > 0 && totalCount !== count} + onChange={onSelectAll} + /> + } + /> + ); +};