feat: update Notifications front-end

Signed-off-by: Marek Libra <marek.libra@gmail.com>
This commit is contained in:
Marek Libra
2024-02-15 12:16:04 +01:00
parent ad327b9b8d
commit 758f2a40c5
18 changed files with 562 additions and 425 deletions
@@ -17,7 +17,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
import {
Notification,
NotificationStatus,
NotificationType,
} from '@backstage/plugin-notifications-common';
/** @public */
@@ -27,16 +26,15 @@ export const notificationsApiRef = createApiRef<NotificationsApi>({
/** @public */
export type GetNotificationsOptions = {
type?: NotificationType;
offset?: number;
limit?: number;
search?: string;
read?: boolean;
};
/** @public */
export type UpdateNotificationsOptions = {
ids: string[];
done?: boolean;
read?: boolean;
saved?: boolean;
};
@@ -60,16 +60,16 @@ describe('NotificationsClient', () => {
server.use(
rest.get(`${mockBaseUrl}/`, (req, res, ctx) => {
expect(req.url.search).toBe(
'?type=undone&limit=10&offset=0&search=find+me',
'?limit=10&offset=0&search=find+me&read=true',
);
return res(ctx.json(expectedResp));
}),
);
const response = await client.getNotifications({
type: 'undone',
limit: 10,
offset: 0,
search: 'find me',
read: true,
});
expect(response).toEqual(expectedResp);
});
@@ -103,14 +103,12 @@ describe('NotificationsClient', () => {
rest.post(`${mockBaseUrl}/update`, async (req, res, ctx) => {
expect(await req.json()).toEqual({
ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'],
done: true,
});
return res(ctx.json(expectedResp));
}),
);
const response = await client.updateNotifications({
ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'],
done: true,
});
expect(response).toEqual(expectedResp);
});
@@ -42,9 +42,6 @@ export class NotificationsClient implements NotificationsApi {
options?: GetNotificationsOptions,
): Promise<Notification[]> {
const queryString = new URLSearchParams();
if (options?.type) {
queryString.append('type', options.type);
}
if (options?.limit !== undefined) {
queryString.append('limit', options.limit.toString(10));
}
@@ -54,6 +51,9 @@ export class NotificationsClient implements NotificationsApi {
if (options?.search) {
queryString.append('search', options.search);
}
if (options?.read !== undefined) {
queryString.append('read', options.read ? 'true' : 'false');
}
const urlSegment = `?${queryString}`;
@@ -0,0 +1,214 @@
/*
* 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 {
Divider,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Typography,
} from '@material-ui/core';
export type NotificationsFiltersProps = {
unreadOnly?: boolean;
onUnreadOnlyChanged: (checked: boolean | undefined) => void;
// createdAfter?: string;
// sorting?: {
// orderBy: GetNotificationsOrderByEnum;
// orderByDirec: GetNotificationsOrderByDirecEnum;
// };
// onCreatedAfterChanged: (value: string) => void;
// setSorting: ({
// orderBy,
// orderByDirec,
// }: {
// orderBy: GetNotificationsOrderByEnum;
// orderByDirec: GetNotificationsOrderByDirecEnum;
// }) => void;
};
// export const CreatedAfterOptions: {
// [key: string]: { label: string; getDate: () => Date };
// } = {
// last24h: {
// label: 'Last 24h',
// getDate: () => new Date(Date.now() - 24 * 3600 * 1000),
// },
// lastWeek: {
// label: 'Last week',
// getDate: () => new Date(Date.now() - 7 * 24 * 3600 * 1000),
// },
// all: {
// label: 'Any time',
// getDate: () => new Date(0),
// },
// };
// export const SortByOptions: {
// [key: string]: {
// label: string;
// orderBy: GetNotificationsOrderByEnum;
// orderByDirec: GetNotificationsOrderByDirecEnum;
// };
// } = {
// newest: {
// label: 'Newest on top',
// orderBy: GetNotificationsOrderByEnum.Created,
// orderByDirec: GetNotificationsOrderByDirecEnum.Asc,
// },
// oldest: {
// label: 'Oldest on top',
// orderBy: GetNotificationsOrderByEnum.Created,
// orderByDirec: GetNotificationsOrderByDirecEnum.Desc,
// },
// topic: {
// label: 'Topic',
// orderBy: GetNotificationsOrderByEnum.Topic,
// orderByDirec: GetNotificationsOrderByDirecEnum.Asc,
// },
// origin: {
// label: 'Origin',
// orderBy: GetNotificationsOrderByEnum.Origin,
// orderByDirec: GetNotificationsOrderByDirecEnum.Asc,
// },
// };
// TODO: Implement sorting on server (to work with pagination)
// const getSortBy = (sorting: NotificationsFiltersProps['sorting']): string => {
// if (
// sorting?.orderBy === GetNotificationsOrderByEnum.Created &&
// sorting.orderByDirec === GetNotificationsOrderByDirecEnum.Desc
// ) {
// return 'oldest';
// }
// if (sorting?.orderBy === GetNotificationsOrderByEnum.Topic) {
// return 'topic';
// }
// if (sorting?.orderBy === GetNotificationsOrderByEnum.Origin) {
// return 'origin';
// }
// return 'newest';
// };
export const NotificationsFilters = ({
unreadOnly,
// createdAfter,
// sorting,
// onCreatedAfterChanged,
onUnreadOnlyChanged,
}: // setSorting,
NotificationsFiltersProps) => {
// const sortBy = getSortBy(sorting);
// const handleOnCreatedAfterChanged = (
// event: React.ChangeEvent<{ name?: string; value: unknown }>,
// ) => {
// onCreatedAfterChanged(event.target.value as string);
// };
const handleOnUnreadOnlyChanged = (
event: React.ChangeEvent<{ name?: string; value: unknown }>,
) => {
let value = undefined;
if (event.target.value === 'unread') value = true;
if (event.target.value === 'read') value = false;
onUnreadOnlyChanged(value);
};
// const handleOnSortByChanged = (
// event: React.ChangeEvent<{ name?: string; value: unknown }>,
// ) => {
// const idx = (event.target.value as string) || 'newest';
// const option = SortByOptions[idx];
// setSorting({
// orderBy: option.orderBy,
// orderByDirec: option.orderByDirec,
// });
// };
let unreadOnlyValue = 'all';
if (unreadOnly) unreadOnlyValue = 'unread';
if (unreadOnly === false) unreadOnlyValue = 'read';
return (
<>
<Grid container>
<Grid item xs={12}>
<Typography variant="h6">Filters</Typography>
<Divider variant="fullWidth" />
</Grid>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-view">View</InputLabel>
<Select
labelId="notifications-filter-view"
label="View"
value={unreadOnlyValue}
onChange={handleOnUnreadOnlyChanged}
>
<MenuItem value="unread">New only</MenuItem>
<MenuItem value="read">Marked as read</MenuItem>
<MenuItem value="all">All</MenuItem>
</Select>
</FormControl>
</Grid>
{/* TODO: extend BE to support following:
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-view">
Created after
</InputLabel>
<Select
label="Created after"
placeholder="Notifications since"
value={createdAfter}
onChange={handleOnCreatedAfterChanged}
>
{Object.keys(CreatedAfterOptions).map((key: string) => (
<MenuItem value={key} key={key}>
{CreatedAfterOptions[key].label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-sort">Sort by</InputLabel>
<Select
label="Sort by"
placeholder="Field to sort by"
value={sortBy}
onChange={handleOnSortByChanged}
>
{Object.keys(SortByOptions).map((key: string) => (
<MenuItem value={key} key={key}>
{SortByOptions[key].label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid> */}
</Grid>
</>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './NotificationsFilters';
@@ -14,35 +14,35 @@
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import {
Content,
ErrorPanel,
PageWithHeader,
ResponseErrorPanel,
} from '@backstage/core-components';
import { NotificationsTable } from '../NotificationsTable';
import { useNotificationsApi } from '../../hooks';
import { Button, Grid, makeStyles } from '@material-ui/core';
import Bookmark from '@material-ui/icons/Bookmark';
import Check from '@material-ui/icons/Check';
import Inbox from '@material-ui/icons/Inbox';
import { NotificationType } from '@backstage/plugin-notifications-common';
import { Grid } from '@material-ui/core';
import { useSignal } from '@backstage/plugin-signals-react';
const useStyles = makeStyles(_theme => ({
filterButton: {
width: '100%',
justifyContent: 'start',
},
}));
import { NotificationsFilters } from '../NotificationsFilters';
import { GetNotificationsOptions } from '../../api';
export const NotificationsPage = () => {
const [type, setType] = useState<NotificationType>('undone');
const [refresh, setRefresh] = React.useState(false);
const { lastSignal } = useSignal('notifications');
const [unreadOnly, setUnreadOnly] = React.useState<boolean | undefined>(true);
const [containsText, setContainsText] = React.useState<string>();
const { error, value, retry } = useNotificationsApi(
api => api.getNotifications({ type }),
[type],
const { error, value, retry, loading } = useNotificationsApi(
// TODO: add pagination and other filters
api => {
const options: GetNotificationsOptions = { search: containsText };
if (unreadOnly !== undefined) {
options.read = !unreadOnly;
}
return api.getNotifications(options);
},
[containsText, unreadOnly],
);
useEffect(() => {
@@ -52,7 +52,6 @@ export const NotificationsPage = () => {
}
}, [refresh, setRefresh, retry]);
const { lastSignal } = useSignal('notifications');
useEffect(() => {
if (lastSignal && lastSignal.action) {
setRefresh(true);
@@ -63,9 +62,8 @@ export const NotificationsPage = () => {
setRefresh(true);
};
const styles = useStyles();
if (error) {
return <ErrorPanel error={new Error('Failed to load notifications')} />;
return <ResponseErrorPanel error={error} />;
}
return (
@@ -73,36 +71,21 @@ export const NotificationsPage = () => {
<Content>
<Grid container>
<Grid item xs={2}>
<Button
className={styles.filterButton}
startIcon={<Inbox />}
variant={type === 'undone' ? 'contained' : 'text'}
onClick={() => setType('undone')}
>
Inbox
</Button>
<Button
className={styles.filterButton}
startIcon={<Check />}
variant={type === 'done' ? 'contained' : 'text'}
onClick={() => setType('done')}
>
Done
</Button>
<Button
className={styles.filterButton}
startIcon={<Bookmark />}
variant={type === 'saved' ? 'contained' : 'text'}
onClick={() => setType('saved')}
>
Saved
</Button>
<NotificationsFilters
// createdAfter={createdAfter}
unreadOnly={unreadOnly}
onUnreadOnlyChanged={setUnreadOnly}
// onCreatedAfterChanged={setCreatedAfter}
// setSorting={setSorting}
// sorting={sorting}
/>
</Grid>
<Grid item xs={10}>
<NotificationsTable
isLoading={loading}
notifications={value}
type={type}
onUpdate={onUpdate}
setContainsText={setContainsText}
/>
</Grid>
</Grid>
@@ -13,296 +13,162 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
IconButton,
makeStyles,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
Typography,
} from '@material-ui/core';
import {
Notification,
NotificationType,
} from '@backstage/plugin-notifications-common';
import { useNavigate } from 'react-router-dom';
import Checkbox from '@material-ui/core/Checkbox';
import Check from '@material-ui/icons/Check';
import Bookmark from '@material-ui/icons/Bookmark';
import React, { useMemo } from 'react';
import throttle from 'lodash/throttle';
import { Box, IconButton, Tooltip, Typography } from '@material-ui/core';
import { Notification } from '@backstage/plugin-notifications-common';
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 MarkAsUnreadIcon from '@material-ui/icons/Markunread';
import MarkAsReadIcon from '@material-ui/icons/CheckCircle';
// @ts-ignore
import RelativeTime from 'react-relative-time';
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
import { Link, Table, TableColumn } from '@backstage/core-components';
const useStyles = makeStyles(theme => ({
table: {
border: `1px solid ${theme.palette.divider}`,
},
header: {
borderBottom: `1px solid ${theme.palette.divider}`,
},
notificationRow: {
cursor: 'pointer',
'&.unread': {
border: '1px solid rgba(255, 255, 255, .3)',
},
'& .hideOnHover': {
display: 'initial',
},
'& .showOnHover': {
display: 'none',
},
'&:hover': {
'& .hideOnHover': {
display: 'none',
},
'& .showOnHover': {
display: 'initial',
},
},
},
actionButton: {
padding: '9px',
},
checkBox: {
padding: '0 10px 10px 0',
},
}));
const ThrottleDelayMs = 1000;
/** @public */
export const NotificationsTable = (props: {
onUpdate: () => void;
type: NotificationType;
export type NotificationsTableProps = {
isLoading?: boolean;
notifications?: Notification[];
}) => {
const { notifications, type } = props;
const navigate = useNavigate();
const styles = useStyles();
const [selected, setSelected] = useState<string[]>([]);
onUpdate: () => void;
setContainsText: (search: string) => void;
};
/** @public */
export const NotificationsTable = ({
isLoading,
notifications = [],
onUpdate,
setContainsText,
}: NotificationsTableProps) => {
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]);
}
};
const onSwitchReadStatus = React.useCallback(
(notification: Notification) => {
notificationsApi
.updateNotifications({
ids: [notification.id],
read: !notification.read,
})
.then(() => onUpdate());
},
[notificationsApi, onUpdate],
);
useEffect(() => {
setSelected([]);
}, [type]);
const throttledContainsTextHandler = useMemo(
() => throttle(setContainsText, ThrottleDelayMs),
[setContainsText],
);
const isChecked = (id: string) => {
return selected.indexOf(id) !== -1;
};
const isAllSelected = () => {
return (
selected.length === notifications?.length && notifications.length > 0
);
};
return (
<Table size="small" className={styles.table}>
<TableHead>
<TableRow>
<TableCell colSpan={3}>
{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 === 'done' && selected.length > 0 && (
<Button
startIcon={<Inbox fontSize="small" />}
onClick={() => {
notificationsApi
.updateNotifications({ ids: selected, done: false })
.then(() => props.onUpdate());
setSelected([]);
}}
>
Move to inbox
</Button>
)}
{type === 'undone' && selected.length > 0 && (
<Button
startIcon={<Check fontSize="small" />}
onClick={() => {
notificationsApi
.updateNotifications({ ids: selected, done: true })
.then(() => props.onUpdate());
setSelected([]);
}}
>
Mark as done
</Button>
)}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.notifications?.map(notification => {
const compactColumns = React.useMemo(
(): TableColumn<Notification>[] => [
{
customFilterAndSearch: () =>
true /* Keep it on backend due to pagination. If recent flickering is an issue, implement search here as well. */,
render: (notification: Notification) => {
// Compact content
return (
<TableRow
key={notification.id}
className={`${styles.notificationRow} ${
!notification.read ? 'unread' : ''
}`}
hover
>
<TableCell
width="60px"
style={{ verticalAlign: 'center', paddingRight: '0px' }}
>
<Checkbox
className={styles.checkBox}
size="small"
checked={isChecked(notification.id)}
onClick={() => onCheckBoxClick(notification.id)}
/>
</TableCell>
<TableCell
onClick={() =>
notificationsApi
.updateNotifications({ ids: [notification.id], read: true })
.then(() => navigate(notification.payload.link))
}
style={{ paddingLeft: 0 }}
>
<>
<Box>
<Typography variant="subtitle2">
{notification.payload.title}
{notification.payload.link ? (
<Link to={notification.payload.link}>
{notification.payload.title}
</Link>
) : (
notification.payload.title
)}
</Typography>
<Typography variant="body2">
{notification.payload.description}
</Typography>
</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Box className="hideOnHover">
<RelativeTime value={notification.created} />
</Box>
<Box className="showOnHover">
<Tooltip title={notification.payload.link}>
<IconButton
className={styles.actionButton}
onClick={() =>
notificationsApi
.updateNotifications({
ids: [notification.id],
read: true,
})
.then(() => navigate(notification.payload.link))
}
>
<ArrowForwardIcon />
</IconButton>
</Tooltip>
<Tooltip
title={notification.read ? 'Move to inbox' : 'Mark as done'}
>
<IconButton
className={styles.actionButton}
onClick={() => {
if (notification.read) {
notificationsApi
.updateNotifications({
ids: [notification.id],
done: false,
})
.then(() => {
props.onUpdate();
});
} else {
notificationsApi
.updateNotifications({
ids: [notification.id],
done: true,
})
.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
.updateNotifications({
ids: [notification.id],
saved: false,
})
.then(() => {
props.onUpdate();
});
} else {
notificationsApi
.updateNotifications({
ids: [notification.id],
saved: true,
})
.then(() => {
props.onUpdate();
});
}
}}
>
{notification.saved ? (
<CloseIcon fontSize="small" />
) : (
<Bookmark fontSize="small" />
)}
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
<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>
</>
);
})}
</TableBody>
</Table>
},
},
// {
// // 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>
// )
// );
// },
// },
{
// TODO: action for saving notifications
// actions
width: '1rem',
render: (notification: Notification) => {
const markAsReadText = !!notification.read
? 'Return among unread'
: 'Mark as read';
const IconComponent = !!notification.read
? MarkAsUnreadIcon
: MarkAsReadIcon;
return (
<Tooltip title={markAsReadText}>
<IconButton
onClick={() => {
onSwitchReadStatus(notification);
}}
>
<IconComponent aria-label={markAsReadText} />
</IconButton>
</Tooltip>
);
},
},
],
[onSwitchReadStatus],
);
// TODO: render "Saved notifications" as "Pinned"
return (
<Table<Notification>
isLoading={isLoading}
options={{
search: true,
// TODO: add pagination
// paging: true,
// pageSize,
header: false,
sorting: false,
}}
// onPageChange={setPageNumber}
// onRowsPerPageChange={setPageSize}
// page={offset}
// totalCount={value?.totalCount}
onSearchChange={throttledContainsTextHandler}
data={notifications}
columns={compactColumns}
/>
);
};