feat: add bulk actions to the Notifications page
Signed-off-by: Marek Libra <marek.libra@gmail.com>
This commit is contained in:
@@ -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} • </>
|
||||
)}
|
||||
{notification.payload.topic && (
|
||||
<>{notification.payload.topic} • </>
|
||||
)}
|
||||
{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}
|
||||
// >
|
||||
// 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} • </>
|
||||
)}
|
||||
{notification.payload.topic && (
|
||||
<>{notification.payload.topic} • </>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user