feat: add bulk actions to the Notifications page

Signed-off-by: Marek Libra <marek.libra@gmail.com>
This commit is contained in:
Marek Libra
2024-03-17 11:27:37 +01:00
parent 16fb59bed4
commit ec40998df3
6 changed files with 294 additions and 122 deletions
+6
View File
@@ -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.
@@ -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;
@@ -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) {
@@ -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<Notification['id']>;
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 (
<Grid container wrap="nowrap">
<Grid item>
<Tooltip title={markAsSavedText}>
<div>
{/* The <div> here is a workaround for the Tooltip which does not work for a "disabled" child */}
<IconButton
disabled={isDisabled}
onClick={() => {
onSwitchSavedStatus([...selectedNotifications], !isOneSaved);
}}
>
<SavedIconComponent aria-label={markAsSavedText} />
</IconButton>
</div>
</Tooltip>
</Grid>
<Grid item>
<Tooltip title={markAsReadText}>
<div>
<IconButton
disabled={isDisabled}
onClick={() => {
onSwitchReadStatus([...selectedNotifications], !isOneRead);
}}
>
<IconComponent aria-label={markAsReadText} />
</IconButton>
</div>
</Tooltip>
</Grid>
</Grid>
);
};
@@ -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<Notification['id']>(),
);
const onNotificationsSelectChange = React.useCallback(
(ids: Notification['id'][], checked: boolean) => {
let newSelect: Set<Notification['id']>;
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<Notification>[] => [
{
/* selection column */
width: '1rem',
title: (
<SelectAll
count={selectedNotifications.size}
totalCount={notifications.length}
onSelectAll={() =>
onNotificationsSelectChange(
notifications.map(notification => notification.id),
selectedNotifications.size !== notifications.length,
)
}
/>
),
render: (notification: Notification) => (
<SeverityIcon severity={notification.payload?.severity} />
<CheckBox
color="primary"
checked={selectedNotifications.has(notification.id)}
onChange={(_, checked) =>
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 (
<>
<Box>
<Typography variant="subtitle2">
{notification.payload.link ? (
<Link to={notification.payload.link}>
{notification.payload.title}
</Link>
) : (
notification.payload.title
)}
</Typography>
<Typography variant="body2">
{notification.payload.description}
</Typography>
<Typography variant="caption">
{notification.origin && (
<>{notification.origin}&nbsp;&bull;&nbsp;</>
)}
{notification.payload.topic && (
<>{notification.payload.topic}&nbsp;&bull;&nbsp;</>
)}
{notification.created && (
<RelativeTime value={notification.created} />
)}
</Typography>
</Box>
</>
);
},
},
// {
// // TODO: additional action links
// width: '25%',
// render: (notification: Notification) => {
// return (
// notification.payload.link && (
// <Grid container>
// {/* TODO: render additionalLinks of different titles */}
// <Grid item>
// <Link
// key={notification.payload.link}
// to={notification.payload.link}
// >
// &nbsp;More info
// </Link>
// </Grid>
// </Grid>
// )
// );
// },
// },
{
// 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 (
<Grid container wrap="nowrap">
<Grid container>
<Grid item>
<Tooltip title={markAsSavedText}>
<IconButton
onClick={() => {
onSwitchSavedStatus(notification);
}}
>
<SavedIconComponent aria-label={markAsSavedText} />
</IconButton>
</Tooltip>
<SeverityIcon severity={notification.payload?.severity} />
</Grid>
<Grid item>
<Tooltip title={markAsReadText}>
<IconButton
onClick={() => {
onSwitchReadStatus(notification);
}}
>
<IconComponent aria-label={markAsReadText} />
</IconButton>
</Tooltip>
<Box>
<Typography variant="subtitle2">
{notification.payload.link ? (
<Link to={notification.payload.link}>
{notification.payload.title}
</Link>
) : (
notification.payload.title
)}
</Typography>
<Typography variant="body2">
{notification.payload.description}
</Typography>
<Typography variant="caption">
{notification.origin && (
<>{notification.origin}&nbsp;&bull;&nbsp;</>
)}
{notification.payload.topic && (
<>{notification.payload.topic}&nbsp;&bull;&nbsp;</>
)}
{notification.created && (
<RelativeTime value={notification.created} />
)}
</Typography>
</Box>
</Grid>
</Grid>
);
},
},
{
/* actions column */
width: '1rem',
title: (
<BulkActions
notifications={notifications}
selectedNotifications={selectedNotifications}
onSwitchReadStatus={onSwitchReadStatus}
onSwitchSavedStatus={onSwitchSavedStatus}
/>
),
render: (notification: Notification) => (
<BulkActions
notifications={[notification]}
selectedNotifications={new Set([notification.id])}
onSwitchReadStatus={onSwitchReadStatus}
onSwitchSavedStatus={onSwitchSavedStatus}
/>
),
},
],
[
onSwitchReadStatus,
onSwitchSavedStatus,
selectedNotifications,
onNotificationsSelectChange,
notifications,
],
[onSwitchReadStatus, onSwitchSavedStatus],
);
return (
<Table<Notification>
isLoading={isLoading}
options={{
padding: 'dense',
search: true,
paging: true,
pageSize,
header: false,
header: true,
sorting: false,
}}
onPageChange={onPageChange}
@@ -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 (
<FormControlLabel
label={count > 0 ? `(${count})` : undefined}
className={classes.label}
control={
<Checkbox
color="primary"
disabled={!totalCount}
checked={count > 0}
indeterminate={count > 0 && totalCount !== count}
onChange={onSelectAll}
/>
}
/>
);
};