diff --git a/.changeset/new-beers-sell.md b/.changeset/new-beers-sell.md new file mode 100644 index 0000000000..fd7407007d --- /dev/null +++ b/.changeset/new-beers-sell.md @@ -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. diff --git a/docs/notifications/notificationSettings.png b/docs/notifications/notificationSettings.png index cdad63d764..e135a3f194 100644 Binary files a/docs/notifications/notificationSettings.png and b/docs/notifications/notificationSettings.png differ diff --git a/plugins/notifications-backend/migrations/20250317_addTopic.js b/plugins/notifications-backend/migrations/20250317_addTopic.js new file mode 100644 index 0000000000..c8f78a5640 --- /dev/null +++ b/plugins/notifications-backend/migrations/20250317_addTopic.js @@ -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', + }); + }); +}; diff --git a/plugins/notifications-backend/report.sql.md b/plugins/notifications-backend/report.sql.md index 6b9066e77c..aacb787a61 100644 --- a/plugins/notifications-backend/report.sql.md +++ b/plugins/notifications-backend/report.sql.md @@ -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`) diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts index 0386b516a2..c7819552cb 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -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: [], }, ], }, diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index b258f38e7a..35ecf1d778 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -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('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 +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 { 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, + }); + }); }); }); diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index 46ee00c554..0538e90697 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -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: [] }, + ], }, ], }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 7f0279b437..ebf62c9fa0 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -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; diff --git a/plugins/notifications-common/report.api.md b/plugins/notifications-common/report.api.md index 167b37349d..93889b206b 100644 --- a/plugins/notifications-common/report.api.md +++ b/plugins/notifications-common/report.api.md @@ -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; +}; ``` diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 237f93f40a..48f89cbbb4 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -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[]; }; 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/report.api.md b/plugins/notifications/report.api.md index cdf6d9d319..62c41d07c7 100644 --- a/plugins/notifications/report.api.md +++ b/plugins/notifications/report.api.md @@ -207,6 +207,7 @@ export function useNotificationsApi( // @public (undocumented) export const UserNotificationSettingsCard: (props: { originNames?: Record; + topicNames?: Record; }) => JSX_2.Element; // (No @packageDocumentation comment for this package) diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/NoBorderTableCell.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/NoBorderTableCell.tsx new file mode 100644 index 0000000000..71cd52f8a8 --- /dev/null +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/NoBorderTableCell.tsx @@ -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); diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/OriginRow.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/OriginRow.tsx new file mode 100644 index 0000000000..d8d347c202 --- /dev/null +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/OriginRow.tsx @@ -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 ( + + + {origin.topics && origin.topics.length > 0 && ( + + handleRowToggle(origin.id)} + > + {open ? : } + + + )} + + {formatOriginName(origin.id)} + all + {settings.channels.map(ch => ( + + + ) => { + handleChange(ch.id, origin.id, null, event.target.checked); + }} + /> + + + ))} + + ); +}; diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/TopicRow.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/TopicRow.tsx new file mode 100644 index 0000000000..5c9c3cbacc --- /dev/null +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/TopicRow.tsx @@ -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 ( + + + + {formatTopicName(topic.id)} + {settings.channels.map(ch => ( + + + ) => { + handleChange(ch.id, origin.id, topic.id, event.target.checked); + }} + /> + + + ))} + + ); +}; diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx index 1d4308bb7e..fbf7fafc28 100644 --- a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx @@ -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( + 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 | undefined; + topicMap: Record | undefined; +}; + +export const NotificationFormatProvider = ({ + children, + originMap, + topicMap, +}: Props) => { + const formatName = ( + id: string, + nameMap: Record | 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 ( + + {children} + + ); +}; /** @public */ export const UserNotificationSettingsCard = (props: { originNames?: Record; + topicNames?: Record; }) => { const [settings, setNotificationSettings] = useState< NotificationSettings | undefined @@ -52,11 +109,15 @@ export const UserNotificationSettingsCard = (props: { {loading && } {error && } {settings && ( - + + + )} ); diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx index 8841b2829a..aa079100be 100644 --- a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx @@ -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; + topicNames?: Record; }) => { const { settings, onChange } = props; - const allOrigins = [ - ...new Set( - settings.channels.flatMap(channel => - channel.origins.map(origin => origin.id), - ), - ), - ]; + const [expandedRows, setExpandedRows] = useState>(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 ( No notification settings available, check back later @@ -96,44 +113,47 @@ export const UserNotificationSettingsPanel = (props: { + 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 => [ + , + ...(expandedRows.has(origin.id) + ? origin.topics?.map(topic => ( + - - - ))} - - ))} + )) || [] + : []), + ]), + )}
); diff --git a/plugins/scaffolder-backend-module-notifications/report.api.md b/plugins/scaffolder-backend-module-notifications/report.api.md index 1d437cebd7..9c015e56e6 100644 --- a/plugins/scaffolder-backend-module-notifications/report.api.md +++ b/plugins/scaffolder-backend-module-notifications/report.api.md @@ -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; }, { diff --git a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts index a8e86f9b48..43a9201950 100644 --- a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts +++ b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts @@ -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, }; 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 }}