feat(notifications): implement severity of notifications

Signed-off-by: Marek Libra <marek.libra@gmail.com>
This commit is contained in:
Marek Libra
2024-03-03 09:15:37 +01:00
parent 739415b07c
commit dff7a7e9e2
13 changed files with 249 additions and 16 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-notifications-backend': patch
'@backstage/plugin-notifications': patch
---
all notifications can be marked and filtered by severity critical, high, normal or low, the default is 'normal'
@@ -0,0 +1,28 @@
/*
* 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.
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('notification', table => {
table.tinyint('severity').notNullable().alter();
// we do not need to migrate data since there are not real deployments so far
});
};
exports.down = async function down(knex) {
await knex.schema.alterTable('notification', table => {
table.string('severity').nullable().alter();
});
};
@@ -14,9 +14,15 @@
* limitations under the License.
*/
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { DatabaseNotificationsStore } from './DatabaseNotificationsStore';
import {
DatabaseNotificationsStore,
getNumericSeverity,
} from './DatabaseNotificationsStore';
import { Knex } from 'knex';
import { Notification } from '@backstage/plugin-notifications-common';
import {
Notification,
NotificationSeverity,
} from '@backstage/plugin-notifications-common';
jest.setTimeout(60_000);
@@ -48,6 +54,7 @@ const id4 = '04e0871e-e60a-4f68-8110-5ae3513f992e';
const id5 = '05e0871e-e60a-4f68-8110-5ae3513f992e';
const id6 = '06e0871e-e60a-4f68-8110-5ae3513f992e';
const id7 = '07e0871e-e60a-4f68-8110-5ae3513f992e';
const ids = [id1, id2, id3, id4, id5, id6, id7];
const now = Date.now();
const timeDelay = 5 * 1000; /* 5 secs */
@@ -266,6 +273,64 @@ describe.each(databases.eachSupportedId())(
});
});
describe('getNotifications filters on severity', () => {
beforeEach(async () => {
const severities: (NotificationSeverity | undefined)[] = [
'normal',
undefined,
'critical',
'high',
'low',
];
await Promise.all(
severities.map((severity, idx) =>
storage.saveNotification({
id: ids[idx],
user,
origin: 'test-origin',
created: new Date(now - idx * timeDelay),
payload: {
title: severity || 'default',
severity,
},
}),
),
);
});
it('normal', async () => {
const normal = await storage.getNotifications({
user,
minimalSeverity: getNumericSeverity('normal'),
});
expect(normal.map(idOnly)).toEqual([id1, id2, id3, id4]);
});
it('critical', async () => {
const critical = await storage.getNotifications({
user,
minimalSeverity: getNumericSeverity('critical'),
});
expect(critical.length).toBe(1);
expect(critical.at(0)?.id).toEqual(id3);
});
it('high', async () => {
const high = await storage.getNotifications({
user,
minimalSeverity: getNumericSeverity('high'),
});
expect(high.map(idOnly)).toEqual([id3, id4]);
});
it('low', async () => {
const low = await storage.getNotifications({
user,
minimalSeverity: getNumericSeverity('low'),
});
expect(low.map(idOnly)).toEqual([id1, id2, id3, id4, id5]);
});
});
describe('getNotifications pagination', () => {
beforeEach(async () => {
await storage.saveNotification(testNotification1);
@@ -439,13 +504,14 @@ describe.each(databases.eachSupportedId())(
payload: {
title: 'New notification',
link: '/scaffolder/task/1234',
severity: 'normal',
severity: 'low',
},
} as any,
});
expect(existing).not.toBeNull();
expect(existing?.id).toEqual(id2);
expect(existing?.payload.title).toEqual('New notification');
expect(existing?.payload.severity).toEqual('low');
expect(existing?.read).toBeNull();
});
});
@@ -22,7 +22,10 @@ import {
NotificationModifyOptions,
NotificationsStore,
} from './NotificationsStore';
import { Notification } from '@backstage/plugin-notifications-common';
import {
Notification,
NotificationSeverity,
} from '@backstage/plugin-notifications-common';
import { Knex } from 'knex';
const migrationsDir = resolvePackagePath(
@@ -30,6 +33,17 @@ const migrationsDir = resolvePackagePath(
'migrations',
);
const severities: NotificationSeverity[] = [
'critical',
'high',
'normal',
'low',
];
export const getNumericSeverity = (severity: string): Number => {
const idx = severities.indexOf(severity as NotificationSeverity);
return idx >= 0 ? idx : 2 /* normal */;
};
/** @internal */
export class DatabaseNotificationsStore implements NotificationsStore {
private constructor(private readonly db: Knex) {}
@@ -70,7 +84,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
description: row.description,
link: row.link,
topic: row.topic,
severity: row.severity,
severity: severities[row.severity],
scope: row.scope,
icon: row.icon,
},
@@ -87,7 +101,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
link: notification.payload?.link,
title: notification.payload?.title,
description: notification.payload?.description,
severity: notification.payload?.severity,
severity: getNumericSeverity(notification.payload?.severity ?? 'normal'),
scope: notification.payload?.scope,
saved: notification.saved,
read: notification.read,
@@ -155,6 +169,10 @@ export class DatabaseNotificationsStore implements NotificationsStore {
query.whereNull('notification.saved');
} // or match both if undefined
if (options.minimalSeverity !== undefined) {
query.where('notification.severity', '<=', options.minimalSeverity);
}
return query;
};
@@ -225,25 +243,28 @@ export class DatabaseNotificationsStore implements NotificationsStore {
return rows[0] as Notification;
}
async restoreExistingNotification(options: {
async restoreExistingNotification({
id,
notification,
}: {
id: string;
notification: Notification;
}) {
const query = this.db('notification')
.where('id', options.id)
.where('user', options.notification.user);
.where('id', id)
.where('user', notification.user);
await query.update({
title: options.notification.payload.title,
description: options.notification.payload.description,
link: options.notification.payload.link,
topic: options.notification.payload.topic,
title: notification.payload.title,
description: notification.payload.description,
link: notification.payload.link,
topic: notification.payload.topic,
updated: new Date(),
severity: options.notification.payload.severity,
severity: getNumericSeverity(notification.payload?.severity ?? 'normal'),
read: null,
});
return await this.getNotification(options);
return await this.getNotification({ id });
}
async getNotification(options: { id: string }): Promise<Notification | null> {
@@ -32,6 +32,7 @@ export type NotificationGetOptions = {
read?: boolean;
saved?: boolean;
createdAfter?: Date;
minimalSeverity?: Number;
};
/** @internal */
@@ -18,6 +18,7 @@ import express, { Request } from 'express';
import Router from 'express-promise-router';
import {
DatabaseNotificationsStore,
getNumericSeverity,
NotificationGetOptions,
} from '../database';
import { v4 as uuid } from 'uuid';
@@ -237,6 +238,11 @@ export async function createRouter(
}
opts.createdAfter = new Date(sinceEpoch);
}
if (req.query.minimal_severity) {
opts.minimalSeverity = getNumericSeverity(
req.query.minimal_severity.toString(),
);
}
const [notifications, totalCount] = await Promise.all([
store.getNotifications(opts),
+2
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 { NotificationSeverity } from '@backstage/plugin-notifications-common';
import { NotificationStatus } from '@backstage/plugin-notifications-common';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
@@ -26,6 +27,7 @@ export type GetNotificationsOptions = {
createdAfter?: Date;
sort?: 'created' | 'topic' | 'origin';
sortOrder?: 'asc' | 'desc';
minimalSeverity?: NotificationSeverity;
};
// @public (undocumented)
@@ -16,6 +16,7 @@
import { createApiRef } from '@backstage/core-plugin-api';
import {
Notification,
NotificationSeverity,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -34,6 +35,7 @@ export type GetNotificationsOptions = {
createdAfter?: Date;
sort?: 'created' | 'topic' | 'origin';
sortOrder?: 'asc' | 'desc';
minimalSeverity?: NotificationSeverity;
};
/** @public */
@@ -67,6 +67,9 @@ export class NotificationsClient implements NotificationsApi {
if (options?.createdAfter !== undefined) {
queryString.append('created_after', options.createdAfter.toISOString());
}
if (options?.minimalSeverity !== undefined) {
queryString.append('minimal_severity', options.minimalSeverity);
}
const urlSegment = `?${queryString}`;
return await this.request<GetNotificationsResponse>(urlSegment);
@@ -25,6 +25,7 @@ import {
Typography,
} from '@material-ui/core';
import { GetNotificationsOptions } from '../../api';
import { NotificationSeverity } from '@backstage/plugin-notifications-common';
export type SortBy = Required<
Pick<GetNotificationsOptions, 'sort' | 'sortOrder'>
@@ -39,6 +40,8 @@ export type NotificationsFiltersProps = {
onSortingChanged: (sortBy: SortBy) => void;
saved?: boolean;
onSavedChanged: (checked: boolean | undefined) => void;
severity: NotificationSeverity;
onSeverityChanged: (severity: NotificationSeverity) => void;
};
export const CreatedAfterOptions: {
@@ -108,6 +111,13 @@ const getSortByText = (sortBy?: SortBy): string => {
return 'newest';
};
const AllSeverityOptions: { [key in NotificationSeverity]: string } = {
critical: 'Critical',
high: 'High',
normal: 'Normal',
low: 'Low',
};
export const NotificationsFilters = ({
sorting,
onSortingChanged,
@@ -117,6 +127,8 @@ export const NotificationsFilters = ({
onCreatedAfterChanged,
saved,
onSavedChanged,
severity,
onSeverityChanged,
}: NotificationsFiltersProps) => {
const sortByText = getSortByText(sorting);
@@ -162,6 +174,14 @@ export const NotificationsFilters = ({
viewValue = 'read';
}
const handleOnSeverityChanged = (
event: React.ChangeEvent<{ name?: string; value: unknown }>,
) => {
const value: NotificationSeverity =
(event.target.value as NotificationSeverity) || 'normal';
onSeverityChanged(value);
};
return (
<>
<Grid container>
@@ -169,6 +189,7 @@ export const NotificationsFilters = ({
<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>
@@ -185,14 +206,16 @@ export const NotificationsFilters = ({
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-view">
<InputLabel id="notifications-filter-created">
Created after
</InputLabel>
<Select
label="Created after"
labelId="notifications-filter-created"
placeholder="Notifications since"
value={createdAfter}
onChange={handleOnCreatedAfterChanged}
@@ -212,6 +235,7 @@ export const NotificationsFilters = ({
<Select
label="Sort by"
labelId="notifications-filter-sort"
placeholder="Field to sort by"
value={sortByText}
onChange={handleOnSortByChanged}
@@ -224,6 +248,27 @@ export const NotificationsFilters = ({
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-severity">
Minimal severity
</InputLabel>
<Select
label="Minimal severity"
labelId="notifications-filter-severity"
value={severity}
onChange={handleOnSeverityChanged}
>
{Object.keys(AllSeverityOptions).map((key: string) => (
<MenuItem value={key} key={key}>
{AllSeverityOptions[key as NotificationSeverity]}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</>
);
@@ -32,6 +32,7 @@ import {
SortByOptions,
} from '../NotificationsFilters';
import { GetNotificationsOptions } from '../../api';
import { NotificationSeverity } from '@backstage/plugin-notifications-common';
export const NotificationsPage = () => {
const [refresh, setRefresh] = React.useState(false);
@@ -45,6 +46,8 @@ export const NotificationsPage = () => {
const [sorting, setSorting] = React.useState<SortBy>(
SortByOptions.newest.sortBy,
);
const [severity, setSeverity] =
React.useState<NotificationSeverity>('normal');
const { error, value, retry, loading } = useNotificationsApi(
api => {
@@ -52,6 +55,7 @@ export const NotificationsPage = () => {
search: containsText,
limit: pageSize,
offset: pageNumber * pageSize,
minimalSeverity: severity,
...(sorting || {}),
};
if (unreadOnly !== undefined) {
@@ -76,6 +80,7 @@ export const NotificationsPage = () => {
pageSize,
sorting,
saved,
severity,
],
);
@@ -114,6 +119,8 @@ export const NotificationsPage = () => {
sorting={sorting}
saved={saved}
onSavedChanged={setSaved}
severity={severity}
onSeverityChanged={setSeverity}
/>
</Grid>
<Grid item xs={10}>
@@ -33,6 +33,7 @@ import MarkAsUnreadIcon from '@material-ui/icons/Markunread' /* TODO: use Drafts
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';
const ThrottleDelayMs = 1000;
@@ -93,6 +94,12 @@ export const NotificationsTable = ({
const compactColumns = React.useMemo(
(): TableColumn<Notification>[] => [
{
width: '1rem',
render: (notification: Notification) => (
<SeverityIcon severity={notification.payload?.severity} />
),
},
{
customFilterAndSearch: () =>
true /* Keep it on backend due to pagination. If recent flickering is an issue, implement search here as well. */,
@@ -0,0 +1,39 @@
/*
* 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 { NotificationSeverity } from '@backstage/plugin-notifications-common';
import NormalIcon from '@material-ui/icons/CheckOutlined';
import CriticalIcon from '@material-ui/icons/ErrorOutline';
import HighIcon from '@material-ui/icons/WarningOutlined';
import LowIcon from '@material-ui/icons/InfoOutlined';
export const SeverityIcon = ({
severity,
}: {
severity?: NotificationSeverity;
}) => {
switch (severity) {
case 'critical':
return <CriticalIcon htmlColor="#C9190B" />;
case 'high':
return <HighIcon htmlColor="#F0AB00" />;
case 'low':
return <LowIcon htmlColor="#2B9AF3" />;
case 'normal':
default:
return <NormalIcon htmlColor="#5BA352" />;
}
};