Merge pull request #21889 from drodil/notifications

Notifications initial implementation
This commit is contained in:
Patrik Oldsberg
2024-02-06 13:13:22 +01:00
committed by GitHub
72 changed files with 3876 additions and 9 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-notifications-backend': patch
'@backstage/plugin-notifications-common': patch
'@backstage/plugin-notifications-node': patch
'@backstage/plugin-notifications': patch
---
Initial notifications system for backstage
+1
View File
@@ -59,6 +59,7 @@
"@backstage/plugin-newrelic": "workspace:^",
"@backstage/plugin-newrelic-dashboard": "workspace:^",
"@backstage/plugin-nomad": "workspace:^",
"@backstage/plugin-notifications": "workspace:^",
"@backstage/plugin-octopus-deploy": "workspace:^",
"@backstage/plugin-org": "workspace:^",
"@backstage/plugin-pagerduty": "workspace:^",
+4 -2
View File
@@ -67,15 +67,15 @@ import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import {
TechDocsIndexPage,
TechDocsReaderPage,
techdocsPlugin,
TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
import {
ExpandableNavigation,
LightBox,
ReportIssue,
TextSize,
LightBox,
} from '@backstage/plugin-techdocs-module-addons-contrib';
import {
SettingsLayout,
@@ -107,6 +107,7 @@ import { PuppetDbPage } from '@backstage/plugin-puppetdb';
import { DevToolsPage } from '@backstage/plugin-devtools';
import { customDevToolsPage } from './components/devtools/CustomDevToolsPage';
import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities';
import { NotificationsPage } from '@backstage/plugin-notifications';
const app = createApp({
apis,
@@ -272,6 +273,7 @@ const routes = (
<Route path="/devtools" element={<DevToolsPage />}>
{customDevToolsPage}
</Route>
<Route path="/notifications" element={<NotificationsPage />} />
</FlatRoutes>
);
+5 -2
View File
@@ -34,6 +34,7 @@ import {
import { SidebarSearchModal } from '@backstage/plugin-search';
import { Shortcuts } from '@backstage/plugin-shortcuts';
import {
Link,
Sidebar,
sidebarConfig,
SidebarDivider,
@@ -42,16 +43,16 @@ import {
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
Link,
useSidebarOpenState,
SidebarSubmenu,
SidebarSubmenuItem,
useSidebarOpenState,
} from '@backstage/core-components';
import { MyGroupsSidebarItem } from '@backstage/plugin-org';
import { SearchModal } from '../search/SearchModal';
import Score from '@material-ui/icons/Score';
import { useApp } from '@backstage/core-plugin-api';
import BuildIcon from '@material-ui/icons/Build';
import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -166,6 +167,8 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
</SidebarScrollWrapper>
<SidebarDivider />
<Shortcuts allowExternalLinks />
<SidebarDivider />
<NotificationsSidebarItem />
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
+2
View File
@@ -45,6 +45,7 @@
"@backstage/plugin-lighthouse-backend": "workspace:^",
"@backstage/plugin-linguist-backend": "workspace:^",
"@backstage/plugin-nomad-backend": "workspace:^",
"@backstage/plugin-notifications-backend": "workspace:^",
"@backstage/plugin-permission-backend": "workspace:^",
"@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -57,6 +58,7 @@
"@backstage/plugin-search-backend-module-explore": "workspace:^",
"@backstage/plugin-search-backend-module-techdocs": "workspace:^",
"@backstage/plugin-search-backend-node": "workspace:^",
"@backstage/plugin-signals-backend": "workspace:^",
"@backstage/plugin-sonarqube-backend": "workspace:^",
"@backstage/plugin-techdocs-backend": "workspace:^",
"@backstage/plugin-todo-backend": "workspace:^"
+2
View File
@@ -51,5 +51,7 @@ backend.add(import('@backstage/plugin-search-backend/alpha'));
backend.add(import('@backstage/plugin-techdocs-backend/alpha'));
backend.add(import('@backstage/plugin-todo-backend'));
backend.add(import('@backstage/plugin-sonarqube-backend'));
backend.add(import('@backstage/plugin-signals-backend'));
backend.add(import('@backstage/plugin-notifications-backend'));
backend.start();
+2 -2
View File
@@ -27,7 +27,7 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { EventBroker } from '@backstage/plugin-events-node';
import { DefaultSignalService } from '@backstage/plugin-signals-node';
import { SignalService } from '@backstage/plugin-signals-node';
export type PluginEnvironment = {
logger: Logger;
@@ -41,5 +41,5 @@ export type PluginEnvironment = {
scheduler: PluginTaskScheduler;
identity: IdentityApi;
eventBroker: EventBroker;
signalService: DefaultSignalService;
signalService: SignalService;
};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+77
View File
@@ -0,0 +1,77 @@
# notifications
Welcome to the notifications backend plugin!
## Getting started
Add the notifications to your backend:
```ts
const backend = createBackend();
// ...
backend.add(import('@backstage/plugin-notifications-backend'));
```
For users to be able to see notifications in real-time, you have to install also
the signals plugin (`@backstage/plugin-signals-node`, `@backstage/plugin-signals-backend`, and
`@backstage/plugin-signals`).
## Extending Notifications
The notifications can be extended with `NotificationProcessor`. These processors allow to decorate notifications
before they are sent or/and send the notifications to external services.
Start off by creating a notification processor:
```ts
import { Notification } from '@backstage/plugin-notifications-common';
import { NotificationProcessor } from '@backstage/plugin-notifications-node';
class MyNotificationProcessor implements NotificationProcessor {
async decorate(notification: Notification): Promise<Notification> {
if (notification.origin === 'plugin-my-plugin') {
notification.payload.icon = 'my-icon';
}
return notification;
}
async send(notification: Notification): Promise<void> {
nodemailer.sendEmail({
from: 'backstage',
to: 'user',
subject: notification.payload.title,
text: notification.payload.description,
});
}
}
```
Both of the processing functions are optional, and you can implement only one of them.
Add the notification processor to the notification system by:
```ts
import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node';
import { Notification } from '@backstage/plugin-notifications-common';
export const myPlugin = createBackendPlugin({
pluginId: 'myPlugin',
register(env) {
env.registerInit({
deps: {
notifications: notificationsProcessingExtensionPoint,
// ...
},
async init({ notifications }) {
// ...
notifications.addProcessor(new MyNotificationProcessor());
},
});
},
});
```
## Sending notifications
To be able to send notifications to users, you have to integrate the `@backstage/plugin-notifications-node`
to your application and plugins.
@@ -0,0 +1,13 @@
## API Report File for "@backstage/plugin-notifications-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
// @public
const notificationsPlugin: () => BackendFeature;
export default notificationsPlugin;
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-notifications-backend
title: '@backstage/plugin-notifications-backend'
spec:
lifecycle: experimental
type: backstage-backend-plugin
owner: maintainers
@@ -0,0 +1,44 @@
/*
* Copyright 2023 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.createTable('notification', table => {
table.uuid('id').primary();
table.string('user', 255).notNullable();
table.string('title').notNullable();
table.text('description').nullable();
table.string('severity', 8).notNullable();
table.text('link').notNullable();
table.string('origin', 255).notNullable();
table.string('scope', 255).nullable();
table.string('topic', 255).nullable();
table.datetime('created').defaultTo(knex.fn.now()).notNullable();
table.datetime('updated').nullable();
table.datetime('read').nullable();
table.datetime('done').nullable();
table.datetime('saved').nullable();
table.index(['user'], 'notification_user_idx');
table.index(['scope', 'origin'], 'notification_scope_origin_idx');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.dropTable('notification');
};
@@ -0,0 +1,55 @@
{
"name": "@backstage/plugin-notifications-backend",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-notifications-common": "workspace:^",
"@backstage/plugin-notifications-node": "workspace:^",
"@backstage/plugin-signals-node": "workspace:^",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"knex": "^3.0.0",
"node-fetch": "^2.6.7",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/express": "^4.17.6",
"@types/supertest": "^2.0.8",
"msw": "^1.0.0",
"supertest": "^6.2.4"
},
"files": [
"dist"
]
}
@@ -0,0 +1,338 @@
/*
* 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { DatabaseNotificationsStore } from './DatabaseNotificationsStore';
import { Knex } from 'knex';
import { v4 as uuid } from 'uuid';
import { Notification } from '@backstage/plugin-notifications-common';
jest.setTimeout(60_000);
const databases = TestDatabases.create();
async function createStore(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
const mgr = {
getClient: async () => knex,
migrations: {
skip: false,
},
};
return {
knex,
storage: await DatabaseNotificationsStore.create({ database: mgr }),
};
}
const user = 'user:default/john.doe';
const testNotification: Partial<Notification> = {
user,
created: new Date(),
origin: 'plugin-test',
payload: {
title: 'Notification 1',
link: '/catalog',
severity: 'normal',
},
};
const otherUserNotification: Partial<Notification> = {
...testNotification,
user: 'user:default/jane.doe',
};
describe.each(databases.eachSupportedId())(
'DatabaseNotificationsStore (%s)',
databaseId => {
let storage: DatabaseNotificationsStore;
let knex: Knex;
const insertNotification = async (
notification: Partial<Notification> & {
id: string;
done?: Date;
saved?: Date;
read?: Date;
},
) =>
(
await knex('notification')
.insert({
id: notification.id,
user: notification.user,
origin: notification.origin,
created: notification.created,
link: notification.payload?.link,
title: notification.payload?.title,
severity: notification.payload?.severity,
scope: notification.payload?.scope,
done: notification.done,
saved: notification.saved,
read: notification.read,
})
.returning('id')
)[0].id ?? -1;
beforeAll(async () => {
({ storage, knex } = await createStore(databaseId));
});
afterEach(async () => {
jest.resetAllMocks();
await knex('notification').del();
});
describe('getNotifications', () => {
it('should return all notifications for user', async () => {
const id1 = uuid();
const id2 = uuid();
await insertNotification({ id: id1, ...testNotification });
await insertNotification({ id: id2, ...testNotification });
await insertNotification({ id: uuid(), ...otherUserNotification });
const notifications = await storage.getNotifications({ user });
expect(notifications.length).toBe(2);
});
it('should return undone notifications for user', async () => {
const id1 = uuid();
const id2 = uuid();
await insertNotification({
id: id1,
...testNotification,
done: new Date(),
});
await insertNotification({ id: id2, ...testNotification });
await insertNotification({ id: uuid(), ...otherUserNotification });
const notifications = await storage.getNotifications({
user,
type: 'undone',
});
expect(notifications.length).toBe(1);
expect(notifications.at(0)?.id).toEqual(id2);
});
it('should return done notifications for user', async () => {
const id1 = uuid();
const id2 = uuid();
await insertNotification({
id: id1,
...testNotification,
done: new Date(),
});
await insertNotification({ id: id2, ...testNotification });
await insertNotification({ id: uuid(), ...otherUserNotification });
const notifications = await storage.getNotifications({
user,
type: 'done',
});
expect(notifications.length).toBe(1);
expect(notifications.at(0)?.id).toEqual(id1);
});
it('should allow searching for notifications', async () => {
const id1 = uuid();
const id2 = uuid();
await insertNotification({
id: id1,
...testNotification,
payload: {
link: '/catalog',
severity: 'normal',
title: 'Please find me',
},
});
await insertNotification({ id: id2, ...testNotification });
await insertNotification({ id: uuid(), ...otherUserNotification });
const notifications = await storage.getNotifications({
user,
search: 'find me',
});
expect(notifications.length).toBe(1);
expect(notifications.at(0)?.id).toEqual(id1);
});
});
describe('getStatus', () => {
it('should return status for user', async () => {
const id1 = uuid();
const id2 = uuid();
await insertNotification({
id: id1,
...testNotification,
read: new Date(),
});
await insertNotification({ id: id2, ...testNotification });
await insertNotification({ id: uuid(), ...otherUserNotification });
const status = await storage.getStatus({ user });
expect(status.read).toEqual(1);
expect(status.unread).toEqual(1);
});
});
describe('getExistingScopeNotification', () => {
it('should return existing scope notification', async () => {
const id1 = uuid();
const notification: any = {
...testNotification,
id: id1,
payload: {
title: 'Notification',
link: '/scaffolder/task/1234',
severity: 'normal',
scope: 'scaffolder-1234',
},
};
await insertNotification(notification);
const existing = await storage.getExistingScopeNotification({
user,
origin: 'plugin-test',
scope: 'scaffolder-1234',
});
expect(existing).not.toBeNull();
expect(existing?.id).toEqual(id1);
});
});
describe('restoreExistingNotification', () => {
it('should return restore existing scope notification', async () => {
const id1 = uuid();
const notification: any = {
...testNotification,
id: id1,
read: new Date(),
done: new Date(),
payload: {
title: 'Notification',
link: '/scaffolder/task/1234',
severity: 'normal',
scope: 'scaffolder-1234',
},
};
await insertNotification(notification);
const existing = await storage.restoreExistingNotification({
id: id1,
notification: {
user: notification.user,
payload: {
title: 'New notification',
link: '/scaffolder/task/1234',
severity: 'normal',
},
} as any,
});
expect(existing).not.toBeNull();
expect(existing?.id).toEqual(id1);
expect(existing?.payload.title).toEqual('New notification');
expect(existing?.done).toBeNull();
expect(existing?.read).toBeNull();
});
});
describe('getNotification', () => {
it('should return notification by id', async () => {
const id1 = uuid();
await insertNotification({ id: id1, ...testNotification });
const notification = await storage.getNotification({ id: id1 });
expect(notification?.id).toEqual(id1);
});
});
describe('markRead', () => {
it('should mark notification read', async () => {
const id1 = uuid();
await insertNotification({ id: id1, ...testNotification });
await storage.markRead({ ids: [id1], user });
const notification = await storage.getNotification({ id: id1 });
expect(notification?.read).not.toBeNull();
});
});
describe('markUnread', () => {
it('should mark notification unread', async () => {
const id1 = uuid();
await insertNotification({
id: id1,
...testNotification,
read: new Date(),
});
await storage.markUnread({ ids: [id1], user });
const notification = await storage.getNotification({ id: id1 });
expect(notification?.read).toBeNull();
});
});
describe('markDone', () => {
it('should mark notification done', async () => {
const id1 = uuid();
await insertNotification({ id: id1, ...testNotification });
await storage.markDone({ ids: [id1], user });
const notification = await storage.getNotification({ id: id1 });
expect(notification?.done).not.toBeNull();
});
});
describe('markUndone', () => {
it('should mark notification undone', async () => {
const id1 = uuid();
await insertNotification({
id: id1,
...testNotification,
done: new Date(),
});
await storage.markUndone({ ids: [id1], user });
const notification = await storage.getNotification({ id: id1 });
expect(notification?.done).toBeNull();
});
});
describe('markSaved', () => {
it('should mark notification saved', async () => {
const id1 = uuid();
await insertNotification({ id: id1, ...testNotification });
await storage.markSaved({ ids: [id1], user });
const notification = await storage.getNotification({ id: id1 });
expect(notification?.saved).not.toBeNull();
});
});
describe('markUnsaved', () => {
it('should mark notification not saved', async () => {
const id1 = uuid();
await insertNotification({
id: id1,
...testNotification,
saved: new Date(),
});
await storage.markUnsaved({ ids: [id1], user });
const notification = await storage.getNotification({ id: id1 });
expect(notification?.saved).toBeNull();
});
});
},
);
@@ -0,0 +1,240 @@
/*
* Copyright 2023 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 {
PluginDatabaseManager,
resolvePackagePath,
} from '@backstage/backend-common';
import {
NotificationGetOptions,
NotificationModifyOptions,
NotificationsStore,
} from './NotificationsStore';
import { Notification } from '@backstage/plugin-notifications-common';
import { Knex } from 'knex';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-notifications-backend',
'migrations',
);
/** @internal */
export class DatabaseNotificationsStore implements NotificationsStore {
private constructor(private readonly db: Knex) {}
static async create({
database,
skipMigrations,
}: {
database: PluginDatabaseManager;
skipMigrations?: boolean;
}): Promise<DatabaseNotificationsStore> {
const client = await database.getClient();
if (!database.migrations?.skip && !skipMigrations) {
await client.migrate.latest({
directory: migrationsDir,
});
}
return new DatabaseNotificationsStore(client);
}
private mapToInteger = (val: string | number | undefined): number => {
return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0;
};
private mapToNotifications = (rows: any[]): Notification[] => {
return rows.map(row => ({
id: row.id,
user: row.user,
created: row.created,
done: row.done,
saved: row.saved,
read: row.read,
updated: row.updated,
origin: row.origin,
payload: {
title: row.title,
description: row.description,
link: row.link,
topic: row.topic,
severity: row.severity,
scope: row.scope,
icon: row.icon,
},
}));
};
private getNotificationsBaseQuery = (
options: NotificationGetOptions | NotificationModifyOptions,
) => {
const { user, type } = options;
const query = this.db('notification').where('user', user);
if (options.sort !== undefined && options.sort !== null) {
query.orderBy(options.sort, options.sortOrder ?? 'desc');
} else if (options.sort !== null) {
query.orderBy('created', options.sortOrder ?? 'desc');
}
if (type === 'undone') {
query.whereNull('done');
} else if (type === 'done') {
query.whereNotNull('done');
} else if (type === 'saved') {
query.whereNotNull('saved');
}
if (options.limit) {
query.limit(options.limit);
}
if (options.offset) {
query.offset(options.offset);
}
if (options.search) {
query.whereRaw(
`(LOWER(notification.title) LIKE LOWER(?) OR LOWER(notification.description) LIKE LOWER(?))`,
[`%${options.search}%`, `%${options.search}%`],
);
}
if (options.ids) {
query.whereIn('notification.id', options.ids);
}
return query;
};
async getNotifications(options: NotificationGetOptions) {
const notificationQuery = this.getNotificationsBaseQuery(options);
const notifications = await notificationQuery.select();
return this.mapToNotifications(notifications);
}
async saveNotification(notification: Notification) {
await this.db.insert(notification).into('notification');
}
async getStatus(options: NotificationGetOptions) {
const notificationQuery = this.getNotificationsBaseQuery({
...options,
sort: null,
});
const readSubQuery = notificationQuery
.clone()
.count('id')
.whereNotNull('read')
.as('READ');
const unreadSubQuery = notificationQuery
.clone()
.count('id')
.whereNull('read')
.as('UNREAD');
const query = await notificationQuery
.select(readSubQuery, unreadSubQuery)
.first();
return {
unread: this.mapToInteger((query as any)?.UNREAD),
read: this.mapToInteger((query as any)?.READ),
};
}
async getExistingScopeNotification(options: {
user: string;
scope: string;
origin: string;
}) {
const query = this.db('notification')
.where('user', options.user)
.where('scope', options.scope)
.where('origin', options.origin)
.select()
.limit(1);
const rows = await query;
if (!rows || rows.length === 0) {
return null;
}
return rows[0] as Notification;
}
async restoreExistingNotification(options: {
id: string;
notification: Notification;
}) {
const query = this.db('notification')
.where('id', options.id)
.where('user', options.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,
updated: options.notification.created,
severity: options.notification.payload.severity,
read: null,
done: null,
});
return await this.getNotification(options);
}
async getNotification(options: { id: string }): Promise<Notification | null> {
const rows = await this.db('notification')
.where('id', options.id)
.select()
.limit(1);
if (!rows || rows.length === 0) {
return null;
}
return this.mapToNotifications(rows)[0];
}
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 markDone(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ done: new Date(), read: new Date() });
}
async markUndone(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ done: null, read: null });
}
async markSaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: new Date() });
}
async markUnsaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: null });
}
}
@@ -0,0 +1,72 @@
/*
* Copyright 2023 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 {
Notification,
NotificationStatus,
NotificationType,
} from '@backstage/plugin-notifications-common';
/** @internal */
export type NotificationGetOptions = {
user: string;
ids?: string[];
type?: NotificationType;
offset?: number;
limit?: number;
search?: string;
sort?: 'created' | 'read' | 'updated' | null;
sortOrder?: 'asc' | 'desc';
};
/** @internal */
export type NotificationModifyOptions = {
ids: string[];
} & NotificationGetOptions;
/** @internal */
export interface NotificationsStore {
getNotifications(options: NotificationGetOptions): Promise<Notification[]>;
saveNotification(notification: Notification): Promise<void>;
getExistingScopeNotification(options: {
user: string;
scope: string;
origin: string;
}): Promise<Notification | null>;
restoreExistingNotification(options: {
id: string;
notification: Notification;
}): Promise<Notification | null>;
getNotification(options: { id: string }): Promise<Notification | null>;
getStatus(options: NotificationGetOptions): Promise<NotificationStatus>;
markRead(options: NotificationModifyOptions): Promise<void>;
markUnread(options: NotificationModifyOptions): Promise<void>;
markDone(options: NotificationModifyOptions): Promise<void>;
markUndone(options: NotificationModifyOptions): Promise<void>;
markSaved(options: NotificationModifyOptions): Promise<void>;
markUnsaved(options: NotificationModifyOptions): Promise<void>;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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 './DatabaseNotificationsStore';
export * from './NotificationsStore';
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 { notificationsPlugin as default } from './plugin';
@@ -0,0 +1,92 @@
/*
* Copyright 2023 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 {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
import { signalService } from '@backstage/plugin-signals-node';
import {
NotificationProcessor,
notificationsProcessingExtensionPoint,
NotificationsProcessingExtensionPoint,
} from '@backstage/plugin-notifications-node';
class NotificationsProcessingExtensionPointImpl
implements NotificationsProcessingExtensionPoint
{
#processors = new Array<NotificationProcessor>();
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
): void {
this.#processors.push(...processors.flat());
}
get processors() {
return this.#processors;
}
}
/**
* Notifications backend plugin
*
* @public
*/
export const notificationsPlugin = createBackendPlugin({
pluginId: 'notifications',
register(env) {
const processingExtensions =
new NotificationsProcessingExtensionPointImpl();
env.registerExtensionPoint(
notificationsProcessingExtensionPoint,
processingExtensions,
);
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
identity: coreServices.identity,
database: coreServices.database,
tokenManager: coreServices.tokenManager,
discovery: coreServices.discovery,
signals: signalService,
},
async init({
httpRouter,
logger,
identity,
database,
tokenManager,
discovery,
signals,
}) {
httpRouter.use(
await createRouter({
logger,
identity,
database,
tokenManager,
discovery,
signalService: signals,
processors: processingExtensions.processors,
}),
);
},
});
},
});
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright 2023 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,97 @@
/*
* Copyright 2023 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 {
DatabaseManager,
getVoidLogger,
PluginDatabaseManager,
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { ConfigReader } from '@backstage/config';
import { SignalService } from '@backstage/plugin-signals-node';
function createDatabase(): PluginDatabaseManager {
return DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
}),
).forPlugin('notifications');
}
describe('createRouter', () => {
let app: express.Express;
const identityMock: IdentityApi = {
async getIdentity() {
return {
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'user:default/guest',
},
token: 'no-token',
};
},
};
const mockedTokenManager: jest.Mocked<TokenManager> = {
getToken: jest.fn(),
authenticate: jest.fn(),
};
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
const signalService: jest.Mocked<SignalService> = {
publish: jest.fn(),
};
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
identity: identityMock,
database: createDatabase(),
tokenManager: mockedTokenManager,
discovery,
signalService,
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
@@ -0,0 +1,365 @@
/*
* Copyright 2023 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 {
errorHandler,
PluginDatabaseManager,
TokenManager,
} from '@backstage/backend-common';
import express, { Request } from 'express';
import Router from 'express-promise-router';
import {
getBearerTokenFromAuthorizationHeader,
IdentityApi,
} from '@backstage/plugin-auth-node';
import {
DatabaseNotificationsStore,
NotificationGetOptions,
} from '../database';
import { v4 as uuid } from 'uuid';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import {
Entity,
isGroupEntity,
isUserEntity,
RELATION_HAS_MEMBER,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { NotificationProcessor } from '@backstage/plugin-notifications-node';
import { AuthenticationError, InputError } from '@backstage/errors';
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
import { SignalService } from '@backstage/plugin-signals-node';
import {
Notification,
NotificationType,
} from '@backstage/plugin-notifications-common';
/** @internal */
export interface RouterOptions {
logger: LoggerService;
identity: IdentityApi;
database: PluginDatabaseManager;
tokenManager: TokenManager;
discovery: DiscoveryService;
signalService?: SignalService;
catalog?: CatalogApi;
processors?: NotificationProcessor[];
}
/** @internal */
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const {
logger,
database,
identity,
discovery,
catalog,
tokenManager,
processors,
signalService,
} = options;
const catalogClient =
catalog ?? new CatalogClient({ discoveryApi: discovery });
const store = await DatabaseNotificationsStore.create({ database });
const getUser = async (req: Request<unknown>) => {
const user = await identity.getIdentity({ request: req });
if (!user) {
throw new AuthenticationError();
}
return user.identity.userEntityRef;
};
const authenticateService = async (req: Request<unknown>) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
if (!token) {
throw new AuthenticationError();
}
await tokenManager.authenticate(token);
};
const getUsersForEntityRef = async (
entityRef: string | string[] | null,
): Promise<string[]> => {
const { token } = await tokenManager.getToken();
// TODO: Support for broadcast
if (entityRef === null) {
return [];
}
const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
const entities = await catalogClient.getEntitiesByRefs(
{
entityRefs: refs,
fields: ['kind', 'metadata.name', 'metadata.namespace'],
},
{ token },
);
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
if (!entity) {
return [];
}
if (isUserEntity(entity)) {
return [stringifyEntityRef(entity)];
} else if (isGroupEntity(entity) && entity.relations) {
const users = entity.relations
.filter(
relation =>
relation.type === RELATION_HAS_MEMBER && relation.targetRef,
)
.map(r => r.targetRef);
const childGroups = await catalogClient.getEntitiesByRefs(
{
entityRefs: entity.spec.children,
fields: ['kind', 'metadata.name', 'metadata.namespace'],
},
{ token },
);
const childGroupUsers = await Promise.all(
childGroups.items.map(mapEntity),
);
return [...users, ...childGroupUsers.flat(2)];
} else if (!isGroupEntity(entity) && entity.spec?.owner) {
const owner = await catalogClient.getEntityByRef(
entity.spec.owner as string,
{ token },
);
if (owner) {
return mapEntity(owner);
}
}
return [];
};
const users: string[] = [];
for (const entity of entities.items) {
const u = await mapEntity(entity);
users.push(...u);
}
return users;
};
const decorateNotification = async (notification: Notification) => {
let ret: Notification = notification;
for (const processor of processors ?? []) {
ret = processor.decorate ? await processor.decorate(ret) : ret;
}
return ret;
};
const processorSendNotification = async (notification: Notification) => {
for (const processor of processors ?? []) {
if (processor.send) {
processor.send(notification);
}
}
};
// TODO: Move to use OpenAPI router instead
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
logger.info('PONG!');
response.json({ status: 'ok' });
});
router.get('/', async (req, res) => {
const user = await getUser(req);
const opts: NotificationGetOptions = {
user: user,
};
if (req.query.type) {
opts.type = req.query.type.toString() as NotificationType;
}
if (req.query.offset) {
opts.offset = Number.parseInt(req.query.offset.toString(), 10);
}
if (req.query.limit) {
opts.limit = Number.parseInt(req.query.limit.toString(), 10);
}
if (req.query.search) {
opts.search = req.query.search.toString();
}
const notifications = await store.getNotifications(opts);
res.send(notifications);
});
router.get('/status', async (req, res) => {
const user = await getUser(req);
const status = await store.getStatus({ user, type: 'undone' });
res.send(status);
});
router.post('/update', async (req, res) => {
const user = await getUser(req);
const { ids, done, read, saved } = req.body;
if (!ids || !Array.isArray(ids)) {
throw new InputError();
}
if (done === true) {
await store.markDone({ user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'done', notification_ids: ids },
channel: 'notifications',
});
}
} else if (done === false) {
await store.markUndone({ user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'undone', notification_ids: ids },
channel: 'notifications',
});
}
}
if (read === true) {
await store.markRead({ user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'mark_read', notification_ids: ids },
channel: 'notifications',
});
}
} else if (read === false) {
await store.markUnread({ user: user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'mark_unread', notification_ids: ids },
channel: 'notifications',
});
}
}
if (saved === true) {
await store.markSaved({ user: user, ids });
} else if (saved === false) {
await store.markUnsaved({ user: user, ids });
}
const notifications = await store.getNotifications({ ids, user: user });
res.status(200).send(notifications);
});
// Add new notification
// Allowed only for service-to-service authentication, uses `getUsersForEntityRef` to retrieve recipients for
// specific entity reference
router.post('/', async (req, res) => {
const { recipients, origin, payload } = req.body;
const notifications = [];
let users = [];
try {
await authenticateService(req);
} catch (e) {
throw new AuthenticationError();
}
const { title, link, description, scope } = payload;
if (!recipients || !title || !origin || !link) {
logger.error(`Invalid notification request received`);
throw new InputError();
}
let entityRef = null;
// TODO: Support for broadcast notifications
if (recipients.entityRef && recipients.type === 'entity') {
entityRef = recipients.entityRef;
}
try {
users = await getUsersForEntityRef(entityRef);
} catch (e) {
throw new InputError();
}
const baseNotification: Omit<Notification, 'id' | 'user'> = {
payload: {
...payload,
severity: payload.severity ?? 'normal',
},
origin,
created: new Date(),
};
const uniqueUsers = [...new Set(users)];
for (const user of uniqueUsers) {
const userNotification = {
...baseNotification,
id: uuid(),
user,
};
const notification = await decorateNotification(userNotification);
let existingNotification;
if (scope) {
existingNotification = await store.getExistingScopeNotification({
user,
scope,
origin,
});
}
let ret = notification;
if (existingNotification) {
const restored = await store.restoreExistingNotification({
id: existingNotification.id,
notification,
});
ret = restored ?? notification;
} else {
await store.saveNotification(notification);
}
processorSendNotification(ret);
notifications.push(ret);
}
if (signalService) {
await signalService.publish({
recipients: entityRef === null ? null : uniqueUsers,
message: {
action: 'new_notification',
notification: { title, description, link },
},
channel: 'notifications',
});
}
res.json(notifications);
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,136 @@
/*
* Copyright 2023 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 {
createServiceBuilder,
HostDiscovery,
loadBackendConfig,
PluginDatabaseManager,
ServerTokenManager,
} from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
import Knex from 'knex';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Request } from 'express';
import {
CatalogApi,
CatalogRequestOptions,
GetEntitiesByRefsRequest,
} from '@backstage/catalog-client';
import { DefaultSignalService } from '@backstage/plugin-signals-node';
import {
EventBroker,
EventParams,
EventSubscriber,
} from '@backstage/plugin-events-node';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'notifications-backend' });
logger.debug('Starting application server...');
const config = await loadBackendConfig({ logger, argv: process.argv });
const db = Knex(config.get('backend.database'));
const tokenManager = ServerTokenManager.fromConfig(config, {
logger,
});
const discovery = HostDiscovery.fromConfig(config);
const dbMock: PluginDatabaseManager = {
async getClient() {
return db;
},
};
const catalogApi = {
async getEntitiesByRefs(
_request: GetEntitiesByRefsRequest,
__options?: CatalogRequestOptions,
) {
return {
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: 'user', namespace: 'default' },
spec: {},
},
],
};
},
} as Partial<CatalogApi> as CatalogApi;
const identityMock: IdentityApi = {
async getIdentity({ request }: { request: Request<unknown> }) {
const token = request.headers.authorization?.split(' ')[1];
return {
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'user:default/guest',
},
token: token || 'no-token',
};
},
};
const mockSubscribers: EventSubscriber[] = [];
const eventBroker: EventBroker = {
async publish(params: EventParams): Promise<void> {
mockSubscribers.forEach(sub => sub.onEvent(params));
},
subscribe(...subscribers: EventSubscriber[]) {
subscribers.flat().forEach(subscriber => {
mockSubscribers.push(subscriber);
});
},
};
const signalService = DefaultSignalService.create({ eventBroker });
const router = await createRouter({
logger,
identity: identityMock,
database: dbMock,
catalog: catalogApi,
discovery,
tokenManager,
signalService,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/notifications', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 {};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+5
View File
@@ -0,0 +1,5 @@
# @backstage/plugin-notifications-common
Welcome to the common package for the notifications plugin!
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,42 @@
## API Report File for "@backstage/plugin-notifications-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// @public (undocumented)
type Notification_2 = {
id: string;
user: string;
created: Date;
saved?: Date;
read?: Date;
done?: Date;
updated?: Date;
origin: string;
payload: NotificationPayload;
};
export { Notification_2 as Notification };
// @public (undocumented)
export type NotificationPayload = {
title: string;
description?: string;
link: string;
severity: NotificationSeverity;
topic?: string;
scope?: string;
icon?: string;
};
// @public (undocumented)
export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low';
// @public (undocumented)
export type NotificationStatus = {
unread: number;
read: number;
};
// @public (undocumented)
export type NotificationType = 'undone' | 'done' | 'saved';
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-notifications-common
title: '@backstage/plugin-notifications-common'
description: Common functionalities for the notifications plugin
spec:
lifecycle: experimental
type: backstage-common-library
owner: maintainers
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@backstage/plugin-notifications-common",
"description": "Common functionalities for the notifications plugin",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "common-library"
},
"sideEffects": false,
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
},
"files": [
"dist"
],
"dependencies": {
"@material-ui/icons": "^4.9.1"
}
}
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2023 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.
*/
/**
* Common functionalities for the notifications plugin.
*
* @packageDocumentation
*/
export * from './types';
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 {};
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright 2023 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.
*/
/** @public */
export type NotificationType = 'undone' | 'done' | 'saved';
/** @public */
export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low';
/** @public */
export type NotificationPayload = {
title: string;
description?: string;
link: string;
// TODO: Add support for additional links
// additionalLinks?: string[];
severity: NotificationSeverity;
topic?: string;
scope?: string;
icon?: string;
};
/** @public */
export type Notification = {
id: string;
user: string;
created: Date;
saved?: Date;
read?: Date;
done?: Date;
updated?: Date;
origin: string;
payload: NotificationPayload;
};
/** @public */
export type NotificationStatus = {
unread: number;
read: number;
};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+51
View File
@@ -0,0 +1,51 @@
# @backstage/plugin-notifications-node
Welcome to the Node.js library package for the notifications plugin!
## Getting Started
To be able to send notifications from other backend plugins, the `NotificationService` must be initialized for the
environment. Add notification service to your `plugin.ts` as a dependency for init
```ts
import { notificationService } from '@backstage/plugin-notifications-node';
export const myPlugin = createBackendPlugin({
pluginId: 'myPlugin',
register(env) {
env.registerInit({
deps: {
config: coreServices.rootConfig,
logger: coreServices.logger,
httpRouter: coreServices.httpRouter,
notificationService: notificationService,
},
async init({ config, logger, httpRouter, notificationService }) {
httpRouter.use(
await createRouter({
config,
logger,
permissions,
notificationService,
}),
);
},
});
},
});
```
You also need to set up the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications`
to be able to show notifications in the UI.
## Sending notifications
To send notifications from backend plugin, use the `NotificationService::send` functionality. This function will
save the notification and optionally signal the frontend to show the latest status for users.
When sending notifications, you can specify the entity reference of the notification. If the entity reference is
a user, the notification will be sent to only that user. If it's a group, the notification will be sent to all
members of the group. If it's some other entity, the notification will be sent to the owner of that entity.
If the notification has `scope` set and user already has notification with that scope, the existing notification
will be updated with the new notification values and moved to inbox as unread.
+69
View File
@@ -0,0 +1,69 @@
## API Report File for "@backstage/plugin-notifications-node"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
import { NotificationPayload } from '@backstage/plugin-notifications-common';
import { ServiceRef } from '@backstage/backend-plugin-api';
import { TokenManager } from '@backstage/backend-common';
// @public (undocumented)
export class DefaultNotificationService implements NotificationService {
// (undocumented)
static create({
tokenManager,
discovery,
pluginId,
}: NotificationServiceOptions): DefaultNotificationService;
// (undocumented)
send(notification: NotificationSendOptions): Promise<void>;
}
// @public (undocumented)
export interface NotificationProcessor {
decorate?(notification: Notification_2): Promise<Notification_2>;
send?(notification: Notification_2): Promise<void>;
}
// @public (undocumented)
export type NotificationRecipients = {
type: 'entity';
entityRef: string | string[];
};
// @public (undocumented)
export type NotificationSendOptions = {
recipients: NotificationRecipients;
payload: NotificationPayload;
};
// @public (undocumented)
export interface NotificationService {
// (undocumented)
send(options: NotificationSendOptions): Promise<void>;
}
// @public (undocumented)
export const notificationService: ServiceRef<NotificationService, 'plugin'>;
// @public (undocumented)
export type NotificationServiceOptions = {
discovery: DiscoveryService;
tokenManager: TokenManager;
pluginId: string;
};
// @public (undocumented)
export interface NotificationsProcessingExtensionPoint {
// (undocumented)
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
): void;
}
// @public (undocumented)
export const notificationsProcessingExtensionPoint: ExtensionPoint<NotificationsProcessingExtensionPoint>;
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-notifications-node
title: '@backstage/plugin-notifications-node'
description: Node.js library for the notifications plugin
spec:
lifecycle: experimental
type: backstage-node-library
owner: maintainers
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@backstage/plugin-notifications-node",
"description": "Node.js library for the notifications plugin",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "node-library"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/test-utils": "workspace:^",
"msw": "^1.0.0"
},
"files": [
"dist"
],
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/plugin-notifications-common": "workspace:^",
"@backstage/plugin-signals-node": "workspace:^",
"knex": "^3.0.0",
"uuid": "^8.0.0"
}
}
@@ -0,0 +1,54 @@
/*
* 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 { createExtensionPoint } from '@backstage/backend-plugin-api';
import { Notification } from '@backstage/plugin-notifications-common';
/**
* @public
*/
export interface NotificationProcessor {
/**
* Decorate notification before sending it
*
* @param notification - The notification to decorate
* @returns The same notification or a modified version of it
*/
decorate?(notification: Notification): Promise<Notification>;
/**
* Send notification using this processor.
*
* @param notification - The notification to send
*/
send?(notification: Notification): Promise<void>;
}
/**
* @public
*/
export interface NotificationsProcessingExtensionPoint {
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
): void;
}
/**
* @public
*/
export const notificationsProcessingExtensionPoint =
createExtensionPoint<NotificationsProcessingExtensionPoint>({
id: 'notifications.processing',
});
+25
View File
@@ -0,0 +1,25 @@
/*
* Copyright 2023 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.
*/
/**
* Node.js library for the notifications plugin.
*
* @packageDocumentation
*/
export * from './service';
export * from './lib';
export * from './extensions';
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2023 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 {
coreServices,
createServiceFactory,
createServiceRef,
} from '@backstage/backend-plugin-api';
import { DefaultNotificationService } from './service';
import { NotificationService } from './service/NotificationService';
/** @public */
export const notificationService = createServiceRef<NotificationService>({
id: 'notifications.service',
scope: 'plugin',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {
discovery: coreServices.discovery,
tokenManager: coreServices.tokenManager,
pluginMetadata: coreServices.pluginMetadata,
},
factory({ discovery, tokenManager, pluginMetadata }) {
return DefaultNotificationService.create({
discovery,
tokenManager,
pluginId: pluginMetadata.getId(),
});
},
}),
});
@@ -0,0 +1,90 @@
/*
* 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 { setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { NotificationPayload } from '@backstage/plugin-notifications-common';
import {
DefaultNotificationService,
NotificationSendOptions,
} from './DefaultNotificationService';
const server = setupServer();
const testNotification: NotificationPayload = {
title: 'Notification 1',
link: '/catalog',
severity: 'normal',
};
describe('DefaultNotificationService', () => {
setupRequestMockHandlers(server);
const mockBaseUrl = 'http://backstage/api/notifications';
const discoveryApi = {
getBaseUrl: async () => mockBaseUrl,
getExternalBaseUrl: async () => mockBaseUrl,
};
const tokenManager = {
getToken: async () => ({ token: '1234' }),
authenticate: jest.fn(),
};
let service: DefaultNotificationService;
beforeEach(() => {
service = DefaultNotificationService.create({
discovery: discoveryApi,
tokenManager,
pluginId: 'test',
});
});
describe('getNotifications', () => {
it('should create notification', async () => {
const body: NotificationSendOptions = {
recipients: { type: 'entity', entityRef: ['user:default/john.doe'] },
payload: testNotification,
};
server.use(
rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => {
const json = await req.json();
expect(json).toEqual({ ...body, origin: 'plugin-test' });
expect(req.headers.get('Authorization')).toEqual('Bearer 1234');
return res(ctx.status(200));
}),
);
await expect(service.send(body)).resolves.not.toThrow();
});
it('should throw error if failing', async () => {
const body: NotificationSendOptions = {
recipients: { type: 'entity', entityRef: ['user:default/john.doe'] },
payload: testNotification,
};
server.use(
rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => {
const json = await req.json();
expect(json).toEqual({ ...body, origin: 'plugin-test' });
expect(req.headers.get('Authorization')).toEqual('Bearer 1234');
return res(ctx.status(400));
}),
);
await expect(service.send(body)).rejects.toThrow();
});
});
});
@@ -0,0 +1,85 @@
/*
* Copyright 2023 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 { TokenManager } from '@backstage/backend-common';
import { NotificationService } from './NotificationService';
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { NotificationPayload } from '@backstage/plugin-notifications-common';
/** @public */
export type NotificationServiceOptions = {
discovery: DiscoveryService;
tokenManager: TokenManager;
pluginId: string;
};
/** @public */
export type NotificationRecipients = {
type: 'entity';
entityRef: string | string[];
};
// TODO: Support for broadcast messages
// | { type: 'broadcast' };
/** @public */
export type NotificationSendOptions = {
recipients: NotificationRecipients;
payload: NotificationPayload;
};
/** @public */
export class DefaultNotificationService implements NotificationService {
private constructor(
private readonly discovery: DiscoveryService,
private readonly tokenManager: TokenManager,
private readonly pluginId: string,
) {}
static create({
tokenManager,
discovery,
pluginId,
}: NotificationServiceOptions): DefaultNotificationService {
return new DefaultNotificationService(discovery, tokenManager, pluginId);
}
async send(notification: NotificationSendOptions): Promise<void> {
try {
const baseUrl = await this.discovery.getBaseUrl('notifications');
const { token } = await this.tokenManager.getToken();
const response = await fetch(`${baseUrl}/`, {
method: 'POST',
body: JSON.stringify({
...notification,
// TODO: Should retrieve this in the backend from service auth instead
origin: `plugin-${this.pluginId}`,
}),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
} catch (error) {
// TODO: Should not throw in optimal case, see BEP
throw new Error(`Failed to send notifications: ${error}`);
}
}
}
@@ -0,0 +1,22 @@
/*
* 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 { NotificationSendOptions } from './DefaultNotificationService';
/** @public */
export interface NotificationService {
send(options: NotificationSendOptions): Promise<void>;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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 './DefaultNotificationService';
export type { NotificationService } from './NotificationService';
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 {};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+41
View File
@@ -0,0 +1,41 @@
# notifications
Welcome to the notifications plugin!
_This plugin was created through the Backstage CLI_
## Getting started
First, install the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications-node` packages.
See the documentation for installation instructions.
To add the notifications main menu, add the following to your `packages/app/src/components/Root/Root.tsx`:
```tsx
import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
<SidebarPage>
<Sidebar>
<SidebarGroup>
// ...
<NotificationsSidebarItem />
</SidebarGroup>
</Sidebar>
</SidebarPage>;
```
Also add the route to notifications to `packages/app/src/App.tsx`:
```tsx
import { NotificationsPage } from '@backstage/plugin-notifications';
<FlatRoutes>
// ...
<Route path="/notifications" element={<NotificationsPage />} />
</FlatRoutes>;
```
## Real-time notifications
To be able to get real-time notifications to the UI without need for the user to refresh the page, you also need to
add `@backstage/plugin-signals` package to your installation.
+135
View File
@@ -0,0 +1,135 @@
## API Report File for "@backstage/plugin-notifications"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
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 { NotificationStatus } from '@backstage/plugin-notifications-common';
import { NotificationType } from '@backstage/plugin-notifications-common';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
// @public (undocumented)
export type GetNotificationsOptions = {
type?: NotificationType;
offset?: number;
limit?: number;
search?: string;
};
// @public (undocumented)
export interface NotificationsApi {
// (undocumented)
getNotifications(
options?: GetNotificationsOptions,
): Promise<Notification_2[]>;
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification_2[]>;
}
// @public (undocumented)
export const notificationsApiRef: ApiRef<NotificationsApi>;
// @public (undocumented)
export class NotificationsClient implements NotificationsApi {
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi });
// (undocumented)
getNotifications(
options?: GetNotificationsOptions,
): Promise<Notification_2[]>;
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification_2[]>;
}
// @public (undocumented)
export const NotificationsPage: () => JSX_2.Element;
// @public (undocumented)
export const notificationsPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
// @public (undocumented)
export const NotificationsSidebarItem: (props?: {
webNotificationsEnabled?: boolean;
titleCounterEnabled?: boolean;
}) => React_2.JSX.Element;
// @public (undocumented)
export const NotificationsTable: (props: {
onUpdate: () => void;
type: NotificationType;
notifications?: Notification_2[];
}) => React_2.JSX.Element;
// @public (undocumented)
export type UpdateNotificationsOptions = {
ids: string[];
done?: boolean;
read?: boolean;
saved?: boolean;
};
// @public (undocumented)
export function useNotificationsApi<T>(
f: (api: NotificationsApi) => Promise<T>,
deps?: any[],
):
| {
retry: () => void;
loading: boolean;
error?: undefined;
value?: undefined;
}
| {
retry: () => void;
loading: false;
error: Error;
value?: undefined;
}
| {
retry: () => void;
loading: true;
error?: Error | undefined;
value?: T | undefined;
}
| {
retry: () => void;
loading: false;
error?: undefined;
value: T;
};
// @public (undocumented)
export function useTitleCounter(): {
setNotificationCount: (newCount: number) => void;
};
// @public (undocumented)
export function useWebNotifications(): {
sendWebNotification: (options: {
title: string;
description: string;
}) => Notification | null;
};
// (No @packageDocumentation comment for this package)
```
+9
View File
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-notifications
title: '@backstage/plugin-notifications'
spec:
lifecycle: experimental
type: backstage-frontend-plugin
owner: maintainers
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2023 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 { createDevApp } from '@backstage/dev-utils';
import { notificationsPlugin } from '../src/plugin';
createDevApp()
.registerPlugin(notificationsPlugin)
.addPage({
element: <div />,
title: 'Root Page',
path: '/notifications',
})
.render();
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-notifications",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin"
},
"sideEffects": false,
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-notifications-common": "workspace:^",
"@backstage/plugin-signals-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
"@types/react": "^16.13.1 || ^17.0.0",
"react-relative-time": "^0.0.9",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
"msw": "^1.0.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,53 @@
/*
* Copyright 2023 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 { createApiRef } from '@backstage/core-plugin-api';
import {
Notification,
NotificationStatus,
NotificationType,
} from '@backstage/plugin-notifications-common';
/** @public */
export const notificationsApiRef = createApiRef<NotificationsApi>({
id: 'plugin.notifications.service',
});
/** @public */
export type GetNotificationsOptions = {
type?: NotificationType;
offset?: number;
limit?: number;
search?: string;
};
/** @public */
export type UpdateNotificationsOptions = {
ids: string[];
done?: boolean;
read?: boolean;
saved?: boolean;
};
/** @public */
export interface NotificationsApi {
getNotifications(options?: GetNotificationsOptions): Promise<Notification[]>;
getStatus(): Promise<NotificationStatus>;
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification[]>;
}
@@ -0,0 +1,104 @@
/*
* 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 { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { NotificationsClient } from './NotificationsClient';
import { Notification } from '@backstage/plugin-notifications-common';
const server = setupServer();
const testNotification: Partial<Notification> = {
user: 'user:default/john.doe',
origin: 'plugin-test',
payload: {
title: 'Notification 1',
link: '/catalog',
severity: 'normal',
},
};
describe('NotificationsClient', () => {
setupRequestMockHandlers(server);
const mockBaseUrl = 'http://backstage/api/notifications';
const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
const fetchApi = new MockFetchApi();
let client: NotificationsClient;
beforeEach(() => {
client = new NotificationsClient({ discoveryApi, fetchApi });
});
describe('getNotifications', () => {
const expectedResp = [testNotification];
it('should fetch notifications from correct endpoint', async () => {
server.use(
rest.get(`${mockBaseUrl}/`, (_, res, ctx) =>
res(ctx.json(expectedResp)),
),
);
const response = await client.getNotifications();
expect(response).toEqual(expectedResp);
});
it('should fetch notifications with options', async () => {
server.use(
rest.get(`${mockBaseUrl}/`, (req, res, ctx) => {
expect(req.url.search).toBe(
'?type=undone&limit=10&offset=0&search=find+me',
);
return res(ctx.json(expectedResp));
}),
);
const response = await client.getNotifications({
type: 'undone',
limit: 10,
offset: 0,
search: 'find me',
});
expect(response).toEqual(expectedResp);
});
it('should fetch status from correct endpoint', async () => {
server.use(
rest.get(`${mockBaseUrl}/status`, (_, res, ctx) =>
res(ctx.json({ read: 1, unread: 1 })),
),
);
const response = await client.getStatus();
expect(response).toEqual({ read: 1, unread: 1 });
});
it('should update notifications', async () => {
server.use(
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);
});
});
});
@@ -0,0 +1,89 @@
/*
* Copyright 2023 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 {
GetNotificationsOptions,
NotificationsApi,
UpdateNotificationsOptions,
} from './NotificationsApi';
import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import {
Notification,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
/** @public */
export class NotificationsClient implements NotificationsApi {
private readonly discoveryApi: DiscoveryApi;
private readonly fetchApi: FetchApi;
public constructor(options: {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
}) {
this.discoveryApi = options.discoveryApi;
this.fetchApi = options.fetchApi;
}
async getNotifications(
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));
}
if (options?.offset !== undefined) {
queryString.append('offset', options.offset.toString(10));
}
if (options?.search) {
queryString.append('search', options.search);
}
const urlSegment = `?${queryString}`;
return await this.request<Notification[]>(urlSegment);
}
async getStatus(): Promise<NotificationStatus> {
return await this.request<NotificationStatus>('status');
}
async updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification[]> {
return await this.request<Notification[]>('update', {
method: 'POST',
body: JSON.stringify(options),
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(), init);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return response.json() as Promise<T>;
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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 './NotificationsApi';
export * from './NotificationsClient';
@@ -0,0 +1,112 @@
/*
* Copyright 2023 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, { useEffect, useState } from 'react';
import {
Content,
ErrorPanel,
PageWithHeader,
} 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 { useSignal } from '@backstage/plugin-signals-react';
const useStyles = makeStyles(_theme => ({
filterButton: {
width: '100%',
justifyContent: 'start',
},
}));
export const NotificationsPage = () => {
const [type, setType] = useState<NotificationType>('undone');
const [refresh, setRefresh] = React.useState(false);
const { error, value, retry } = useNotificationsApi(
api => api.getNotifications({ type }),
[type],
);
useEffect(() => {
if (refresh) {
retry();
setRefresh(false);
}
}, [refresh, setRefresh, retry]);
const { lastSignal } = useSignal('notifications');
useEffect(() => {
if (lastSignal && lastSignal.action) {
setRefresh(true);
}
}, [lastSignal]);
const onUpdate = () => {
setRefresh(true);
};
const styles = useStyles();
if (error) {
return <ErrorPanel error={new Error('Failed to load notifications')} />;
}
return (
<PageWithHeader title="Notifications" themeId="tool">
<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>
</Grid>
<Grid item xs={10}>
<NotificationsTable
notifications={value}
type={type}
onUpdate={onUpdate}
/>
</Grid>
</Grid>
</Content>
</PageWithHeader>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 './NotificationsPage';
@@ -0,0 +1,107 @@
/*
* Copyright 2023 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, { useEffect } from 'react';
import { useNotificationsApi } from '../../hooks';
import { SidebarItem } from '@backstage/core-components';
import NotificationsIcon from '@material-ui/icons/Notifications';
import { useRouteRef } from '@backstage/core-plugin-api';
import { rootRouteRef } from '../../routes';
import { useSignal } from '@backstage/plugin-signals-react';
import { useWebNotifications } from '../../hooks/useWebNotifications';
import { useTitleCounter } from '../../hooks/useTitleCounter';
import { JsonObject } from '@backstage/types';
/** @public */
export const NotificationsSidebarItem = (props?: {
webNotificationsEnabled?: boolean;
titleCounterEnabled?: boolean;
}) => {
const { webNotificationsEnabled = false, titleCounterEnabled = true } =
props ?? { webNotificationsEnabled: false, titleCounterEnabled: true };
const { loading, error, value, retry } = useNotificationsApi(api =>
api.getStatus(),
);
const [unreadCount, setUnreadCount] = React.useState(0);
const notificationsRoute = useRouteRef(rootRouteRef);
// TODO: Add signal type support to `useSignal` to make it a bit easier to use
// TODO: Do we want to add long polling in case signals are not available
const { lastSignal } = useSignal('notifications');
const { sendWebNotification } = useWebNotifications();
const [refresh, setRefresh] = React.useState(false);
const { setNotificationCount } = useTitleCounter();
useEffect(() => {
if (refresh) {
retry();
setRefresh(false);
}
}, [refresh, retry]);
useEffect(() => {
const handleWebNotification = (signal: JsonObject) => {
if (!webNotificationsEnabled || !('notification' in signal)) {
return;
}
const notificationData = signal.notification as JsonObject;
if (
!notificationData ||
!('title' in notificationData) ||
!('description' in notificationData) ||
!('title' in notificationData)
) {
return;
}
const notification = sendWebNotification({
title: notificationData.title as string,
description: notificationData.description as string,
});
if (notification) {
notification.onclick = event => {
event.preventDefault();
notification.close();
window.open(notificationData.link as string, '_blank');
};
}
};
if (lastSignal && lastSignal.action) {
handleWebNotification(lastSignal);
setRefresh(true);
}
}, [lastSignal, sendWebNotification, webNotificationsEnabled]);
useEffect(() => {
if (!loading && !error && value) {
setUnreadCount(value.unread);
if (titleCounterEnabled) {
setNotificationCount(value.unread);
}
}
}, [loading, error, value, titleCounterEnabled, setNotificationCount]);
// TODO: Figure out if the count can be added to hasNotifications
return (
<SidebarItem
icon={NotificationsIcon}
to={notificationsRoute()}
text="Notifications"
hasNotifications={!error && !!unreadCount}
/>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 './NotificationsSideBarItem';
@@ -0,0 +1,308 @@
/*
* Copyright 2023 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, { 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 { notificationsApiRef } from '../../api';
import { useApi } from '@backstage/core-plugin-api';
import Inbox from '@material-ui/icons/Inbox';
import CloseIcon from '@material-ui/icons/Close';
// @ts-ignore
import RelativeTime from 'react-relative-time';
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
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',
},
}));
/** @public */
export const NotificationsTable = (props: {
onUpdate: () => void;
type: NotificationType;
notifications?: Notification[];
}) => {
const { notifications, type } = props;
const navigate = useNavigate();
const styles = useStyles();
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
);
};
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 => {
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 }}
>
<Typography variant="subtitle2">
{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>
);
})}
</TableBody>
</Table>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 './NotificationsTable';
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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 './NotificationsSideBarItem';
export * from './NotificationsTable';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2023 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 './useNotificationsApi';
export * from './useWebNotifications';
export * from './useTitleCounter';
@@ -0,0 +1,31 @@
/*
* Copyright 2023 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 { NotificationsApi, notificationsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
/** @public */
export function useNotificationsApi<T>(
f: (api: NotificationsApi) => Promise<T>,
deps: any[] = [],
) {
const notificationsApi = useApi(notificationsApiRef);
return useAsyncRetry(async () => {
return await f(notificationsApi);
}, deps);
}
@@ -0,0 +1,60 @@
/*
* 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 { useCallback, useEffect, useState } from 'react';
/** @public */
export function useTitleCounter() {
const [title, setTitle] = useState(document.title);
const [count, setCount] = useState(0);
const getPrefix = (value: number) => {
return value === 0 ? '' : `(${value}) `;
};
const cleanTitle = (currentTitle: string) => {
return currentTitle.replace(/^\(\d+\)\s/, '');
};
useEffect(() => {
document.title = title;
}, [title]);
useEffect(() => {
const baseTitle = cleanTitle(title);
setTitle(`${getPrefix(count)}${baseTitle}`);
return () => {
document.title = cleanTitle(title);
};
}, [title, count]);
const titleElement = document.querySelector('title');
if (titleElement) {
new MutationObserver(() => {
setTitle(document.title);
}).observe(titleElement, {
subtree: true,
characterData: true,
childList: true,
});
}
const setNotificationCount = useCallback(
(newCount: number) => setCount(newCount),
[],
);
return { setNotificationCount };
}
@@ -0,0 +1,54 @@
/*
* 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 { useCallback, useEffect, useState } from 'react';
/** @public */
export function useWebNotifications() {
const [webNotificationPermission, setWebNotificationPermission] =
useState('default');
const [webNotifications, setWebNotifications] = useState<Notification[]>([]);
useEffect(() => {
if ('Notification' in window && webNotificationPermission === 'default') {
window.Notification.requestPermission().then(permission => {
setWebNotificationPermission(permission);
});
}
}, [webNotificationPermission]);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
webNotifications.forEach(n => n.close());
setWebNotifications([]);
}
});
const sendWebNotification = useCallback(
(options: { title: string; description: string }) => {
if (webNotificationPermission !== 'granted') {
return null;
}
const notification = new Notification(options.title, {
body: options.description,
});
return notification;
},
[webNotificationPermission],
);
return { sendWebNotification };
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2023 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 { notificationsPlugin, NotificationsPage } from './plugin';
export * from './api';
export * from './hooks';
export * from './components';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2023 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 { notificationsPlugin } from './plugin';
describe('notifications', () => {
it('should export plugin', () => {
expect(notificationsPlugin).toBeDefined();
});
});
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright 2023 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 {
createApiFactory,
createPlugin,
createRoutableExtension,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
import { notificationsApiRef } from './api/NotificationsApi';
import { NotificationsClient } from './api';
/** @public */
export const notificationsPlugin = createPlugin({
id: 'notifications',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: notificationsApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
new NotificationsClient({ discoveryApi, fetchApi }),
}),
],
});
/** @public */
export const NotificationsPage = notificationsPlugin.provide(
createRoutableExtension({
name: 'NotificationsPage',
component: () =>
import('./components/NotificationsPage').then(m => m.NotificationsPage),
mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2023 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'notifications',
});
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 '@testing-library/jest-dom';
+104 -3
View File
@@ -7783,6 +7783,95 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-notifications-backend@workspace:^, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend":
version: 0.0.0-use.local
resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/catalog-client": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/plugin-notifications-common": "workspace:^"
"@backstage/plugin-notifications-node": "workspace:^"
"@backstage/plugin-signals-node": "workspace:^"
"@types/express": ^4.17.6
"@types/supertest": ^2.0.8
express: ^4.17.1
express-promise-router: ^4.1.0
knex: ^3.0.0
msw: ^1.0.0
node-fetch: ^2.6.7
supertest: ^6.2.4
uuid: ^8.0.0
winston: ^3.2.1
yn: ^4.0.0
languageName: unknown
linkType: soft
"@backstage/plugin-notifications-common@workspace:^, @backstage/plugin-notifications-common@workspace:plugins/notifications-common":
version: 0.0.0-use.local
resolution: "@backstage/plugin-notifications-common@workspace:plugins/notifications-common"
dependencies:
"@backstage/cli": "workspace:^"
"@material-ui/icons": ^4.9.1
languageName: unknown
linkType: soft
"@backstage/plugin-notifications-node@workspace:^, @backstage/plugin-notifications-node@workspace:plugins/notifications-node":
version: 0.0.0-use.local
resolution: "@backstage/plugin-notifications-node@workspace:plugins/notifications-node"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/catalog-client": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/plugin-notifications-common": "workspace:^"
"@backstage/plugin-signals-node": "workspace:^"
"@backstage/test-utils": "workspace:^"
knex: ^3.0.0
msw: ^1.0.0
uuid: ^8.0.0
languageName: unknown
linkType: soft
"@backstage/plugin-notifications@workspace:^, @backstage/plugin-notifications@workspace:plugins/notifications":
version: 0.0.0-use.local
resolution: "@backstage/plugin-notifications@workspace:plugins/notifications"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-notifications-common": "workspace:^"
"@backstage/plugin-signals-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@material-ui/core": ^4.9.13
"@material-ui/icons": ^4.9.1
"@material-ui/lab": ^4.0.0-alpha.61
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^14.0.0
"@testing-library/user-event": ^14.0.0
"@types/react": ^16.13.1 || ^17.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
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
"@backstage/plugin-octopus-deploy@workspace:^, @backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy":
version: 0.0.0-use.local
resolution: "@backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy"
@@ -25247,9 +25336,9 @@ __metadata:
linkType: hard
"dom-accessibility-api@npm:^0.5.9":
version: 0.5.13
resolution: "dom-accessibility-api@npm:0.5.13"
checksum: a5a5f14c01e466d424750aaac9225f1dc43cf16d101a1c40e01a554abce63c48084707002c39b805f2ce212273c179dd6d2258175997cd06d5f79851bf52dd40
version: 0.5.16
resolution: "dom-accessibility-api@npm:0.5.16"
checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248
languageName: node
linkType: hard
@@ -27062,6 +27151,7 @@ __metadata:
"@backstage/plugin-newrelic": "workspace:^"
"@backstage/plugin-newrelic-dashboard": "workspace:^"
"@backstage/plugin-nomad": "workspace:^"
"@backstage/plugin-notifications": "workspace:^"
"@backstage/plugin-octopus-deploy": "workspace:^"
"@backstage/plugin-org": "workspace:^"
"@backstage/plugin-pagerduty": "workspace:^"
@@ -27147,6 +27237,7 @@ __metadata:
"@backstage/plugin-lighthouse-backend": "workspace:^"
"@backstage/plugin-linguist-backend": "workspace:^"
"@backstage/plugin-nomad-backend": "workspace:^"
"@backstage/plugin-notifications-backend": "workspace:^"
"@backstage/plugin-permission-backend": "workspace:^"
"@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
@@ -27159,6 +27250,7 @@ __metadata:
"@backstage/plugin-search-backend-module-explore": "workspace:^"
"@backstage/plugin-search-backend-module-techdocs": "workspace:^"
"@backstage/plugin-search-backend-node": "workspace:^"
"@backstage/plugin-signals-backend": "workspace:^"
"@backstage/plugin-sonarqube-backend": "workspace:^"
"@backstage/plugin-techdocs-backend": "workspace:^"
"@backstage/plugin-todo-backend": "workspace:^"
@@ -39087,6 +39179,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"