Merge pull request #21889 from drodil/notifications
Notifications initial implementation
This commit is contained in:
@@ -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
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
+107
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user