feat: support saving and marking notifications done

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2023-12-15 16:13:57 +02:00
parent b3d70e2a4f
commit 49afb1823f
16 changed files with 431 additions and 56 deletions
@@ -16,6 +16,7 @@
import { createApiRef } from '@backstage/core-plugin-api';
import {
Notification,
NotificationIds,
NotificationStatus,
NotificationType,
} from '@backstage/plugin-notifications-common';
@@ -36,6 +37,11 @@ export interface NotificationsApi {
getStatus(): Promise<NotificationStatus>;
// TODO: Mark as read/unread by notification id(s)
// TODO: Mark as saved/unsaved by notification id(s)
markRead(ids: string[]): Promise<NotificationIds>;
markUnread(ids: string[]): Promise<NotificationIds>;
markSaved(ids: string[]): Promise<NotificationIds>;
markUnsaved(ids: string[]): Promise<NotificationIds>;
}
@@ -18,6 +18,7 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import {
Notification,
NotificationIds,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -44,18 +45,50 @@ export class NotificationsClient implements NotificationsApi {
const urlSegment = `notifications?${queryString}`;
return await this.get<Notification[]>(urlSegment);
return await this.request<Notification[]>(urlSegment);
}
async getStatus(): Promise<NotificationStatus> {
return await this.get<NotificationStatus>('status');
return await this.request<NotificationStatus>('status');
}
private async get<T>(path: string): Promise<T> {
async markRead(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('read', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markUnread(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('unread', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markSaved(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('save', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markUnsaved(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('unsave', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
private async request<T>(path: string, init?: any): Promise<T> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`;
const url = new URL(path, baseUrl);
const response = await this.fetchApi.fetch(url.toString());
const response = await this.fetchApi.fetch(url.toString(), init);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
@@ -44,12 +44,14 @@ const useStyles = makeStyles(_theme => ({
export const NotificationsPage = () => {
const [type, setType] = useState<NotificationType>('unread');
const {
loading: _loading,
error,
value,
retry: _retry,
} = useNotificationsApi(api => api.getNotifications({ type }), [type]);
const { loading, error, value, retry } = useNotificationsApi(
api => api.getNotifications({ type }),
[type],
);
const onUpdate = () => {
retry();
};
const styles = useStyles();
if (error) {
@@ -89,7 +91,12 @@ export const NotificationsPage = () => {
</Grid>
<Grid item xs={10}>
<TableContainer component={Paper}>
<NotificationsTable notifications={value} />
<NotificationsTable
notifications={value}
type={type}
loading={loading}
onUpdate={onUpdate}
/>
</TableContainer>
</Grid>
</Grid>
@@ -13,8 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
IconButton,
makeStyles,
Table,
@@ -24,20 +26,45 @@ import {
Tooltip,
Typography,
} from '@material-ui/core';
import { Notification } from '@backstage/plugin-notifications-common';
import {
Notification,
NotificationType,
} from '@backstage/plugin-notifications-common';
import { useNavigate } from 'react-router-dom';
import NotificationsIcon from '@material-ui/icons/Notifications';
import Checkbox from '@material-ui/core/Checkbox';
import Check from '@material-ui/icons/Check';
import Bookmark from '@material-ui/icons/Bookmark';
import { notificationsApiRef } from '../../api';
import { useApi } from '@backstage/core-plugin-api';
import Inbox from '@material-ui/icons/Inbox';
import CloseIcon from '@material-ui/icons/Close';
import { Skeleton } from '@material-ui/lab';
// @ts-ignore
import RelativeTime from 'react-relative-time';
const useStyles = makeStyles(theme => ({
notificationRow: {
cursor: 'pointer',
'&.hideOnHover': {
display: 'initial',
},
'& .showOnHover': {
display: 'none',
},
'&:hover': {
backgroundColor: theme.palette.linkHover,
'& .hideOnHover': {
display: 'none',
},
'& .showOnHover': {
display: 'initial',
},
},
},
actionButton: {
padding: '9px',
},
checkBox: {
padding: '0 10px 10px 0',
},
@@ -45,25 +72,102 @@ const useStyles = makeStyles(theme => ({
/** @public */
export const NotificationsTable = (props: {
onUpdate: () => void;
type: NotificationType;
loading?: boolean;
notifications?: Notification[];
}) => {
const { notifications } = props;
const { notifications, type, loading } = props;
const navigate = useNavigate();
const styles = useStyles();
// TODO: Add select all
// TODO: Make mark as read work
// TODO: Check status of the notification and change to "Mark as unread" if it's already read
// TODO: Add support to save notifications (storageApi)
const [selected, setSelected] = useState<string[]>([]);
const notificationsApi = useApi(notificationsApiRef);
const onCheckBoxClick = (id: string) => {
const index = selected.indexOf(id);
if (index !== -1) {
setSelected(selected.filter(s => s !== id));
} else {
setSelected([...selected, id]);
}
};
useEffect(() => {
setSelected([]);
}, [type]);
const isChecked = (id: string) => {
return selected.indexOf(id) !== -1;
};
const isAllSelected = () => {
return (
selected.length === notifications?.length && notifications.length > 0
);
};
if (loading) {
return <Skeleton variant="rect" height={200} />;
}
// TODO: Show timestamp relative time (react-relative-time npm package)
// TODO: Add signals listener and refresh data on message
// TODO: Handle no notifications properly
// TODO: Handle loading notifications
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell colSpan={3}>
{notifications?.length ?? 0} notifications
{type !== 'saved' && !notifications?.length && 'No notifications'}
{type !== 'saved' && !!notifications?.length && (
<Checkbox
size="small"
style={{ paddingLeft: 0 }}
checked={isAllSelected()}
onClick={() => {
if (isAllSelected()) {
setSelected([]);
} else {
setSelected(
notifications ? notifications.map(n => n.id) : [],
);
}
}}
/>
)}
{type === 'saved' &&
`${notifications?.length ?? 0} saved notifications`}
{selected.length === 0 &&
!!notifications?.length &&
type !== 'saved' &&
'Select all'}
{selected.length > 0 && `${selected.length} selected`}
{type === 'read' && selected.length > 0 && (
<Button
startIcon={<Inbox fontSize="small" />}
onClick={() => {
notificationsApi
.markUnread(selected)
.then(() => props.onUpdate());
setSelected([]);
}}
>
Move to inbox
</Button>
)}
{type === 'unread' && selected.length > 0 && (
<Button
startIcon={<Check fontSize="small" />}
onClick={() => {
notificationsApi
.markRead(selected)
.then(() => props.onUpdate());
setSelected([]);
}}
>
Mark as done
</Button>
)}
</TableCell>
</TableRow>
</TableHead>
@@ -71,7 +175,12 @@ export const NotificationsTable = (props: {
return (
<TableRow key={notification.id} className={styles.notificationRow}>
<TableCell width={100} style={{ verticalAlign: 'center' }}>
<Checkbox className={styles.checkBox} size="small" />
<Checkbox
className={styles.checkBox}
size="small"
checked={isChecked(notification.id)}
onClick={() => onCheckBoxClick(notification.id)}
/>
{notification.icon ?? <NotificationsIcon fontSize="small" />}
</TableCell>
<TableCell onClick={() => navigate(notification.link)}>
@@ -81,16 +190,67 @@ export const NotificationsTable = (props: {
</Typography>
</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Tooltip title="Mark as read">
<IconButton>
<Check fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Save">
<IconButton>
<Bookmark fontSize="small" />
</IconButton>
</Tooltip>
<Box className="hideOnHover">
<RelativeTime value={notification.created} />
</Box>
<Box className="showOnHover">
<Tooltip
title={notification.read ? 'Move to inbox' : 'Mark as done'}
>
<IconButton
className={styles.actionButton}
onClick={() => {
if (notification.read) {
notificationsApi
.markUnread([notification.id])
.then(() => {
props.onUpdate();
});
} else {
notificationsApi
.markRead([notification.id])
.then(() => {
props.onUpdate();
});
}
}}
>
{notification.read ? (
<Inbox fontSize="small" />
) : (
<Check fontSize="small" />
)}
</IconButton>
</Tooltip>
<Tooltip
title={notification.saved ? 'Remove from saved' : 'Save'}
>
<IconButton
className={styles.actionButton}
onClick={() => {
if (notification.saved) {
notificationsApi
.markUnsaved([notification.id])
.then(() => {
props.onUpdate();
});
} else {
notificationsApi
.markSaved([notification.id])
.then(() => {
props.onUpdate();
});
}
}}
>
{notification.saved ? (
<CloseIcon fontSize="small" />
) : (
<Bookmark fontSize="small" />
)}
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
);