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:
Patrik Oldsberg
2025-06-03 13:24:18 +02:00
committed by GitHub
20 changed files with 719 additions and 108 deletions
+8
View File
@@ -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',
});
});
};
+9 -7
View File
@@ -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;
+21 -7
View File
@@ -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;
};
```
+27 -8
View File
@@ -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[];
};
+9 -1
View File
@@ -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;
};
+1
View File
@@ -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)
@@ -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>
);
};
@@ -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>
);
@@ -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 }}