diff --git a/docs/notifications/notificationSettings.png b/docs/notifications/notificationSettings.png index cdad63d764..d4c2535ece 100644 Binary files a/docs/notifications/notificationSettings.png and b/docs/notifications/notificationSettings.png differ diff --git a/package.json b/package.json index 083c70e483..74b98d9d65 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "build:plugins-report": "node ./scripts/build-plugins-report", "clean": "backstage-cli repo clean", "create-plugin": "echo \"use 'yarn new' instead\"", - "dev": "yarn workspaces foreach -A --include example-backend --include example-app --parallel --jobs unlimited -v -i run start", + "dev": "NODE_OPTIONS=--no-node-snapshot yarn workspaces foreach -A --include example-backend --include example-app --parallel --jobs unlimited -v -i run start", "dev:next": "yarn workspaces foreach -A --include example-backend --include example-app-next --parallel --jobs unlimited -v -i run start", "docker-build": "yarn tsc && yarn workspace example-backend build && yarn workspace example-backend build-image", "fix": "backstage-cli repo fix --publish", diff --git a/plugins/notifications-backend/migrations/20250317_addTopic.js b/plugins/notifications-backend/migrations/20250317_addTopic.js new file mode 100644 index 0000000000..3a21ee13cb --- /dev/null +++ b/plugins/notifications-backend/migrations/20250317_addTopic.js @@ -0,0 +1,43 @@ +/* + * 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. + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('user_settings', table => { + table.string('topic', 255).nullable().after('origin'); + }); + + await knex.raw( + 'ALTER TABLE user_settings DROP CONSTRAINT user_settings_unique_idx', + ); + + await knex.schema.alterTable('user_settings', table => { + table.unique(['user', 'channel', 'origin', 'topic'], { + indexName: 'user_settings_unique_idx', + }); + }); +}; + +exports.down = async function down(knex) { + await knex.raw( + 'ALTER TABLE user_settings DROP CONSTRAINT user_settings_unique_idx', + ); + + await knex.schema.alterTable('user_settings', table => { + table.dropColumn('topic'); + table.unique(['user', 'channel', 'origin'], { + indexName: 'user_settings_unique_idx', + }); + }); +}; diff --git a/plugins/notifications-backend/report.sql.md b/plugins/notifications-backend/report.sql.md index 6b9066e77c..6f3495b265 100644 --- a/plugins/notifications-backend/report.sql.md +++ b/plugins/notifications-backend/report.sql.md @@ -68,9 +68,10 @@ | `channel` | `character varying` | false | 255 | - | | `enabled` | `boolean` | false | - | `true` | | `origin` | `character varying` | false | 255 | - | +| `topic` | `character varying` | true | 255 | - | | `user` | `character varying` | false | 255 | - | ### Indices -- `user_settings_unique_idx` (`user`, `channel`, `origin`) unique +- `user_settings_unique_idx` (`user`, `channel`, `origin`, `topic`) unique - `user_settings_user_idx` (`user`) diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts index 0386b516a2..d0551cb777 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -161,8 +161,14 @@ const notificationSettings: NotificationSettings = { enabled: true, }, { - id: 'plugin-test2', - enabled: false, + id: 'abcd-origin', + enabled: true, + topics: [ + { + id: 'efgh-topic', + enabled: false, + }, + ], }, ], }, diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index b258f38e7a..d4bc881fbb 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -169,10 +169,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 +539,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('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 { const settingsQuery = this.db('user_settings').where( 'user', @@ -534,6 +571,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); } @@ -546,16 +587,29 @@ export class DatabaseNotificationsStore implements NotificationsStore { 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({ user: options.user, channel: channel.id, origin: origin.id, + topic: null, enabled: origin.enabled, }); + + origin.topics?.forEach(topic => { + rows.push({ + user: options.user, + channel: channel.id, + origin: origin.id, + topic: topic.id, + enabled: topic.enabled, + }); + }); }); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 28defa9081..126963932b 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -95,21 +95,42 @@ export async function createRouter( 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; - } + channels: channels.map(channelId => { return { - id: channel, - origins: origins.map(origin => ({ - id: origin, - enabled: true, - })), + id: channelId, + origins: origins.map(originId => { + const existingChannel = settings.channels.find( + c => c.id === channelId, + ); + 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 => { + const existingTopic = + existingOrigin?.topics && + existingOrigin.topics.find(topic => topic.id === t.topic); + return { + id: t.topic, + enabled: existingTopic + ? existingTopic.enabled + : defaultEnabled, + }; + }), + }; + }), }; }), }; @@ -120,9 +141,15 @@ export async function createRouter( 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 ( @@ -139,6 +166,7 @@ export async function createRouter( user, origin, channel: processor.getName(), + topic: payload.topic ?? null, }); if (!enabled) { continue; @@ -496,6 +524,7 @@ export async function createRouter( user, channel: WEB_NOTIFICATION_CHANNEL, origin: userNotification.origin, + topic: userNotification.payload.topic ?? null, }); let ret = notification; diff --git a/plugins/notifications-common/report.api.md b/plugins/notifications-common/report.api.md index 167b37349d..0aeb49f71f 100644 --- a/plugins/notifications-common/report.api.md +++ b/plugins/notifications-common/report.api.md @@ -15,6 +15,7 @@ export const isNotificationsEnabledFor: ( settings: NotificationSettings, channelId: string, originId: string, + topicId: string | null, ) => boolean; // @public (undocumented) @@ -67,6 +68,10 @@ export type NotificationSettings = { origins: { id: string; enabled: boolean; + topics?: { + id: string; + enabled: boolean; + }[]; }[]; }[]; }; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 237f93f40a..4bf9bf1bac 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -135,6 +135,10 @@ export type NotificationSettings = { origins: { id: string; enabled: boolean; + topics?: { + id: string; + enabled: boolean; + }[]; }[]; }[]; }; diff --git a/plugins/notifications-common/src/utils.ts b/plugins/notifications-common/src/utils.ts index c57412bd97..b95df5bea5 100644 --- a/plugins/notifications-common/src/utils.ts +++ b/plugins/notifications-common/src/utils.ts @@ -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; }; diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx index dc9f2a223f..691c95e430 100644 --- a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx @@ -19,6 +19,7 @@ import { isNotificationsEnabledFor, NotificationSettings, } from '@backstage/plugin-notifications-common'; +import ChevronRight from '@material-ui/icons/ChevronRight'; import Table from '@material-ui/core/Table'; import MuiTableCell from '@material-ui/core/TableCell'; import { withStyles } from '@material-ui/core/styles'; @@ -36,23 +37,29 @@ const TableCell = withStyles({ }, })(MuiTableCell); +const CenterContentsTableCell = withStyles({ + root: { + display: 'flex', + alignItems: 'center', + }, +})(MuiTableCell); + +const TopicTableRow = withStyles({ + root: { + paddingLeft: '4px', + }, +})(TableRow); + export const UserNotificationSettingsPanel = (props: { settings: NotificationSettings; onChange: (settings: NotificationSettings) => void; originNames?: Record; }) => { const { settings, onChange } = props; - const allOrigins = [ - ...new Set( - settings.channels.flatMap(channel => - channel.origins.map(origin => origin.id), - ), - ), - ]; - const handleChange = ( channelId: string, originId: string, + topicId: string | null, enabled: boolean, ) => { const updatedSettings = { @@ -66,9 +73,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; + }) ?? [], }; }), }; @@ -84,7 +112,7 @@ export const UserNotificationSettingsPanel = (props: { return capitalize(originId.replaceAll(/[_:]/g, ' ')); }; - if (settings.channels.length === 0 || allOrigins.length === 0) { + if (settings.channels.length === 0) { return ( No notification settings available, check back later @@ -96,44 +124,93 @@ export const UserNotificationSettingsPanel = (props: { - - Origin - + + Origin {' '} + Topic + {settings.channels.map(channel => ( - {channel.id} + + {channel.id} + ))} - {allOrigins.map(origin => ( - - {formatOriginName(origin)} - {settings.channels.map(channel => ( - - - ) => { - handleChange(channel.id, origin, event.target.checked); - }} - /> - - - ))} - - ))} + {settings.channels.map(channel => + channel.origins.flatMap(origin => [ + + {formatOriginName(origin.id)} + {settings.channels.map(ch => ( + + + , + ) => { + handleChange( + ch.id, + origin.id, + null, + event.target.checked, + ); + }} + /> + + + ))} + , + ...(origin.topics?.map(topic => ( + + + + {topic.id} + + {settings.channels.map(ch => ( + + + , + ) => { + handleChange( + ch.id, + origin.id, + topic.id, + event.target.checked, + ); + }} + /> + + + ))} + + )) || []), + ]), + )}
); diff --git a/plugins/scaffolder-backend-module-notifications/report.api.md b/plugins/scaffolder-backend-module-notifications/report.api.md index cb796e5033..9b2c7ee360 100644 --- a/plugins/scaffolder-backend-module-notifications/report.api.md +++ b/plugins/scaffolder-backend-module-notifications/report.api.md @@ -17,6 +17,7 @@ export function createSendNotificationAction(options: { recipients: string; entityRefs?: string[]; title: string; + topic?: string; info?: string; link?: string; severity?: NotificationSeverity; diff --git a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts index d1954dfd8c..c4973f9dbd 100644 --- a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts +++ b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts @@ -35,6 +35,7 @@ export function createSendNotificationAction(options: { recipients: string; entityRefs?: string[]; title: string; + topic?: string; info?: string; link?: string; severity?: NotificationSeverity; @@ -86,6 +87,11 @@ export function createSendNotificationAction(options: { description: `Notification severity`, enum: ['low', 'normal', 'high', 'critical'], }, + topic: { + title: 'Topic', + description: 'Notification topic', + type: 'string', + }, scope: { title: 'Scope', description: 'Notification scope', @@ -108,6 +114,7 @@ export function createSendNotificationAction(options: { info, link, severity, + topic, scope, optional, } = ctx.input; @@ -129,6 +136,7 @@ export function createSendNotificationAction(options: { description: info, link, severity, + topic, scope, }; diff --git a/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml index a85f53b12f..abd18b1d42 100644 --- a/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml @@ -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 }}