feat: support pagination and searching in notification backend
disabled broadcast notifications for now Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
@@ -21,17 +21,6 @@ import { PluginEnvironment } from '../types';
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
// TODO: Remove this test code
|
||||
setInterval(() => {
|
||||
env.notificationService.send({
|
||||
receivers: { type: 'broadcast' },
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
link: '/catalog',
|
||||
topic: 'topic',
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
return await createRouter({
|
||||
logger: env.logger,
|
||||
identity: env.identity,
|
||||
|
||||
@@ -72,6 +72,29 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
query.where('saved', true);
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
query.limit(options.limit);
|
||||
}
|
||||
|
||||
if (options.offset) {
|
||||
query.offset(options.offset);
|
||||
}
|
||||
|
||||
if (options.search) {
|
||||
if (this.db.client.config.client === 'pg') {
|
||||
query.whereRaw(
|
||||
`(to_tsvector('english', notifications.title || ' ' || notifications.description) @@ websearch_to_tsquery('english', quote_literal(?))
|
||||
or to_tsvector('english', notifications.title || ' ' || notifications.description) @@ to_tsquery('english',quote_literal(?)))`,
|
||||
[`${options.search}`, `${options.search.replaceAll(/\s/g, '+')}:*`],
|
||||
);
|
||||
} else {
|
||||
query.whereRaw(
|
||||
`LOWER(notifications.title || ' ' || notifications.description) LIKE LOWER(?)`,
|
||||
[`%${options.search}%`],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ('ids' in options && options.ids) {
|
||||
query.whereIn('id', options.ids);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
export type NotificationGetOptions = {
|
||||
user_ref: string;
|
||||
type?: NotificationType;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -41,7 +41,10 @@ import { NotificationProcessor } from '../types';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { SignalService } from '@backstage/plugin-signals-node';
|
||||
import { Notification } from '@backstage/plugin-notifications-common';
|
||||
import {
|
||||
Notification,
|
||||
NotificationType,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
|
||||
/** @public */
|
||||
export interface RouterOptions {
|
||||
@@ -94,13 +97,9 @@ export async function createRouter(
|
||||
): Promise<string[]> => {
|
||||
const { token } = await tokenManager.getToken();
|
||||
|
||||
// Broadcast
|
||||
// TODO: Support for broadcast
|
||||
if (entityRef === null) {
|
||||
const users = await catalogClient.getEntities({
|
||||
filter: { kind: 'User' },
|
||||
fields: ['kind', 'metadata.name', 'metadata.namespace'],
|
||||
});
|
||||
return users.items.map(stringifyEntityRef);
|
||||
return [];
|
||||
}
|
||||
|
||||
const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
|
||||
@@ -119,12 +118,23 @@ export async function createRouter(
|
||||
if (isUserEntity(entity)) {
|
||||
return [stringifyEntityRef(entity)];
|
||||
} else if (isGroupEntity(entity) && entity.relations) {
|
||||
return 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,
|
||||
@@ -162,6 +172,7 @@ export async function createRouter(
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Move to use OpenAPI router instead
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
@@ -176,7 +187,16 @@ export async function createRouter(
|
||||
user_ref: user,
|
||||
};
|
||||
if (req.query.type) {
|
||||
opts.type = req.query.type as any;
|
||||
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);
|
||||
@@ -201,7 +221,7 @@ export async function createRouter(
|
||||
if (signalService) {
|
||||
await signalService.publish({
|
||||
recipients: [user],
|
||||
message: { action: 'refresh' },
|
||||
message: { action: 'done', notification_ids: ids },
|
||||
channel: 'notifications',
|
||||
});
|
||||
}
|
||||
@@ -219,7 +239,7 @@ export async function createRouter(
|
||||
if (signalService) {
|
||||
await signalService.publish({
|
||||
recipients: [user],
|
||||
message: { action: 'refresh' },
|
||||
message: { action: 'undone', notification_ids: ids },
|
||||
channel: 'notifications',
|
||||
});
|
||||
}
|
||||
@@ -238,7 +258,7 @@ export async function createRouter(
|
||||
if (signalService) {
|
||||
await signalService.publish({
|
||||
recipients: [user],
|
||||
message: { action: 'refresh' },
|
||||
message: { action: 'mark_read', notification_ids: ids },
|
||||
channel: 'notifications',
|
||||
});
|
||||
}
|
||||
@@ -256,7 +276,7 @@ export async function createRouter(
|
||||
if (signalService) {
|
||||
await signalService.publish({
|
||||
recipients: [user],
|
||||
message: { action: 'refresh' },
|
||||
message: { action: 'mark_unread', notification_ids: ids },
|
||||
channel: 'notifications',
|
||||
});
|
||||
}
|
||||
@@ -354,7 +374,10 @@ export async function createRouter(
|
||||
if (signalService) {
|
||||
await signalService.publish({
|
||||
recipients: entityRef === null ? null : users,
|
||||
message: { action: 'refresh', title, description, link },
|
||||
message: {
|
||||
action: 'new_notification',
|
||||
notification: { title, description, link },
|
||||
},
|
||||
channel: 'notifications',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,14 +23,10 @@ export class DefaultNotificationService implements NotificationService {
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type NotificationReceivers =
|
||||
| {
|
||||
type: 'entity';
|
||||
entityRef: string | string[];
|
||||
}
|
||||
| {
|
||||
type: 'broadcast';
|
||||
};
|
||||
export type NotificationReceivers = {
|
||||
type: 'entity';
|
||||
entityRef: string | string[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type NotificationSendOptions = {
|
||||
|
||||
@@ -28,9 +28,13 @@ export type NotificationServiceOptions = {
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type NotificationReceivers =
|
||||
| { type: 'entity'; entityRef: string | string[] }
|
||||
| { type: 'broadcast' };
|
||||
export type NotificationReceivers = {
|
||||
type: 'entity';
|
||||
entityRef: string | string[];
|
||||
};
|
||||
|
||||
// TODO: Support for broadcast messages
|
||||
// | { type: 'broadcast' };
|
||||
|
||||
/** @public */
|
||||
export type NotificationSendOptions = {
|
||||
|
||||
@@ -20,6 +20,9 @@ import { RouteRef } from '@backstage/core-plugin-api';
|
||||
// @public (undocumented)
|
||||
export type GetNotificationsOptions = {
|
||||
type?: NotificationType;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -29,6 +29,9 @@ export const notificationsApiRef = createApiRef<NotificationsApi>({
|
||||
/** @public */
|
||||
export type GetNotificationsOptions = {
|
||||
type?: NotificationType;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -42,6 +42,15 @@ export class NotificationsClient implements NotificationsApi {
|
||||
if (options?.type) {
|
||||
queryString.append('type', options.type);
|
||||
}
|
||||
if (options?.limit) {
|
||||
queryString.append('limit', options.limit.toString(10));
|
||||
}
|
||||
if (options?.offset) {
|
||||
queryString.append('offset', options.offset.toString(10));
|
||||
}
|
||||
if (options?.search) {
|
||||
queryString.append('search', options.search);
|
||||
}
|
||||
|
||||
const urlSegment = `notifications?${queryString}`;
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export const NotificationsPage = () => {
|
||||
|
||||
const { lastSignal } = useSignal('notifications');
|
||||
useEffect(() => {
|
||||
if (lastSignal && lastSignal.action === 'refresh') {
|
||||
if (lastSignal && lastSignal.action) {
|
||||
setRefresh(true);
|
||||
}
|
||||
}, [lastSignal]);
|
||||
@@ -68,7 +68,6 @@ export const NotificationsPage = () => {
|
||||
return <ErrorPanel error={new Error('Failed to load notifications')} />;
|
||||
}
|
||||
|
||||
// TODO: Add signals listener and refresh data on message
|
||||
return (
|
||||
<PageWithHeader title="Notifications" themeId="tool">
|
||||
<Content>
|
||||
|
||||
+32
-18
@@ -22,6 +22,7 @@ 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 = () => {
|
||||
@@ -31,6 +32,8 @@ export const NotificationsSidebarItem = () => {
|
||||
const config = useApi(configApiRef);
|
||||
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);
|
||||
@@ -54,25 +57,36 @@ export const NotificationsSidebarItem = () => {
|
||||
}, [refresh, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSignal && lastSignal.action === 'refresh') {
|
||||
if (
|
||||
webNotificationsEnabled &&
|
||||
'title' in lastSignal &&
|
||||
'description' in lastSignal &&
|
||||
'link' in lastSignal
|
||||
) {
|
||||
const notification = sendWebNotification({
|
||||
title: lastSignal.title as string,
|
||||
description: lastSignal.description as string,
|
||||
});
|
||||
if (notification) {
|
||||
notification.onclick = event => {
|
||||
event.preventDefault();
|
||||
notification.close();
|
||||
window.open(lastSignal.link as string, '_blank');
|
||||
};
|
||||
}
|
||||
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]);
|
||||
|
||||
@@ -7848,6 +7848,7 @@ __metadata:
|
||||
"@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
|
||||
|
||||
Reference in New Issue
Block a user