feat: add user specific notification settings

The settings can be customized for each origin and each processor
individually. The default Web indicates notifications shown in the
Backstage UI. By default, if there are no settings saved in the
database, all notifications are enabled for all processors.

The origins will populate by time for each user as they receive the
first notification from that origin. Processors are shown as their own
columns.

Later, if it makes sense, allow users to also disable/enable
notifications based on notification topic.

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-08-08 13:32:10 +03:00
parent d336f929d8
commit 97ba58fa17
26 changed files with 802 additions and 55 deletions
@@ -16,6 +16,7 @@
import { createApiRef } from '@backstage/core-plugin-api';
import {
Notification,
NotificationSettings,
NotificationSeverity,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -64,4 +65,10 @@ export interface NotificationsApi {
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification[]>;
getNotificationSettings(): Promise<NotificationSettings>;
updateNotificationSettings(
settings: NotificationSettings,
): Promise<NotificationSettings>;
}
@@ -23,6 +23,7 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import {
Notification,
NotificationSettings,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -93,6 +94,20 @@ export class NotificationsClient implements NotificationsApi {
});
}
async getNotificationSettings(): Promise<NotificationSettings> {
return await this.request<NotificationSettings>('settings');
}
async updateNotificationSettings(
settings: NotificationSettings,
): Promise<NotificationSettings> {
return await this.request<NotificationSettings>('settings', {
method: 'POST',
body: JSON.stringify(settings),
headers: { 'Content-Type': 'application/json' },
});
}
private async request<T>(path: string, init?: any): Promise<T> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`;
const url = new URL(path, baseUrl);
@@ -0,0 +1,63 @@
/*
* 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, { 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';
/** @public */
export const UserNotificationSettingsCard = (props: {
originNames?: Record<string, string>;
}) => {
const [settings, setNotificationSettings] = React.useState<
NotificationSettings | undefined
>(undefined);
const client = useApi(notificationsApiRef);
const { error, value, loading } = useNotificationsApi(api => {
return api.getNotificationSettings();
});
useEffect(() => {
if (!loading && !error) {
setNotificationSettings(value);
}
}, [loading, value, error]);
const onUpdate = (newSettings: NotificationSettings) => {
client
.updateNotificationSettings(newSettings)
.then(updatedSettings => setNotificationSettings(updatedSettings));
};
return (
<InfoCard title="Notification settings" variant="gridItem">
{loading && <Progress />}
{error && <ErrorPanel title="Failed to load settings" error={error} />}
{settings && (
<UserNotificationSettingsPanel
settings={settings}
onChange={onUpdate}
originNames={props.originNames}
/>
)}
</InfoCard>
);
};
@@ -0,0 +1,140 @@
/*
* 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,
} 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';
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';
const TableCell = withStyles({
root: {
borderBottom: 'none',
},
})(MuiTableCell);
export const UserNotificationSettingsPanel = (props: {
settings: NotificationSettings;
onChange: (settings: NotificationSettings) => void;
originNames?: Record<string, string>;
}) => {
const { settings, onChange } = props;
const allOrigins = [
...new Set(
settings.channels.flatMap(channel =>
channel.origins.map(origin => origin.id),
),
),
];
const handleChange = (
channelId: string,
originId: string,
enabled: boolean,
) => {
const updatedSettings = {
channels: settings.channels.map(channel => {
if (channel.id !== channelId) {
return channel;
}
return {
...channel,
origins: channel.origins.map(origin => {
if (origin.id !== originId) {
return origin;
}
return {
...origin,
enabled,
};
}),
};
}),
};
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) {
return (
<Typography variant="body1">
No notification settings available, check back later
</Typography>
);
}
return (
<Table>
<TableHead>
<TableRow>
<TableCell>
<Typography variant="subtitle1">Origin</Typography>
</TableCell>
{settings.channels.map(channel => (
<TableCell>
<Typography variant="subtitle1">{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: React.ChangeEvent<HTMLInputElement>) => {
handleChange(channel.id, origin, event.target.checked);
}}
/>
</Tooltip>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { UserNotificationSettingsCard } from './UserNotificationSettingsCard';
@@ -15,4 +15,5 @@
*/
export * from './NotificationsSideBarItem';
export * from './NotificationsTable';
export * from './UserNotificationSettingsCard';
export type { NotificationsPageProps } from './NotificationsPage';