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
+10 -6
View File
@@ -22,13 +22,17 @@ export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
// TODO: Remove this test code
let notifications = 0;
setInterval(() => {
env.notificationService.send(
'user:default/guest',
'Test',
'This is test notification',
'/catalog',
);
if (notifications < 10) {
env.notificationService.send({
entityRef: 'user:default/guest',
title: 'Test',
description: 'This is test notification',
link: '/catalog',
});
notifications++;
}
}, 60000);
return await createRouter({
@@ -70,7 +70,49 @@ export async function createRouter(
res.send(status);
});
// TODO: Add endpoint to set read/unread by notification id(s)
router.post('/read', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markRead({ user_ref: user, ids });
res.status(200).send({ ids });
});
router.post('/unread', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markUnread({ user_ref: user, ids });
res.status(200).send({ ids });
});
router.post('/save', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markSaved({ user_ref: user, ids });
res.status(200).send({ ids });
});
router.post('/unsave', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markUnsaved({ user_ref: user, ids });
res.status(200).send({ ids });
});
router.use(errorHandler());
return router;
@@ -11,11 +11,18 @@ type Notification_2 = {
description: string;
link: string;
icon?: string;
image?: string;
created: Date;
read?: Date;
saved: boolean;
};
export { Notification_2 as Notification };
// @public (undocumented)
export type NotificationIds = {
ids: string[];
};
// @public (undocumented)
export type NotificationStatus = {
unread: number;
@@ -26,8 +26,10 @@ export type Notification = {
link: string;
// TODO: Icon should be typed so that we know what to render
icon?: string;
image?: string;
created: Date;
read?: Date;
saved: boolean;
};
/** @public */
@@ -35,3 +37,8 @@ export type NotificationStatus = {
unread: number;
read: number;
};
/** @public */
export type NotificationIds = {
ids: string[];
};
+32 -6
View File
@@ -28,6 +28,14 @@ export class DatabaseNotificationsStore implements NotificationsStore {
read: number;
}>;
// (undocumented)
markRead(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markSaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnread(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnsaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
saveNotification(notification: Notification_2): Promise<void>;
}
@@ -37,6 +45,21 @@ export type NotificationGetOptions = {
type?: NotificationType;
};
// @public (undocumented)
export type NotificationModifyOptions = {
ids: string[];
} & NotificationGetOptions;
// @public (undocumented)
export type NotificationSendOptions = {
entityRef: string | string[];
title: string;
description: string;
link: string;
image?: string;
icon?: string;
};
// @public (undocumented)
export class NotificationService {
// (undocumented)
@@ -47,12 +70,7 @@ export class NotificationService {
// (undocumented)
getStore(): Promise<NotificationsStore>;
// (undocumented)
send(
entityRef: string | string[],
title: string,
description: string,
link: string,
): Promise<Notification_2[]>;
send(options: NotificationSendOptions): Promise<Notification_2[]>;
}
// @public (undocumented)
@@ -71,6 +89,14 @@ export interface NotificationsStore {
// (undocumented)
getStatus(options: NotificationGetOptions): Promise<NotificationStatus>;
// (undocumented)
markRead(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markSaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnread(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnsaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
saveNotification(notification: Notification_2): Promise<void>;
}
```
@@ -21,8 +21,11 @@ exports.up = async function up(knex) {
table.string('title').notNullable();
table.text('description').notNullable();
table.text('link').notNullable();
table.text('icon').nullable();
table.text('image').nullable();
table.datetime('created').notNullable();
table.datetime('read').nullable();
table.boolean('saved').defaultTo(false).notNullable();
});
};
@@ -19,6 +19,7 @@ import {
} from '@backstage/backend-common';
import {
NotificationGetOptions,
NotificationModifyOptions,
NotificationsStore,
} from './NotificationsStore';
import { Notification } from '@backstage/plugin-notifications-common';
@@ -55,7 +56,9 @@ export class DatabaseNotificationsStore implements NotificationsStore {
return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0;
};
private getNotificationsBaseQuery = (options: NotificationGetOptions) => {
private getNotificationsBaseQuery = (
options: NotificationGetOptions | NotificationModifyOptions,
) => {
const { user_ref, type } = options;
const query = this.db('notifications').where('userRef', user_ref);
@@ -63,8 +66,14 @@ export class DatabaseNotificationsStore implements NotificationsStore {
query.whereNull('read');
} else if (type === 'read') {
query.whereNotNull('read');
} else if (type === 'saved') {
query.where('saved', true);
}
// TODO: Saved
if ('ids' in options && options.ids) {
query.whereIn('id', options.ids);
}
return query;
};
@@ -96,4 +105,24 @@ export class DatabaseNotificationsStore implements NotificationsStore {
read: this.mapToInteger((readQuery as any)?.READ),
};
}
async markRead(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ read: new Date() });
}
async markUnread(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ read: null });
}
async markSaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: true });
}
async markUnsaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: false });
}
}
@@ -26,6 +26,11 @@ export type NotificationGetOptions = {
type?: NotificationType;
};
/** @public */
export type NotificationModifyOptions = {
ids: string[];
} & NotificationGetOptions;
/** @public */
export interface NotificationsStore {
getNotifications(options: NotificationGetOptions): Promise<Notification[]>;
@@ -34,5 +39,11 @@ export interface NotificationsStore {
getStatus(options: NotificationGetOptions): Promise<NotificationStatus>;
// TODO: Mark as read/unread by notification id(s)
markRead(options: NotificationModifyOptions): Promise<void>;
markUnread(options: NotificationModifyOptions): Promise<void>;
markSaved(options: NotificationModifyOptions): Promise<void>;
markUnsaved(options: NotificationModifyOptions): Promise<void>;
}
@@ -36,6 +36,16 @@ export type NotificationServiceOptions = {
discovery: PluginEndpointDiscovery;
};
/** @public */
export type NotificationSendOptions = {
entityRef: string | string[];
title: string;
description: string;
link: string;
image?: string;
icon?: string;
};
/** @public */
export class NotificationService {
private store: NotificationsStore | null = null;
@@ -56,12 +66,8 @@ export class NotificationService {
return new NotificationService(database, catalogClient);
}
async send(
entityRef: string | string[],
title: string,
description: string,
link: string,
): Promise<Notification[]> {
async send(options: NotificationSendOptions): Promise<Notification[]> {
const { entityRef, title, description, link, icon, image } = options;
const users = await this.getUsersForEntityRef(entityRef);
const notifications = [];
const store = await this.getStore();
@@ -73,6 +79,9 @@ export class NotificationService {
description,
link,
created: new Date(),
icon,
image,
saved: false,
};
await store.saveNotification(notification);
+20
View File
@@ -11,6 +11,7 @@ import { DiscoveryApi } from '@backstage/core-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
import { NotificationIds } from '@backstage/plugin-notifications-common';
import { NotificationStatus } from '@backstage/plugin-notifications-common';
import { NotificationType } from '@backstage/plugin-notifications-common';
import { default as React_2 } from 'react';
@@ -29,6 +30,14 @@ export interface NotificationsApi {
): Promise<Notification_2[]>;
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
markRead(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markSaved(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnread(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnsaved(ids: string[]): Promise<NotificationIds>;
}
// @public (undocumented)
@@ -43,6 +52,14 @@ export class NotificationsClient implements NotificationsApi {
): Promise<Notification_2[]>;
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
markRead(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markSaved(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnread(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnsaved(ids: string[]): Promise<NotificationIds>;
}
// @public (undocumented)
@@ -61,6 +78,9 @@ export const NotificationsSidebarItem: () => React_2.JSX.Element;
// @public (undocumented)
export const NotificationsTable: (props: {
onUpdate: () => void;
type: NotificationType;
loading?: boolean;
notifications?: Notification_2[];
}) => React_2.JSX.Element;
+1
View File
@@ -31,6 +31,7 @@
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
"react-relative-time": "^0.0.9",
"react-use": "^17.2.4"
},
"peerDependencies": {
@@ -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>
);
+10
View File
@@ -7845,6 +7845,7 @@ __metadata:
"@testing-library/react": ^12.1.3
"@testing-library/user-event": ^14.0.0
msw: ^1.0.0
react-relative-time: ^0.0.9
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
@@ -39162,6 +39163,15 @@ __metadata:
languageName: node
linkType: hard
"react-relative-time@npm:^0.0.9":
version: 0.0.9
resolution: "react-relative-time@npm:0.0.9"
peerDependencies:
react: ">=0.13.0"
checksum: 9c25887125df0eccfd4fee1edc4c99b19bef262ed5c66ac93269b63f0e1654a7c2758489d0d4b534847decc542dac23555896a7e2a0492af4c0ffbf48f1c62fe
languageName: node
linkType: hard
"react-remove-scroll-bar@npm:^2.3.3":
version: 2.3.4
resolution: "react-remove-scroll-bar@npm:2.3.4"