Merge pull request #29301 from billyatroadie/add-topics-to-notification-settings
Adds ability for user to turn on/off notifications for specific topics within an origin.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-notifications': patch
|
||||
'@backstage/plugin-notifications-backend': patch
|
||||
'@backstage/plugin-notifications-common': patch
|
||||
'@backstage/plugin-notifications': patch
|
||||
---
|
||||
|
||||
Adds ability for user to turn on/off notifications for specific topics within an origin.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 263 KiB |
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.string('topic').nullable().after('origin');
|
||||
table.string('settings_key_hash', 64).notNullable();
|
||||
table.dropUnique([], 'user_settings_unique_idx');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.unique(['settings_key_hash'], 'user_settings_unique_idx');
|
||||
});
|
||||
|
||||
const rows = await knex('user_settings').select('user', 'channel', 'origin');
|
||||
for (const row of rows) {
|
||||
const rawKey = `${row.user}|${row.channel}|${row.origin}|}`;
|
||||
const hash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
await knex('user_settings')
|
||||
.where({
|
||||
user: row.user,
|
||||
channel: row.channel,
|
||||
origin: row.origin,
|
||||
topic: row.topic,
|
||||
})
|
||||
.update({ settings_key_hash: hash });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.table('user_settings', table => {
|
||||
table.dropUnique([], 'user_settings_unique_idx');
|
||||
table.dropColumn('settings_key_hash');
|
||||
table.dropColumn('topic');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.unique(['user', 'channel', 'origin'], {
|
||||
indexName: 'user_settings_unique_idx',
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -63,14 +63,16 @@
|
||||
|
||||
## Table `user_settings`
|
||||
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
| --------- | ------------------- | -------- | ---------- | ------- |
|
||||
| `channel` | `character varying` | false | 255 | - |
|
||||
| `enabled` | `boolean` | false | - | `true` |
|
||||
| `origin` | `character varying` | false | 255 | - |
|
||||
| `user` | `character varying` | false | 255 | - |
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
| ------------------- | ------------------- | -------- | ---------- | ------- |
|
||||
| `channel` | `character varying` | false | 255 | - |
|
||||
| `enabled` | `boolean` | false | - | `true` |
|
||||
| `origin` | `character varying` | false | 255 | - |
|
||||
| `settings_key_hash` | `character varying` | false | 64 | - |
|
||||
| `topic` | `character varying` | true | 255 | - |
|
||||
| `user` | `character varying` | false | 255 | - |
|
||||
|
||||
### Indices
|
||||
|
||||
- `user_settings_unique_idx` (`user`, `channel`, `origin`) unique
|
||||
- `user_settings_unique_idx` (`settings_key_hash`) unique
|
||||
- `user_settings_user_idx` (`user`)
|
||||
|
||||
@@ -157,12 +157,19 @@ const notificationSettings: NotificationSettings = {
|
||||
id: 'Web',
|
||||
origins: [
|
||||
{
|
||||
id: 'plugin-test',
|
||||
id: 'abcd-origin',
|
||||
enabled: true,
|
||||
topics: [
|
||||
{
|
||||
id: 'efgh-topic',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plugin-test2',
|
||||
enabled: false,
|
||||
id: 'plugin-test',
|
||||
enabled: true,
|
||||
topics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
NotificationSeverity,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { Knex } from 'knex';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-notifications-backend',
|
||||
@@ -105,6 +106,16 @@ export const normalizeSeverity = (input?: string): NotificationSeverity => {
|
||||
return lower;
|
||||
};
|
||||
|
||||
export const generateSettingsHash = (
|
||||
user: string,
|
||||
channel: string,
|
||||
origin: string,
|
||||
topic: string | null,
|
||||
): string => {
|
||||
const rawKey = `${user}|${channel}|${origin}|${topic ?? ''}`;
|
||||
return crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
private readonly isSQLite = false;
|
||||
@@ -169,10 +180,31 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
});
|
||||
chan = acc.channels[acc.channels.length - 1];
|
||||
}
|
||||
chan.origins.push({
|
||||
id: row.origin,
|
||||
enabled: Boolean(row.enabled),
|
||||
});
|
||||
let origin = chan.origins.find(
|
||||
(ori: { id: string }) => ori.id === row.origin,
|
||||
);
|
||||
if (!origin) {
|
||||
origin = {
|
||||
id: row.origin,
|
||||
enabled: true,
|
||||
topics: [],
|
||||
};
|
||||
chan.origins.push(origin);
|
||||
}
|
||||
if (row.topic === null) {
|
||||
origin.enabled = Boolean(row.enabled);
|
||||
} else {
|
||||
let topic = origin.topics.find(
|
||||
(top: { id: string }) => top.id === row.topic,
|
||||
);
|
||||
if (!topic) {
|
||||
topic = {
|
||||
id: row.topic,
|
||||
enabled: Boolean(row.enabled),
|
||||
};
|
||||
origin.topics.push(topic);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ channels: [] },
|
||||
@@ -518,10 +550,26 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
return { origins: rows.map(row => row.origin) };
|
||||
}
|
||||
|
||||
async getUserNotificationTopics(options: {
|
||||
user: string;
|
||||
}): Promise<{ topics: { origin: string; topic: string }[] }> {
|
||||
const rows: { topic: string; origin: string }[] =
|
||||
await this.db<NotificationRowType>('notification')
|
||||
.where('user', options.user)
|
||||
.select('topic', 'origin')
|
||||
.whereNotNull('topic')
|
||||
.distinct();
|
||||
|
||||
return {
|
||||
topics: rows.map(row => ({ origin: row.origin, topic: row.topic })),
|
||||
};
|
||||
}
|
||||
|
||||
async getNotificationSettings(options: {
|
||||
user: string;
|
||||
origin?: string;
|
||||
channel?: string;
|
||||
topic?: string;
|
||||
}): Promise<NotificationSettings> {
|
||||
const settingsQuery = this.db<UserSettingsRowType>('user_settings').where(
|
||||
'user',
|
||||
@@ -534,6 +582,10 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
if (options.channel) {
|
||||
settingsQuery.where('channel', options.channel);
|
||||
}
|
||||
|
||||
if (options.topic) {
|
||||
settingsQuery.where('topic', options.topic);
|
||||
}
|
||||
const settings = await settingsQuery.select();
|
||||
return this.mapToNotificationSettings(settings);
|
||||
}
|
||||
@@ -543,19 +595,45 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
settings: NotificationSettings;
|
||||
}): Promise<void> {
|
||||
const rows: {
|
||||
settings_key_hash: string;
|
||||
user: string;
|
||||
channel: string;
|
||||
origin: string;
|
||||
topic: string | null;
|
||||
enabled: boolean;
|
||||
}[] = [];
|
||||
options.settings.channels.map(channel => {
|
||||
channel.origins.map(origin => {
|
||||
|
||||
options.settings.channels.forEach(channel => {
|
||||
channel.origins.forEach(origin => {
|
||||
rows.push({
|
||||
settings_key_hash: generateSettingsHash(
|
||||
options.user,
|
||||
channel.id,
|
||||
origin.id,
|
||||
null,
|
||||
),
|
||||
user: options.user,
|
||||
channel: channel.id,
|
||||
origin: origin.id,
|
||||
topic: null,
|
||||
enabled: origin.enabled,
|
||||
});
|
||||
|
||||
origin.topics?.forEach(topic => {
|
||||
rows.push({
|
||||
settings_key_hash: generateSettingsHash(
|
||||
options.user,
|
||||
channel.id,
|
||||
origin.id,
|
||||
topic.id,
|
||||
),
|
||||
user: options.user,
|
||||
channel: channel.id,
|
||||
origin: origin.id,
|
||||
topic: topic.id,
|
||||
enabled: origin.enabled && topic.enabled,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -307,9 +307,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
expect(notifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not send to user entity if disabled in settings', async () => {
|
||||
it('should not send to user entity if origin is disabled in settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
@@ -335,6 +336,85 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not send to user entity if topic is disabled in settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
topic: 'test-topic',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const response = await sendNotification({
|
||||
recipients: {
|
||||
type: 'entity',
|
||||
entityRef: ['user:default/mock'],
|
||||
},
|
||||
payload: {
|
||||
title: 'test notification',
|
||||
topic: 'test-topic',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([]);
|
||||
|
||||
const notifications = await client('notification')
|
||||
.where('user', 'user:default/mock')
|
||||
.select();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should send to user entity if origin is enabled, but topic is disabled in settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
enabled: true,
|
||||
});
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash1',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
topic: 'test-topic',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const response = await sendNotification({
|
||||
recipients: {
|
||||
type: 'entity',
|
||||
entityRef: ['user:default/mock'],
|
||||
},
|
||||
payload: {
|
||||
title: 'test notification',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
created: expect.any(String),
|
||||
id: expect.any(String),
|
||||
origin: 'external:test-service',
|
||||
payload: {
|
||||
severity: 'normal',
|
||||
title: 'test notification',
|
||||
},
|
||||
user: 'user:default/mock',
|
||||
},
|
||||
]);
|
||||
|
||||
const notifications = await client('notification')
|
||||
.where('user', 'user:default/mock')
|
||||
.select();
|
||||
expect(notifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should fail without recipients', async () => {
|
||||
const response = await sendNotification({
|
||||
payload: {
|
||||
@@ -490,6 +570,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
it('should return user settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
@@ -502,7 +583,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
channels: [
|
||||
{
|
||||
id: 'Web',
|
||||
origins: [{ enabled: false, id: 'external:test-service' }],
|
||||
origins: [
|
||||
{ enabled: false, id: 'external:test-service', topics: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { SignalsService } from '@backstage/plugin-signals-node';
|
||||
import {
|
||||
ChannelSetting,
|
||||
isNotificationsEnabledFor,
|
||||
NewNotificationSignal,
|
||||
Notification,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
NotificationSettings,
|
||||
notificationSeverities,
|
||||
NotificationStatus,
|
||||
OriginSetting,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
|
||||
import { getUsersForEntityRef } from './getUsersForEntityRef';
|
||||
@@ -104,40 +106,86 @@ export async function createRouter(
|
||||
return info.userEntityRef;
|
||||
};
|
||||
|
||||
const getTopicSettings = (
|
||||
topic: any,
|
||||
existingOrigin: OriginSetting | undefined,
|
||||
defaultEnabled: boolean,
|
||||
) => {
|
||||
const existingTopic = existingOrigin?.topics?.find(
|
||||
t => t.id === topic.topic,
|
||||
);
|
||||
return {
|
||||
id: topic.topic,
|
||||
enabled: existingTopic ? existingTopic.enabled : defaultEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
const getOriginSettings = (
|
||||
originId: string,
|
||||
existingChannel: ChannelSetting | undefined,
|
||||
topics: { origin: string; topic: string }[],
|
||||
) => {
|
||||
const existingOrigin = existingChannel?.origins.find(
|
||||
o => o.id === originId,
|
||||
);
|
||||
const defaultEnabled = existingOrigin ? existingOrigin.enabled : true;
|
||||
return {
|
||||
id: originId,
|
||||
enabled: defaultEnabled,
|
||||
topics: topics
|
||||
.filter(t => t.origin === originId)
|
||||
.map(t => getTopicSettings(t, existingOrigin, defaultEnabled)),
|
||||
};
|
||||
};
|
||||
|
||||
const getNotificationChannels = () => {
|
||||
return [WEB_NOTIFICATION_CHANNEL, ...processors.map(p => p.getName())];
|
||||
};
|
||||
|
||||
const getChannelSettings = (
|
||||
channelId: string,
|
||||
settings: NotificationSettings,
|
||||
origins: string[],
|
||||
topics: { origin: string; topic: string }[],
|
||||
) => {
|
||||
const existingChannel = settings.channels.find(c => c.id === channelId);
|
||||
if (existingChannel) {
|
||||
return existingChannel;
|
||||
}
|
||||
return {
|
||||
id: channelId,
|
||||
origins: origins.map(originId =>
|
||||
getOriginSettings(originId, existingChannel, topics),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getNotificationSettings = async (user: string) => {
|
||||
const { origins } = await store.getUserNotificationOrigins({ user });
|
||||
const { topics } = await store.getUserNotificationTopics({ user });
|
||||
const settings = await store.getNotificationSettings({ user });
|
||||
const channels = getNotificationChannels();
|
||||
|
||||
const response: NotificationSettings = {
|
||||
channels: channels.map(channel => {
|
||||
const channelSettings = settings.channels.find(c => c.id === channel);
|
||||
if (channelSettings) {
|
||||
return channelSettings;
|
||||
}
|
||||
return {
|
||||
id: channel,
|
||||
origins: origins.map(origin => ({
|
||||
id: origin,
|
||||
enabled: true,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
return {
|
||||
channels: channels.map(channelId =>
|
||||
getChannelSettings(channelId, settings, origins, topics),
|
||||
),
|
||||
};
|
||||
return response;
|
||||
};
|
||||
|
||||
const isNotificationsEnabled = async (opts: {
|
||||
user: string;
|
||||
channel: string;
|
||||
origin: string;
|
||||
topic: string | null;
|
||||
}) => {
|
||||
const settings = await getNotificationSettings(opts.user);
|
||||
return isNotificationsEnabledFor(settings, opts.channel, opts.origin);
|
||||
return isNotificationsEnabledFor(
|
||||
settings,
|
||||
opts.channel,
|
||||
opts.origin,
|
||||
opts.topic,
|
||||
);
|
||||
};
|
||||
|
||||
const filterProcessors = async (
|
||||
@@ -154,6 +202,7 @@ export async function createRouter(
|
||||
user,
|
||||
origin,
|
||||
channel: processor.getName(),
|
||||
topic: payload.topic ?? null,
|
||||
});
|
||||
if (!enabled) {
|
||||
continue;
|
||||
@@ -508,6 +557,7 @@ export async function createRouter(
|
||||
user,
|
||||
channel: WEB_NOTIFICATION_CHANNEL,
|
||||
origin: userNotification.origin,
|
||||
topic: userNotification.payload.topic ?? null,
|
||||
});
|
||||
|
||||
let ret = notification;
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
```ts
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
// @public (undocumented)
|
||||
export type ChannelSetting = {
|
||||
id: string;
|
||||
origins: OriginSetting[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const getProcessorFiltersFromConfig: (
|
||||
config: Config,
|
||||
@@ -15,6 +21,7 @@ export const isNotificationsEnabledFor: (
|
||||
settings: NotificationSettings,
|
||||
channelId: string,
|
||||
originId: string,
|
||||
topicId: string | null,
|
||||
) => boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -62,13 +69,7 @@ export type NotificationReadSignal = {
|
||||
|
||||
// @public (undocumented)
|
||||
export type NotificationSettings = {
|
||||
channels: {
|
||||
id: string;
|
||||
origins: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}[];
|
||||
}[];
|
||||
channels: ChannelSetting[];
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -85,4 +86,17 @@ export type NotificationStatus = {
|
||||
unread: number;
|
||||
read: number;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type OriginSetting = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
topics?: TopicSetting[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type TopicSetting = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -129,12 +129,31 @@ export type NotificationProcessorFilters = {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type NotificationSettings = {
|
||||
channels: {
|
||||
id: string;
|
||||
origins: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}[];
|
||||
}[];
|
||||
export type TopicSetting = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type OriginSetting = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
topics?: TopicSetting[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ChannelSetting = {
|
||||
id: string;
|
||||
origins: OriginSetting[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type NotificationSettings = {
|
||||
channels: ChannelSetting[];
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export const isNotificationsEnabledFor = (
|
||||
settings: NotificationSettings,
|
||||
channelId: string,
|
||||
originId: string,
|
||||
topicId: string | null,
|
||||
) => {
|
||||
const channel = settings.channels.find(c => c.id === channelId);
|
||||
if (!channel) {
|
||||
@@ -30,5 +31,12 @@ export const isNotificationsEnabledFor = (
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
return origin.enabled;
|
||||
if (topicId === null) {
|
||||
return origin.enabled;
|
||||
}
|
||||
const topic = origin.topics?.find(t => t.id === topicId);
|
||||
if (!topic) {
|
||||
return origin.enabled;
|
||||
}
|
||||
return topic.enabled;
|
||||
};
|
||||
|
||||
@@ -207,6 +207,7 @@ export function useNotificationsApi<T>(
|
||||
// @public (undocumented)
|
||||
export const UserNotificationSettingsCard: (props: {
|
||||
originNames?: Record<string, string>;
|
||||
topicNames?: Record<string, string>;
|
||||
}) => JSX_2.Element;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 { withStyles } from '@material-ui/core/styles';
|
||||
import MuiTableCell from '@material-ui/core/TableCell';
|
||||
|
||||
export const NoBorderTableCell = withStyles({
|
||||
root: {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
})(MuiTableCell);
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ChannelSetting,
|
||||
isNotificationsEnabledFor,
|
||||
NotificationSettings,
|
||||
OriginSetting,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Switch from '@material-ui/core/Switch';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
|
||||
import { NoBorderTableCell } from './NoBorderTableCell';
|
||||
import { useNotificationFormat } from './UserNotificationSettingsCard';
|
||||
|
||||
export const OriginRow = (props: {
|
||||
channel: ChannelSetting;
|
||||
origin: OriginSetting;
|
||||
settings: NotificationSettings;
|
||||
handleChange: (
|
||||
channel: string,
|
||||
origin: string,
|
||||
topic: string | null,
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
open: boolean;
|
||||
handleRowToggle: (originId: string) => void;
|
||||
}) => {
|
||||
const { channel, origin, settings, handleChange, open, handleRowToggle } =
|
||||
props;
|
||||
const { formatOriginName } = useNotificationFormat();
|
||||
return (
|
||||
<TableRow>
|
||||
<NoBorderTableCell>
|
||||
{origin.topics && origin.topics.length > 0 && (
|
||||
<Tooltip
|
||||
title={`Show Topics for the ${formatOriginName(origin.id)} origin`}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="expand row"
|
||||
size="small"
|
||||
onClick={() => handleRowToggle(origin.id)}
|
||||
>
|
||||
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</NoBorderTableCell>
|
||||
<NoBorderTableCell>{formatOriginName(origin.id)}</NoBorderTableCell>
|
||||
<NoBorderTableCell>all</NoBorderTableCell>
|
||||
{settings.channels.map(ch => (
|
||||
<NoBorderTableCell key={ch.id} align="center">
|
||||
<Tooltip
|
||||
title={`Enable or disable ${channel.id.toLocaleLowerCase(
|
||||
'en-US',
|
||||
)} notifications from ${formatOriginName(origin.id)}`}
|
||||
>
|
||||
<Switch
|
||||
checked={isNotificationsEnabledFor(
|
||||
settings,
|
||||
ch.id,
|
||||
origin.id,
|
||||
null,
|
||||
)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(ch.id, origin.id, null, event.target.checked);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</NoBorderTableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
isNotificationsEnabledFor,
|
||||
NotificationSettings,
|
||||
OriginSetting,
|
||||
TopicSetting,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import Switch from '@material-ui/core/Switch';
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
import { NoBorderTableCell } from './NoBorderTableCell';
|
||||
import { useNotificationFormat } from './UserNotificationSettingsCard';
|
||||
|
||||
const TopicTableRow = withStyles({
|
||||
root: {
|
||||
paddingLeft: '4px',
|
||||
},
|
||||
})(TableRow);
|
||||
|
||||
export const TopicRow = (props: {
|
||||
topic: TopicSetting;
|
||||
origin: OriginSetting;
|
||||
settings: NotificationSettings;
|
||||
handleChange: (
|
||||
channel: string,
|
||||
origin: string,
|
||||
topic: string | null,
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
}) => {
|
||||
const { topic, origin, settings, handleChange } = props;
|
||||
const { formatOriginName, formatTopicName } = useNotificationFormat();
|
||||
return (
|
||||
<TopicTableRow>
|
||||
<NoBorderTableCell />
|
||||
<NoBorderTableCell />
|
||||
<NoBorderTableCell>{formatTopicName(topic.id)}</NoBorderTableCell>
|
||||
{settings.channels.map(ch => (
|
||||
<NoBorderTableCell key={`${ch.id}-${topic}`} align="center">
|
||||
<Tooltip
|
||||
title={`Enable or disable ${ch.id.toLocaleLowerCase(
|
||||
'en-US',
|
||||
)} notifications for the ${formatTopicName(
|
||||
topic.id,
|
||||
)} topic from ${formatOriginName(origin.id)}`}
|
||||
>
|
||||
<Switch
|
||||
checked={isNotificationsEnabledFor(
|
||||
settings,
|
||||
ch.id,
|
||||
origin.id,
|
||||
topic.id,
|
||||
)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(ch.id, origin.id, topic.id, event.target.checked);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</NoBorderTableCell>
|
||||
))}
|
||||
</TopicTableRow>
|
||||
);
|
||||
};
|
||||
+67
-6
@@ -14,17 +14,74 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createContext, useState, useContext, useEffect } from 'react';
|
||||
import { ErrorPanel, InfoCard, Progress } from '@backstage/core-components';
|
||||
import { useNotificationsApi } from '../../hooks';
|
||||
import { NotificationSettings } from '@backstage/plugin-notifications-common';
|
||||
import { notificationsApiRef } from '../../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { UserNotificationSettingsPanel } from './UserNotificationSettingsPanel';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
type FormatContextType = {
|
||||
formatOriginName: (id: string) => string;
|
||||
formatTopicName: (id: string) => string;
|
||||
};
|
||||
|
||||
const NotificationFormatContext = createContext<FormatContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useNotificationFormat = () => {
|
||||
const context = useContext(NotificationFormatContext);
|
||||
if (!context)
|
||||
throw new Error(
|
||||
'useNotificationFormat must be used within a NotificationFormatProvider',
|
||||
);
|
||||
return context;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
originMap: Record<string, string> | undefined;
|
||||
topicMap: Record<string, string> | undefined;
|
||||
};
|
||||
|
||||
export const NotificationFormatProvider = ({
|
||||
children,
|
||||
originMap,
|
||||
topicMap,
|
||||
}: Props) => {
|
||||
const formatName = (
|
||||
id: string,
|
||||
nameMap: Record<string, string> | undefined,
|
||||
) => {
|
||||
if (nameMap && id in nameMap) {
|
||||
return nameMap[id];
|
||||
}
|
||||
return capitalize(id.replaceAll(/[-_:]/g, ' '));
|
||||
};
|
||||
|
||||
const formatOriginName = (originId: string) => {
|
||||
return formatName(originId, originMap);
|
||||
};
|
||||
|
||||
const formatTopicName = (topicId: string) => {
|
||||
return formatName(topicId, topicMap);
|
||||
};
|
||||
return (
|
||||
<NotificationFormatContext.Provider
|
||||
value={{ formatOriginName, formatTopicName }}
|
||||
>
|
||||
{children}
|
||||
</NotificationFormatContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const UserNotificationSettingsCard = (props: {
|
||||
originNames?: Record<string, string>;
|
||||
topicNames?: Record<string, string>;
|
||||
}) => {
|
||||
const [settings, setNotificationSettings] = useState<
|
||||
NotificationSettings | undefined
|
||||
@@ -52,11 +109,15 @@ export const UserNotificationSettingsCard = (props: {
|
||||
{loading && <Progress />}
|
||||
{error && <ErrorPanel title="Failed to load settings" error={error} />}
|
||||
{settings && (
|
||||
<UserNotificationSettingsPanel
|
||||
settings={settings}
|
||||
onChange={onUpdate}
|
||||
originNames={props.originNames}
|
||||
/>
|
||||
<NotificationFormatProvider
|
||||
originMap={props.originNames}
|
||||
topicMap={props.topicNames}
|
||||
>
|
||||
<UserNotificationSettingsPanel
|
||||
settings={settings}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
</NotificationFormatProvider>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
+72
-52
@@ -14,11 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ChangeEvent } from 'react';
|
||||
import {
|
||||
isNotificationsEnabledFor,
|
||||
NotificationSettings,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { useState } from 'react';
|
||||
import { NotificationSettings } from '@backstage/plugin-notifications-common';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import MuiTableCell from '@material-ui/core/TableCell';
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
@@ -26,9 +23,8 @@ import TableHead from '@material-ui/core/TableHead';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Switch from '@material-ui/core/Switch';
|
||||
import { capitalize } from 'lodash';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import { TopicRow } from './TopicRow';
|
||||
import { OriginRow } from './OriginRow';
|
||||
|
||||
const TableCell = withStyles({
|
||||
root: {
|
||||
@@ -40,19 +36,26 @@ export const UserNotificationSettingsPanel = (props: {
|
||||
settings: NotificationSettings;
|
||||
onChange: (settings: NotificationSettings) => void;
|
||||
originNames?: Record<string, string>;
|
||||
topicNames?: Record<string, string>;
|
||||
}) => {
|
||||
const { settings, onChange } = props;
|
||||
const allOrigins = [
|
||||
...new Set(
|
||||
settings.channels.flatMap(channel =>
|
||||
channel.origins.map(origin => origin.id),
|
||||
),
|
||||
),
|
||||
];
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleRowToggle = (originId: string) => {
|
||||
setExpandedRows(prevState => {
|
||||
const newExpandedRows = new Set(prevState);
|
||||
if (newExpandedRows.has(originId)) {
|
||||
newExpandedRows.delete(originId);
|
||||
} else {
|
||||
newExpandedRows.add(originId);
|
||||
}
|
||||
return newExpandedRows;
|
||||
});
|
||||
};
|
||||
const handleChange = (
|
||||
channelId: string,
|
||||
originId: string,
|
||||
topicId: string | null,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
const updatedSettings = {
|
||||
@@ -66,9 +69,30 @@ export const UserNotificationSettingsPanel = (props: {
|
||||
if (origin.id !== originId) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
if (topicId === null) {
|
||||
return {
|
||||
...origin,
|
||||
enabled,
|
||||
topics:
|
||||
origin.topics?.map(topic => {
|
||||
return { ...topic, enabled };
|
||||
}) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...origin,
|
||||
enabled,
|
||||
topics:
|
||||
origin.topics?.map(topic => {
|
||||
if (topic.id === topicId) {
|
||||
return {
|
||||
...topic,
|
||||
enabled: origin.enabled ? enabled : origin.enabled,
|
||||
};
|
||||
}
|
||||
return topic;
|
||||
}) ?? [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -77,14 +101,7 @@ export const UserNotificationSettingsPanel = (props: {
|
||||
onChange(updatedSettings);
|
||||
};
|
||||
|
||||
const formatOriginName = (originId: string) => {
|
||||
if (props.originNames && originId in props.originNames) {
|
||||
return props.originNames[originId];
|
||||
}
|
||||
return capitalize(originId.replaceAll(/[_:]/g, ' '));
|
||||
};
|
||||
|
||||
if (settings.channels.length === 0 || allOrigins.length === 0) {
|
||||
if (settings.channels.length === 0) {
|
||||
return (
|
||||
<Typography variant="body1">
|
||||
No notification settings available, check back later
|
||||
@@ -96,44 +113,47 @@ export const UserNotificationSettingsPanel = (props: {
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">Origin</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">Topic</Typography>
|
||||
</TableCell>
|
||||
{settings.channels.map(channel => (
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">{channel.id}</Typography>
|
||||
<TableCell key={channel.id}>
|
||||
<Typography variant="subtitle1" align="center">
|
||||
{channel.id}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{allOrigins.map(origin => (
|
||||
<TableRow>
|
||||
<TableCell>{formatOriginName(origin)}</TableCell>
|
||||
{settings.channels.map(channel => (
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={`Enable or disable ${channel.id.toLocaleLowerCase(
|
||||
'en-US',
|
||||
)} notifications from ${formatOriginName(
|
||||
origin,
|
||||
).toLocaleLowerCase('en-US')}`}
|
||||
>
|
||||
<Switch
|
||||
checked={isNotificationsEnabledFor(
|
||||
settings,
|
||||
channel.id,
|
||||
origin,
|
||||
)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(channel.id, origin, event.target.checked);
|
||||
}}
|
||||
{settings.channels.map(channel =>
|
||||
channel.origins.flatMap(origin => [
|
||||
<OriginRow
|
||||
key={origin.id}
|
||||
channel={channel}
|
||||
origin={origin}
|
||||
settings={settings}
|
||||
open={expandedRows.has(origin.id)}
|
||||
handleChange={handleChange}
|
||||
handleRowToggle={handleRowToggle}
|
||||
/>,
|
||||
...(expandedRows.has(origin.id)
|
||||
? origin.topics?.map(topic => (
|
||||
<TopicRow
|
||||
key={`${origin.id}-${topic.id}`}
|
||||
topic={topic}
|
||||
origin={origin}
|
||||
settings={settings}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
)) || []
|
||||
: []),
|
||||
]),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createSendNotificationAction(options: {
|
||||
link?: string | undefined;
|
||||
severity?: 'normal' | 'high' | 'low' | 'critical' | undefined;
|
||||
scope?: string | undefined;
|
||||
topic?: string | undefined;
|
||||
optional?: boolean | undefined;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ export function createSendNotificationAction(options: {
|
||||
.optional()
|
||||
.describe('Notification severity'),
|
||||
scope: z => z.string().optional().describe('Notification scope'),
|
||||
topic: z => z.string().optional().describe('Notification topic'),
|
||||
optional: z =>
|
||||
z
|
||||
.boolean()
|
||||
@@ -73,6 +74,7 @@ export function createSendNotificationAction(options: {
|
||||
info,
|
||||
link,
|
||||
severity,
|
||||
topic,
|
||||
scope,
|
||||
optional,
|
||||
} = ctx.input;
|
||||
@@ -94,6 +96,7 @@ export function createSendNotificationAction(options: {
|
||||
description: info,
|
||||
link,
|
||||
severity,
|
||||
topic,
|
||||
scope,
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
- normal
|
||||
- high
|
||||
- critical
|
||||
topic:
|
||||
title: Topic
|
||||
type: string
|
||||
description: Notification topic
|
||||
scope:
|
||||
title: Scope
|
||||
type: string
|
||||
@@ -66,4 +70,5 @@ spec:
|
||||
description: ${{ parameters.description }}
|
||||
link: ${{ parameters.link }}
|
||||
severity: ${{ parameters.severity }}
|
||||
topic: ${{ parameters.topic }}
|
||||
scope: ${{ parameters.scope }}
|
||||
|
||||
Reference in New Issue
Block a user