feat(notifications): added the topic filter for notifications

Signed-off-by: Marek Libra <marek.libra@gmail.com>
This commit is contained in:
Marek Libra
2024-12-21 21:53:05 +01:00
parent 0f2a750dac
commit 438c36c554
11 changed files with 310 additions and 48 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-notifications-backend': patch
'@backstage/plugin-notifications': patch
---
added topic filter for notifications
@@ -742,5 +742,53 @@ describe.each(databases.eachSupportedId())(
expect(settings).toEqual(notificationSettings);
});
});
describe('topics', () => {
it('should return all topics for user', async () => {
await storage.saveNotification(testNotification1);
await storage.saveNotification(testNotification2);
await storage.saveBroadcast(testNotification3);
await storage.saveNotification(otherUserNotification);
const topics = await storage.getTopics({ user });
expect(topics.topics.sort()).toEqual([
testNotification1.payload.topic,
testNotification3.payload.topic,
testNotification2.payload.topic,
]);
});
it('should return filtered topics for user by title', async () => {
await storage.saveNotification(testNotification1);
await storage.saveNotification(testNotification2);
await storage.saveBroadcast(testNotification3);
await storage.saveBroadcast(testNotification4);
await storage.saveNotification(otherUserNotification);
const topics = await storage.getTopics({
user,
search: 'Notification 3',
});
expect(topics).toEqual({
topics: [testNotification3.payload.topic],
});
});
it('should return filtered topics for user by severity', async () => {
await storage.saveNotification(testNotification1);
await storage.saveNotification(testNotification2);
await storage.saveBroadcast(testNotification3);
await storage.saveBroadcast(testNotification4);
await storage.saveNotification(otherUserNotification);
const topics = await storage.getTopics({
user,
minimumSeverity: 'critical',
});
expect(topics).toEqual({
topics: [testNotification1.payload.topic],
});
});
});
},
);
@@ -21,6 +21,7 @@ import {
NotificationGetOptions,
NotificationModifyOptions,
NotificationsStore,
TopicGetOptions,
} from './NotificationsStore';
import {
Notification,
@@ -563,4 +564,13 @@ export class DatabaseNotificationsStore implements NotificationsStore {
.delete();
await this.db<UserSettingsRowType>('user_settings').insert(rows);
}
async getTopics(options: TopicGetOptions): Promise<{ topics: string[] }> {
const notificationQuery = this.getNotificationsBaseQuery({
...options,
orderField: [{ field: 'topic', order: 'asc' }],
});
const topics = await notificationQuery.distinct(['topic']);
return { topics: topics.map(row => row.topic) };
}
}
@@ -48,6 +48,16 @@ export type NotificationModifyOptions = {
ids: string[];
} & NotificationGetOptions;
/** @internal */
export type TopicGetOptions = {
user: string;
search?: string;
read?: boolean;
saved?: boolean;
createdAfter?: Date;
minimumSeverity?: NotificationSeverity;
};
/** @internal */
export interface NotificationsStore {
getNotifications(options: NotificationGetOptions): Promise<Notification[]>;
@@ -97,4 +107,6 @@ export interface NotificationsStore {
user: string;
settings: NotificationSettings;
}): Promise<void>;
getTopics(options: TopicGetOptions): Promise<{ topics: string[] }>;
}
@@ -20,6 +20,7 @@ import {
DatabaseNotificationsStore,
normalizeSeverity,
NotificationGetOptions,
TopicGetOptions,
} from '../database';
import { v4 as uuid } from 'uuid';
import { CatalogApi } from '@backstage/catalog-client';
@@ -246,24 +247,10 @@ export async function createRouter(
}
};
// TODO: Move to use OpenAPI router instead
const router = Router();
router.use(express.json());
const listNotificationsHandler = async (req: Request, res: Response) => {
const user = await getUser(req);
const opts: NotificationGetOptions = {
user: user,
};
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.orderField) {
opts.orderField = parseEntityOrderFieldParams(req.query);
}
const appendCommonOptions = (
req: Request,
opts: NotificationGetOptions | TopicGetOptions,
) => {
if (req.query.search) {
opts.search = req.query.search.toString();
}
@@ -274,10 +261,6 @@ export async function createRouter(
// or keep undefined
}
if (req.query.topic) {
opts.topic = req.query.topic.toString();
}
if (req.query.saved === 'true') {
opts.saved = true;
} else if (req.query.saved === 'false') {
@@ -296,6 +279,32 @@ export async function createRouter(
req.query.minimumSeverity.toString(),
);
}
};
// TODO: Move to use OpenAPI router instead
const router = Router();
router.use(express.json());
const listNotificationsHandler = async (req: Request, res: Response) => {
const user = await getUser(req);
const opts: NotificationGetOptions = {
user: user,
};
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.orderField) {
opts.orderField = parseEntityOrderFieldParams(req.query);
}
if (req.query.topic) {
opts.topic = req.query.topic.toString();
}
appendCommonOptions(req, opts);
const [notifications, totalCount] = await Promise.all([
store.getNotifications(opts),
@@ -357,6 +366,21 @@ export async function createRouter(
res.json(notifications[0]);
};
// Get topics
const listTopicsHandler = async (req: Request, res: Response) => {
const user = await getUser(req);
const opts: TopicGetOptions = {
user: user,
};
appendCommonOptions(req, opts);
const topics = await store.getTopics(opts);
res.json(topics);
};
router.get('/topics', listTopicsHandler);
// Make sure this is the last "GET" handler
router.get('/:id', getNotificationHandler); // Deprecated endpoint
router.get('/notifications/:id', getNotificationHandler);
+21 -4
View File
@@ -20,16 +20,21 @@ import { RouteRef } from '@backstage/core-plugin-api';
import { TableProps } from '@backstage/core-components';
// @public (undocumented)
export type GetNotificationsOptions = {
offset?: number;
limit?: number;
export type GetNotificationsCommonOptions = {
search?: string;
read?: boolean;
saved?: boolean;
createdAfter?: Date;
minimumSeverity?: NotificationSeverity;
};
// @public (undocumented)
export type GetNotificationsOptions = GetNotificationsCommonOptions & {
offset?: number;
limit?: number;
sort?: 'created' | 'topic' | 'origin';
sortOrder?: 'asc' | 'desc';
minimumSeverity?: NotificationSeverity;
topic?: string;
};
// @public (undocumented)
@@ -38,6 +43,14 @@ export type GetNotificationsResponse = {
totalCount: number;
};
// @public (undocumented)
export type GetTopicsOptions = GetNotificationsCommonOptions;
// @public (undocumented)
export type GetTopicsResponse = {
topics: string[];
};
// @public (undocumented)
export interface NotificationsApi {
// (undocumented)
@@ -51,6 +64,8 @@ export interface NotificationsApi {
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
getTopics(options?: GetTopicsOptions): Promise<GetTopicsResponse>;
// (undocumented)
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification_2[]>;
@@ -77,6 +92,8 @@ export class NotificationsClient implements NotificationsApi {
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
getTopics(options?: GetTopicsOptions): Promise<GetTopicsResponse>;
// (undocumented)
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification_2[]>;
@@ -27,18 +27,26 @@ export const notificationsApiRef = createApiRef<NotificationsApi>({
});
/** @public */
export type GetNotificationsOptions = {
offset?: number;
limit?: number;
export type GetNotificationsCommonOptions = {
search?: string;
read?: boolean;
saved?: boolean;
createdAfter?: Date;
sort?: 'created' | 'topic' | 'origin';
sortOrder?: 'asc' | 'desc';
minimumSeverity?: NotificationSeverity;
};
/** @public */
export type GetNotificationsOptions = GetNotificationsCommonOptions & {
offset?: number;
limit?: number;
sort?: 'created' | 'topic' | 'origin';
sortOrder?: 'asc' | 'desc';
topic?: string;
};
/** @public */
export type GetTopicsOptions = GetNotificationsCommonOptions;
/** @public */
export type UpdateNotificationsOptions = {
ids: string[];
@@ -52,6 +60,11 @@ export type GetNotificationsResponse = {
totalCount: number;
};
/** @public */
export type GetTopicsResponse = {
topics: string[];
};
/** @public */
export interface NotificationsApi {
getNotifications(
@@ -71,4 +84,6 @@ export interface NotificationsApi {
updateNotificationSettings(
settings: NotificationSettings,
): Promise<NotificationSettings>;
getTopics(options?: GetTopicsOptions): Promise<GetTopicsResponse>;
}
@@ -22,6 +22,8 @@ import { Notification } from '@backstage/plugin-notifications-common';
const server = setupServer();
const testTopic = 'test-topic';
const testNotification: Partial<Notification> = {
user: 'user:default/john.doe',
origin: 'plugin-test',
@@ -29,6 +31,7 @@ const testNotification: Partial<Notification> = {
title: 'Notification 1',
link: '/catalog',
severity: 'normal',
topic: testTopic,
},
};
@@ -75,6 +78,22 @@ describe('NotificationsClient', () => {
expect(response).toEqual(expectedResp);
});
it('should fetch notifications of the topic', async () => {
server.use(
rest.get(`${mockBaseUrl}/notifications`, (req, res, ctx) => {
expect(req.url.search).toBe(`?limit=10&offset=0&topic=${testTopic}`);
return res(ctx.json(expectedResp));
}),
);
const response = await client.getNotifications({
limit: 10,
offset: 0,
topic: testTopic,
});
expect(response).toEqual(expectedResp);
});
it('should omit unselected fetch options', async () => {
server.use(
rest.get(`${mockBaseUrl}/notifications`, (req, res, ctx) => {
@@ -131,4 +150,36 @@ describe('NotificationsClient', () => {
expect(response).toEqual(expectedResp);
});
});
describe('getTopics', () => {
const expectedResp = [testTopic];
it('should fetch topics from correct endpoint', async () => {
server.use(
rest.get(`${mockBaseUrl}/topics`, (_, res, ctx) =>
res(ctx.json(expectedResp)),
),
);
const response = await client.getTopics();
expect(response).toEqual(expectedResp);
});
it('should fetch topics with options', async () => {
server.use(
rest.get(`${mockBaseUrl}/topics`, (req, res, ctx) => {
expect(req.url.search).toBe(
'?search=find+me&read=true&createdAfter=1970-01-01T00%3A00%3A00.005Z',
);
return res(ctx.json(expectedResp));
}),
);
const response = await client.getTopics({
search: 'find me',
read: true,
createdAfter: new Date(5),
});
expect(response).toEqual(expectedResp);
});
});
});
@@ -14,8 +14,11 @@
* limitations under the License.
*/
import {
GetNotificationsCommonOptions,
GetNotificationsOptions,
GetNotificationsResponse,
GetTopicsOptions,
GetTopicsResponse,
NotificationsApi,
UpdateNotificationsOptions,
} from './NotificationsApi';
@@ -56,20 +59,11 @@ export class NotificationsClient implements NotificationsApi {
`${options.sort},${options?.sortOrder ?? 'desc'}`,
);
}
if (options?.search) {
queryString.append('search', options.search);
}
if (options?.read !== undefined) {
queryString.append('read', options.read ? 'true' : 'false');
}
if (options?.saved !== undefined) {
queryString.append('saved', options.saved ? 'true' : 'false');
}
if (options?.createdAfter !== undefined) {
queryString.append('createdAfter', options.createdAfter.toISOString());
}
if (options?.minimumSeverity !== undefined) {
queryString.append('minimumSeverity', options.minimumSeverity);
this.appendCommonQueryStrings(queryString, options);
if (options?.topic !== undefined) {
queryString.append('topic', options.topic);
}
return await this.request<GetNotificationsResponse>(
@@ -111,6 +105,34 @@ export class NotificationsClient implements NotificationsApi {
});
}
async getTopics(options?: GetTopicsOptions): Promise<GetTopicsResponse> {
const queryString = new URLSearchParams();
this.appendCommonQueryStrings(queryString, options);
return await this.request<GetTopicsResponse>(`/topics?${queryString}`);
}
private appendCommonQueryStrings(
queryString: URLSearchParams,
options?: GetNotificationsCommonOptions,
) {
if (options?.search) {
queryString.append('search', options.search);
}
if (options?.read !== undefined) {
queryString.append('read', options.read ? 'true' : 'false');
}
if (options?.saved !== undefined) {
queryString.append('saved', options.saved ? 'true' : 'false');
}
if (options?.createdAfter !== undefined) {
queryString.append('createdAfter', options.createdAfter.toISOString());
}
if (options?.minimumSeverity !== undefined) {
queryString.append('minimumSeverity', options.minimumSeverity);
}
}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = await this.discoveryApi.getBaseUrl('notifications');
const res = await this.fetchApi.fetch(`${baseUrl}${path}`, init);
@@ -40,8 +40,13 @@ export type NotificationsFiltersProps = {
onSavedChanged: (checked: boolean | undefined) => void;
severity: NotificationSeverity;
onSeverityChanged: (severity: NotificationSeverity) => void;
topic?: string;
onTopicChanged: (value: string | undefined) => void;
allTopics?: string[];
};
const ALL = '___all___';
export const CreatedAfterOptions: {
[key: string]: { label: string; getDate: () => Date };
} = {
@@ -127,6 +132,9 @@ export const NotificationsFilters = ({
onSavedChanged,
severity,
onSeverityChanged,
topic,
onTopicChanged,
allTopics,
}: NotificationsFiltersProps) => {
const sortByText = getSortByText(sorting);
@@ -180,6 +188,15 @@ export const NotificationsFilters = ({
onSeverityChanged(value);
};
const handleOnTopicChanged = (
event: React.ChangeEvent<{ name?: string; value: unknown }>,
) => {
const value = event.target.value as string;
onTopicChanged(value === ALL ? undefined : value);
};
const sortedAllTopics = (allTopics || []).sort((a, b) => a.localeCompare(b));
return (
<>
<Grid container>
@@ -265,6 +282,29 @@ export const NotificationsFilters = ({
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-topic">Topic</InputLabel>
<Select
label="Topic"
labelId="notifications-filter-topic"
value={topic ?? ALL}
onChange={handleOnTopicChanged}
>
<MenuItem value={ALL} key={ALL}>
Any topic
</MenuItem>
{sortedAllTopics.map((item: string) => (
<MenuItem value={item} key={item}>
{item}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</>
);
@@ -33,7 +33,11 @@ import {
SortBy,
SortByOptions,
} from '../NotificationsFilters';
import { GetNotificationsOptions, GetNotificationsResponse } from '../../api';
import {
GetNotificationsOptions,
GetNotificationsResponse,
GetTopicsResponse,
} from '../../api';
import {
NotificationSeverity,
NotificationStatus,
@@ -76,9 +80,10 @@ export const NotificationsPage = (props?: NotificationsPageProps) => {
SortByOptions.newest.sortBy,
);
const [severity, setSeverity] = React.useState<NotificationSeverity>('low');
const [topic, setTopic] = React.useState<string>();
const { error, value, retry, loading } = useNotificationsApi<
[GetNotificationsResponse, NotificationStatus]
[GetNotificationsResponse, NotificationStatus, GetTopicsResponse]
>(
api => {
const options: GetNotificationsOptions = {
@@ -94,13 +99,20 @@ export const NotificationsPage = (props?: NotificationsPageProps) => {
if (saved !== undefined) {
options.saved = saved;
}
if (topic !== undefined) {
options.topic = topic;
}
const createdAfterDate = CreatedAfterOptions[createdAfter].getDate();
if (createdAfterDate.valueOf() > 0) {
options.createdAfter = createdAfterDate;
}
return Promise.all([api.getNotifications(options), api.getStatus()]);
return Promise.all([
api.getNotifications(options),
api.getStatus(),
api.getTopics(options),
]);
},
[
containsText,
@@ -111,6 +123,7 @@ export const NotificationsPage = (props?: NotificationsPageProps) => {
sorting,
saved,
severity,
topic,
],
);
@@ -143,6 +156,7 @@ export const NotificationsPage = (props?: NotificationsPageProps) => {
const notifications = value?.[0]?.notifications;
const totalCount = value?.[0]?.totalCount;
const isUnread = !!value?.[1]?.unread;
const allTopics = value?.[2]?.topics;
let tableTitle = `All notifications (${totalCount})`;
if (saved) {
@@ -177,6 +191,9 @@ export const NotificationsPage = (props?: NotificationsPageProps) => {
onSavedChanged={setSaved}
severity={severity}
onSeverityChanged={setSeverity}
topic={topic}
onTopicChanged={setTopic}
allTopics={allTopics}
/>
</Grid>
<Grid item xs={10}>