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
+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>
);