feat(notifications): migrate to backstage ui

migrated notifications plugin to use backstage ui instead
material ui.

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2026-03-31 14:16:10 +03:00
parent 3dd27832fc
commit 19a2a038aa
26 changed files with 738 additions and 658 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications': patch
---
Migrated notifications plugin to use backstage UI
+13 -2
View File
@@ -21,7 +21,18 @@ import {
} from '../src';
import { signalsPlugin } from '@backstage/plugin-signals';
import { SidebarItem } from '@backstage/core-components';
import AddAlert from '@material-ui/icons/AddAlert';
import { IconComponent } from '@backstage/core-plugin-api';
import { RiBellLine } from '@remixicon/react';
const AddAlertIcon: IconComponent = props => {
let size = 24;
if (props.fontSize === 'large') {
size = 32;
} else if (props.fontSize === 'small') {
size = 16;
}
return <RiBellLine size={size} />;
};
createDevApp()
.registerPlugin(notificationsPlugin)
@@ -38,7 +49,7 @@ createDevApp()
.addSidebarItem(<NotificationsSidebarItem webNotificationsEnabled />)
.addSidebarItem(
<SidebarItem
icon={AddAlert}
icon={AddAlertIcon}
text="Random notification"
onClick={() => {
fetch('http://localhost:7007/api/notifications-debug/', {
-3
View File
@@ -59,11 +59,8 @@
"@backstage/plugin-signals-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/ui": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@remixicon/react": "^4.6.0",
"lodash": "^4.17.21",
"material-ui-confirm": "^3.0.12",
"notistack": "^3.0.1",
"react-relative-time": "^0.0.9",
"react-use": "^17.2.4"
+2 -1
View File
@@ -137,8 +137,9 @@ export const notificationsTranslationRef: TranslationRef<
readonly 'table.bulkActions.returnSelectedAmongUnread': 'Return selected among unread';
readonly 'table.bulkActions.saveSelectedForLater': 'Save selected for later';
readonly 'table.bulkActions.undoSaveForSelected': 'Undo save for selected';
readonly 'table.confirmDialog.cancel': 'Cancel';
readonly 'table.confirmDialog.title': 'Are you sure?';
readonly 'table.confirmDialog.markAllReadDescription': 'Mark <b>all</b> notifications as <b>read</b>.';
readonly 'table.confirmDialog.markAllReadDescription': 'Mark all notifications as read.';
readonly 'table.confirmDialog.markAllReadConfirmation': 'Mark All';
readonly 'filters.view.all': 'All';
readonly 'filters.view.label': 'View';
+2 -1
View File
@@ -205,8 +205,9 @@ export const notificationsTranslationRef: TranslationRef<
readonly 'table.bulkActions.returnSelectedAmongUnread': 'Return selected among unread';
readonly 'table.bulkActions.saveSelectedForLater': 'Save selected for later';
readonly 'table.bulkActions.undoSaveForSelected': 'Undo save for selected';
readonly 'table.confirmDialog.cancel': 'Cancel';
readonly 'table.confirmDialog.title': 'Are you sure?';
readonly 'table.confirmDialog.markAllReadDescription': 'Mark <b>all</b> notifications as <b>read</b>.';
readonly 'table.confirmDialog.markAllReadDescription': 'Mark all notifications as read.';
readonly 'table.confirmDialog.markAllReadConfirmation': 'Mark All';
readonly 'filters.view.all': 'All';
readonly 'filters.view.label': 'View';
@@ -13,14 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeEvent } from 'react';
import FormControl from '@material-ui/core/FormControl';
import Divider from '@material-ui/core/Divider';
import Grid from '@material-ui/core/Grid';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import Typography from '@material-ui/core/Typography';
import { Select, Text, Flex } from '@backstage/ui';
import type { Key } from 'react-aria-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { notificationsTranslationRef } from '../../translation';
import { GetNotificationsOptions } from '../../api';
@@ -128,22 +122,20 @@ export const NotificationsFilters = ({
const { t } = useTranslationRef(notificationsTranslationRef);
const sortByText = getSortByText(sorting);
const handleOnCreatedAfterChanged = (
event: ChangeEvent<{ name?: string; value: unknown }>,
) => {
onCreatedAfterChanged(event.target.value as string);
const handleOnCreatedAfterChanged = (key: Key | Key[] | null) => {
if (key !== null && !Array.isArray(key))
onCreatedAfterChanged(key as string);
};
const handleOnViewChanged = (
event: ChangeEvent<{ name?: string; value: unknown }>,
) => {
if (event.target.value === 'unread') {
const handleOnViewChanged = (key: Key | Key[] | null) => {
const value = Array.isArray(key) ? key[0] : key;
if (value === 'unread') {
onUnreadOnlyChanged(true);
onSavedChanged(undefined);
} else if (event.target.value === 'read') {
} else if (value === 'read') {
onUnreadOnlyChanged(false);
onSavedChanged(undefined);
} else if (event.target.value === 'saved') {
} else if (value === 'saved') {
onUnreadOnlyChanged(undefined);
onSavedChanged(true);
} else {
@@ -153,10 +145,8 @@ export const NotificationsFilters = ({
}
};
const handleOnSortByChanged = (
event: ChangeEvent<{ name?: string; value: unknown }>,
) => {
const idx = ((event.target.value as string) ||
const handleOnSortByChanged = (key: Key | Key[] | null) => {
const idx = (((Array.isArray(key) ? key[0] : key) as string) ||
'newest') as keyof typeof SortByOptions;
const option = SortByOptions[idx];
onSortingChanged({ ...option.sortBy });
@@ -171,145 +161,86 @@ export const NotificationsFilters = ({
viewValue = 'read';
}
const handleOnSeverityChanged = (
event: ChangeEvent<{ name?: string; value: unknown }>,
) => {
const handleOnSeverityChanged = (key: Key | Key[] | null) => {
const value: NotificationSeverity =
(event.target.value as NotificationSeverity) || 'normal';
((Array.isArray(key) ? key[0] : key) as NotificationSeverity) || 'normal';
onSeverityChanged(value);
};
const handleOnTopicChanged = (
event: ChangeEvent<{ name?: string; value: unknown }>,
) => {
const value = event.target.value as string;
const handleOnTopicChanged = (key: Key | Key[] | null) => {
const value = (Array.isArray(key) ? key[0] : key) as string;
onTopicChanged(value === ALL ? undefined : value);
};
const sortedAllTopics = (allTopics || []).sort((a, b) => a.localeCompare(b));
const sortedAllTopics = [...(allTopics ?? [])].sort((a, b) =>
a.localeCompare(b),
);
return (
<>
<Grid container>
<Grid item xs={12}>
<Typography variant="h6">{t('filters.title')}</Typography>
<Divider variant="fullWidth" />
</Grid>
<Flex direction="column" gap="4">
<div>
<Text variant="title-x-small">{t('filters.title')}</Text>
</div>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-view">
{t('filters.view.label')}
</InputLabel>
<Select
labelId="notifications-filter-view"
label={t('filters.view.label')}
value={viewValue}
onChange={handleOnViewChanged}
>
<MenuItem value="unread">{t('filters.view.unread')}</MenuItem>
<MenuItem value="read">{t('filters.view.read')}</MenuItem>
<MenuItem value="saved">{t('filters.view.saved')}</MenuItem>
<MenuItem value="all">{t('filters.view.all')}</MenuItem>
</Select>
</FormControl>
</Grid>
<Select
label={t('filters.view.label')}
value={viewValue}
onChange={handleOnViewChanged}
options={[
{ value: 'unread', label: t('filters.view.unread') },
{ value: 'read', label: t('filters.view.read') },
{ value: 'saved', label: t('filters.view.saved') },
{ value: 'all', label: t('filters.view.all') },
]}
/>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-created">
{t('filters.createdAfter.label')}
</InputLabel>
<Select
label={t('filters.createdAfter.label')}
value={createdAfter}
onChange={handleOnCreatedAfterChanged}
options={Object.keys(CreatedAfterOptions).map((key: string) => ({
value: key,
label: t(
CreatedAfterOptions[key as keyof typeof CreatedAfterOptions]
.labelKey,
),
}))}
/>
<Select
label={t('filters.createdAfter.label')}
labelId="notifications-filter-created"
placeholder={t('filters.createdAfter.placeholder')}
value={createdAfter}
onChange={handleOnCreatedAfterChanged}
>
{Object.keys(CreatedAfterOptions).map((key: string) => (
<MenuItem value={key} key={key}>
{t(
CreatedAfterOptions[key as keyof typeof CreatedAfterOptions]
.labelKey,
)}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Select
label={t('filters.sortBy.label')}
value={sortByText}
onChange={handleOnSortByChanged}
options={Object.keys(SortByOptions).map((key: string) => ({
value: key,
label: t(SortByOptions[key as keyof typeof SortByOptions].labelKey),
}))}
/>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-sort">
{t('filters.sortBy.label')}
</InputLabel>
<Select
label={t('filters.severity.label')}
value={severity}
onChange={handleOnSeverityChanged}
options={(
['critical', 'high', 'normal', 'low'] as NotificationSeverity[]
).map(key => ({
value: key,
label: t(`filters.severity.${key}`),
}))}
/>
<Select
label={t('filters.sortBy.label')}
labelId="notifications-filter-sort"
placeholder={t('filters.sortBy.placeholder')}
value={sortByText}
onChange={handleOnSortByChanged}
>
{Object.keys(SortByOptions).map((key: string) => (
<MenuItem value={key} key={key}>
{t(SortByOptions[key as keyof typeof SortByOptions].labelKey)}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-severity">
{t('filters.severity.label')}
</InputLabel>
<Select
label={t('filters.severity.label')}
labelId="notifications-filter-severity"
value={severity}
onChange={handleOnSeverityChanged}
>
{(
['critical', 'high', 'normal', 'low'] as NotificationSeverity[]
).map(key => (
<MenuItem value={key} key={key}>
{t(`filters.severity.${key}`)}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel id="notifications-filter-topic">
{t('filters.topic.label')}
</InputLabel>
<Select
label={t('filters.topic.label')}
labelId="notifications-filter-topic"
value={topic ?? ALL}
onChange={handleOnTopicChanged}
>
<MenuItem value={ALL} key={ALL}>
{t('filters.topic.anyTopic')}
</MenuItem>
{sortedAllTopics.map((item: string) => (
<MenuItem value={item} key={item}>
{item}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</>
<Select
label={t('filters.topic.label')}
value={topic ?? ALL}
onChange={handleOnTopicChanged}
options={[
{ value: ALL, label: t('filters.topic.anyTopic') },
...sortedAllTopics.map((item: string) => ({
value: item,
label: item,
})),
]}
/>
</Flex>
);
};
@@ -21,8 +21,7 @@ import {
PageWithHeader,
ResponseErrorPanel,
} from '@backstage/core-components';
import Grid from '@material-ui/core/Grid';
import { ConfirmProvider } from 'material-ui-confirm';
import { Grid } from '@backstage/ui';
import { useSignal } from '@backstage/plugin-signals-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { notificationsTranslationRef } from '../../translation';
@@ -191,43 +190,41 @@ function NotificationsPageContent(
const pageContent = (
<Content>
<ConfirmProvider>
<Grid container>
<Grid item xs={2}>
<NotificationsFilters
unreadOnly={unreadOnly}
onUnreadOnlyChanged={setUnreadOnly}
createdAfter={createdAfter}
onCreatedAfterChanged={setCreatedAfter}
onSortingChanged={setSorting}
sorting={sorting}
saved={saved}
onSavedChanged={setSaved}
severity={severity}
onSeverityChanged={setSeverity}
topic={topic}
onTopicChanged={setTopic}
allTopics={allTopics}
/>
</Grid>
<Grid item xs={10}>
<NotificationsTable
title={tableTitle}
isLoading={loading}
isUnread={isUnread}
markAsReadOnLinkOpen={markAsReadOnLinkOpen}
notifications={notifications}
onUpdate={onUpdate}
setContainsText={setContainsText}
onPageChange={setPageNumber}
onRowsPerPageChange={setPageSize}
page={pageNumber}
pageSize={pageSize}
totalCount={totalCount}
/>
</Grid>
</Grid>
</ConfirmProvider>
<Grid.Root columns="12" gap="6">
<Grid.Item colSpan="2">
<NotificationsFilters
unreadOnly={unreadOnly}
onUnreadOnlyChanged={setUnreadOnly}
createdAfter={createdAfter}
onCreatedAfterChanged={setCreatedAfter}
onSortingChanged={setSorting}
sorting={sorting}
saved={saved}
onSavedChanged={setSaved}
severity={severity}
onSeverityChanged={setSeverity}
topic={topic}
onTopicChanged={setTopic}
allTopics={allTopics}
/>
</Grid.Item>
<Grid.Item colSpan="10">
<NotificationsTable
title={tableTitle}
isLoading={loading}
isUnread={isUnread}
markAsReadOnLinkOpen={markAsReadOnLinkOpen}
notifications={notifications}
onUpdate={onUpdate}
setContainsText={setContainsText}
onPageChange={setPageNumber}
onRowsPerPageChange={setPageSize}
page={pageNumber}
pageSize={pageSize}
totalCount={totalCount}
/>
</Grid.Item>
</Grid.Root>
</Content>
);
@@ -0,0 +1,26 @@
/*
* Copyright 2023 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.
*/
@layer components {
.snackbarContent {
background-color: var(--bui-bg-app);
color: var(--bui-fg-primary);
}
.snackbarIcon {
margin-right: var(--bui-space-2);
}
}
@@ -13,16 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useNotificationsApi } from '../../hooks';
import { Link, SidebarItem } from '@backstage/core-components';
import NotificationsIcon from '@material-ui/icons/Notifications';
import {
alertApiRef,
IconComponent,
useApi,
useRouteRef,
} from '@backstage/core-plugin-api';
import { SidebarItem } from '@backstage/core-components';
import { IconComponent, useApi, useRouteRef } from '@backstage/core-plugin-api';
import { toastApiRef } from '@backstage/frontend-plugin-api';
import { rootRouteRef } from '../../routes';
import { useSignal } from '@backstage/plugin-signals-react';
import {
@@ -43,31 +38,34 @@ import {
VariantType,
} from 'notistack';
import { SeverityIcon } from '../NotificationsTable/SeverityIcon';
import OpenInNew from '@material-ui/icons/OpenInNew';
import MarkAsReadIcon from '@material-ui/icons/CheckCircle';
import IconButton from '@material-ui/core/IconButton';
import Chip from '@material-ui/core/Chip';
import { styled } from '@material-ui/core/styles';
import { ButtonIcon, Flex, Tag, TagGroup } from '@backstage/ui';
import {
RiExternalLinkLine,
RiCheckboxCircleLine,
RiNotification2Line,
} from '@remixicon/react';
import styles from './NotificationsSideBarItem.module.css';
const StyledMaterialDesignContent = styled(MaterialDesignContent)(
({ theme }) => ({
'&.notistack-MuiContent-low': {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
'&.notistack-MuiContent-normal': {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
'&.notistack-MuiContent-high': {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
'&.notistack-MuiContent-critical': {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
}),
const NotificationsIcon: IconComponent = props => {
let size = 24;
if (props.fontSize === 'large') {
size = 32;
} else if (props.fontSize === 'small') {
size = 16;
}
return <RiNotification2Line size={size} />;
};
const StyledMaterialDesignContent = forwardRef<HTMLDivElement, any>(
(props: any, ref: React.Ref<HTMLDivElement>) => (
<MaterialDesignContent
{...props}
ref={ref}
className={[props.className, styles.snackbarContent]
.filter(Boolean)
.join(' ')}
/>
),
);
declare module 'notistack' {
@@ -146,7 +144,7 @@ export const NotificationsSidebarItem = (
webNotificationsEnabled = false,
titleCounterEnabled = true,
snackbarEnabled = true,
snackbarAutoHideDuration = 10000,
snackbarAutoHideDuration = 15000,
icon = NotificationsIcon,
text = 'Notifications',
...restProps
@@ -155,7 +153,7 @@ export const NotificationsSidebarItem = (
titleCounterEnabled: true,
snackbarProps: {
enabled: true,
autoHideDuration: 10000,
autoHideDuration: 15000,
},
};
@@ -172,7 +170,7 @@ export const NotificationsSidebarItem = (
api.getStatus(),
);
const notificationsApi = useApi(notificationsApiRef);
const alertApi = useApi(alertApiRef);
const toastApi = useApi(toastApiRef);
const [unreadCount, setUnreadCount] = useState(0);
const notificationsRoute = useRouteRef(rootRouteRef)();
// TODO: Do we want to add long polling in case signals are not available
@@ -186,11 +184,14 @@ export const NotificationsSidebarItem = (
const getSnackbarProperties = useCallback(
(notification: Notification) => {
const action = (snackBarId: SnackbarKey) => (
<>
<IconButton
component={Link}
to={notification.payload.link ?? notificationsRoute}
onClick={() => {
<Flex gap="1">
<ButtonIcon
aria-label="open notification"
icon={<RiExternalLinkLine size={16} />}
variant="secondary"
onPress={() => {
const link = notification.payload.link ?? notificationsRoute;
window.open(link, '_blank', 'noopener,noreferrer');
if (notification.payload.link) {
notificationsApi
.updateNotifications({
@@ -198,19 +199,20 @@ export const NotificationsSidebarItem = (
read: true,
})
.catch(() => {
alertApi.post({
message: 'Failed to mark notification as read',
severity: 'error',
toastApi.post({
title: 'Failed to mark notification as read',
status: 'danger',
});
});
}
closeSnackbar(snackBarId);
}}
>
<OpenInNew fontSize="small" />
</IconButton>
<IconButton
onClick={() => {
/>
<ButtonIcon
aria-label="mark as read"
icon={<RiCheckboxCircleLine size={16} />}
variant="secondary"
onPress={() => {
notificationsApi
.updateNotifications({
ids: [notification.id],
@@ -220,21 +222,19 @@ export const NotificationsSidebarItem = (
closeSnackbar(snackBarId);
})
.catch(() => {
alertApi.post({
message: 'Failed to mark notification as read',
severity: 'error',
toastApi.post({
title: 'Failed to mark notification as read',
status: 'danger',
});
});
}}
>
<MarkAsReadIcon fontSize="small" />
</IconButton>
</>
/>
</Flex>
);
return { action };
},
[notificationsRoute, notificationsApi, alertApi],
[notificationsRoute, notificationsApi, toastApi],
);
useEffect(() => {
@@ -286,9 +286,9 @@ export const NotificationsSidebarItem = (
}
})
.catch(() => {
alertApi.post({
message: 'Failed to fetch notification',
severity: 'error',
toastApi.post({
title: 'Failed to fetch notification',
status: 'danger',
});
});
};
@@ -302,7 +302,7 @@ export const NotificationsSidebarItem = (
sendWebNotification,
webNotificationsEnabled,
notificationsApi,
alertApi,
toastApi,
getSnackbarProperties,
snackbarProps,
]);
@@ -341,16 +341,19 @@ export const NotificationsSidebarItem = (
<SnackbarProvider
iconVariant={{
normal: snackbarProps?.iconVariant?.normal ?? (
<SeverityIcon severity="normal" />
<SeverityIcon severity="normal" className={styles.snackbarIcon} />
),
critical: snackbarProps?.iconVariant?.critical ?? (
<SeverityIcon severity="critical" />
<SeverityIcon
severity="critical"
className={styles.snackbarIcon}
/>
),
high: snackbarProps?.iconVariant?.high ?? (
<SeverityIcon severity="high" />
<SeverityIcon severity="high" className={styles.snackbarIcon} />
),
low: snackbarProps?.iconVariant?.low ?? (
<SeverityIcon severity="low" />
<SeverityIcon severity="low" className={styles.snackbarIcon} />
),
}}
dense={snackbarProps?.dense}
@@ -377,7 +380,11 @@ export const NotificationsSidebarItem = (
icon={icon}
{...restProps}
>
{count && <Chip size="small" label={count > 99 ? '99+' : count} />}
{count && (
<TagGroup aria-label="Unread notifications">
<Tag size="small">{count > 99 ? '99+' : count}</Tag>
</TagGroup>
)}
</SidebarItem>
)}
</>
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import { Notification } from '@backstage/plugin-notifications-common';
import Grid from '@material-ui/core/Grid';
import IconButton from '@material-ui/core/IconButton';
import Tooltip from '@material-ui/core/Tooltip';
import MarkAsUnreadIcon from '@material-ui/icons/Markunread' /* TODO: use Drafts and MarkAsUnread once we have mui 5 icons */;
import MarkAsReadIcon from '@material-ui/icons/CheckCircle';
import MarkAsUnsavedIcon from '@material-ui/icons/LabelOff' /* TODO: use BookmarkRemove and BookmarkAdd once we have mui 5 icons */;
import MarkAsSavedIcon from '@material-ui/icons/Label';
import MarkAllReadIcon from '@material-ui/icons/DoneAll';
import { ButtonIcon, Flex, Tooltip, TooltipTrigger } from '@backstage/ui';
import {
RiCheckDoubleLine,
RiBookmarkLine,
RiBookmark3Line,
RiCheckboxCircleLine,
RiMailLine,
} from '@remixicon/react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { notificationsTranslationRef } from '../../translation';
@@ -56,61 +56,56 @@ export const BulkActions = ({
const markAsReadText = isOneRead
? t('table.bulkActions.returnSelectedAmongUnread')
: t('table.bulkActions.markSelectedAsRead');
const IconComponent = isOneRead ? MarkAsUnreadIcon : MarkAsReadIcon;
const ReadIcon = isOneRead ? RiMailLine : RiCheckboxCircleLine;
const markAsSavedText = isOneSaved
? t('table.bulkActions.undoSaveForSelected')
: t('table.bulkActions.saveSelectedForLater');
const SavedIconComponent = isOneSaved ? MarkAsUnsavedIcon : MarkAsSavedIcon;
const SavedIcon = isOneSaved ? RiBookmark3Line : RiBookmarkLine;
const markAllReadText = t('table.bulkActions.markAllRead');
return (
<Grid container wrap="nowrap">
<Grid item xs={3}>
{onMarkAllRead ? (
<Tooltip title={markAllReadText}>
<div>
{/* The <div> here is a workaround for the Tooltip which does not work for a "disabled" child */}
<IconButton disabled={!isUnread} onClick={onMarkAllRead}>
<MarkAllReadIcon aria-label={markAllReadText} />
</IconButton>
</div>
</Tooltip>
) : (
<div />
)}
</Grid>
<Flex gap="1">
{onMarkAllRead ? (
<TooltipTrigger>
<ButtonIcon
aria-label={markAllReadText}
isDisabled={!isUnread}
onPress={onMarkAllRead}
icon={<RiCheckDoubleLine size={16} />}
variant="secondary"
/>
<Tooltip>{markAllReadText}</Tooltip>
</TooltipTrigger>
) : (
<div />
)}
<Grid item xs={3}>
<Tooltip title={markAsSavedText}>
<div>
{/* The <div> here is a workaround for the Tooltip which does not work for a "disabled" child */}
<IconButton
disabled={isDisabled}
onClick={() => {
onSwitchSavedStatus([...selectedNotifications], !isOneSaved);
}}
>
<SavedIconComponent aria-label={markAsSavedText} />
</IconButton>
</div>
</Tooltip>
</Grid>
<TooltipTrigger>
<ButtonIcon
aria-label={markAsSavedText}
isDisabled={isDisabled}
onPress={() => {
onSwitchSavedStatus([...selectedNotifications], !isOneSaved);
}}
icon={<SavedIcon size={16} />}
variant="secondary"
/>
<Tooltip>{markAsSavedText}</Tooltip>
</TooltipTrigger>
<Grid item xs={3}>
<Tooltip title={markAsReadText}>
<div>
<IconButton
disabled={isDisabled}
onClick={() => {
onSwitchReadStatus([...selectedNotifications], !isOneRead);
}}
>
<IconComponent aria-label={markAsReadText} />
</IconButton>
</div>
</Tooltip>
</Grid>
</Grid>
<TooltipTrigger>
<ButtonIcon
aria-label={markAsReadText}
isDisabled={isDisabled}
onPress={() => {
onSwitchReadStatus([...selectedNotifications], !isOneRead);
}}
icon={<ReadIcon size={16} />}
variant="secondary"
/>
<Tooltip>{markAsReadText}</Tooltip>
</TooltipTrigger>
</Flex>
);
};
@@ -13,8 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import { Text, Button } from '@backstage/ui';
import { useState } from 'react';
const MAX_LENGTH = 100;
@@ -25,37 +24,35 @@ export const NotificationDescription = (props: { description: string }) => {
const isLong = description.length > MAX_LENGTH;
if (!isLong) {
return <Typography variant="body2">{description}</Typography>;
return <Text variant="body-small">{description}</Text>;
}
if (shown) {
return (
<Typography variant="body2">
<Text variant="body-small">
{description}{' '}
<Button
variant="text"
size="small"
onClick={() => {
variant="tertiary"
onPress={() => {
setShown(false);
}}
>
Show less
</Button>
</Typography>
</Text>
);
}
return (
<Typography variant="body2">
<Text variant="body-small">
{description.substring(0, MAX_LENGTH)}...{' '}
<Button
variant="text"
size="small"
onClick={() => {
variant="tertiary"
onPress={() => {
setShown(true);
}}
>
Show more
</Button>
</Typography>
</Text>
);
};
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { Notification } from '@backstage/plugin-notifications-common';
import SvgIcon from '@material-ui/core/SvgIcon';
import { useApp } from '@backstage/core-plugin-api';
import { SeverityIcon } from './SeverityIcon';
@@ -26,8 +25,10 @@ export const NotificationIcon = ({
const app = useApp();
if (notification.payload.icon) {
const Icon = app.getSystemIcon(notification.payload.icon) ?? SvgIcon;
return <Icon />;
const Icon = app.getSystemIcon(notification.payload.icon);
if (Icon) {
return <Icon />;
}
}
return <SeverityIcon severity={notification.payload.severity} />;
};
@@ -0,0 +1,36 @@
/*
* Copyright 2023 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.
*/
@layer components {
.severityItem {
align-content: center;
display: flex;
align-items: center;
}
.broadcastIcon {
font-size: 1rem;
vertical-align: text-bottom;
}
.notificationInfoRow {
margin-right: var(--bui-space-1);
}
.notificationInfoRow:not(:first-child) {
margin-left: var(--bui-space-1);
}
}
@@ -17,15 +17,20 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import throttle from 'lodash/throttle';
// @ts-ignore
import RelativeTime from 'react-relative-time';
import Box from '@material-ui/core/Box';
import Grid from '@material-ui/core/Grid';
import CheckBox from '@material-ui/core/Checkbox';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import { Notification } from '@backstage/plugin-notifications-common';
import { useConfirm } from 'material-ui-confirm';
import BroadcastIcon from '@material-ui/icons/RssFeed';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import {
Button,
Checkbox,
Dialog,
DialogBody,
DialogFooter,
DialogHeader,
Flex,
Text,
} from '@backstage/ui';
import { RiRssFill } from '@remixicon/react';
import { useApi } from '@backstage/core-plugin-api';
import { toastApiRef } from '@backstage/frontend-plugin-api';
import {
Link,
Table,
@@ -41,23 +46,9 @@ import { BulkActions } from './BulkActions';
import { NotificationIcon } from './NotificationIcon';
import { NotificationDescription } from './NotificationDescription';
const ThrottleDelayMs = 1000;
import styles from './NotificationsTable.module.css';
const useStyles = makeStyles(theme => ({
severityItem: {
alignContent: 'center',
},
broadcastIcon: {
fontSize: '1rem',
verticalAlign: 'text-bottom',
},
notificationInfoRow: {
marginRight: theme.spacing(0.5),
'&:not(:first-child)': {
marginLeft: theme.spacing(0.5),
},
},
}));
const ThrottleDelayMs = 1000;
/** @public */
export type NotificationsTableProps = Pick<
@@ -89,10 +80,9 @@ export const NotificationsTable = ({
totalCount,
}: NotificationsTableProps) => {
const { t } = useTranslationRef(notificationsTranslationRef);
const classes = useStyles();
const notificationsApi = useApi(notificationsApiRef);
const alertApi = useApi(alertApiRef);
const confirm = useConfirm();
const toastApi = useApi(toastApiRef);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [selectedNotifications, setSelectedNotifications] = useState(
new Set<Notification['id']>(),
@@ -136,38 +126,26 @@ export const NotificationsTable = ({
[notificationsApi, onUpdate],
);
const onMarkAllRead = useCallback(() => {
confirm({
title: 'Are you sure?',
description: (
<>
Mark <b>all</b> notifications as <b>read</b>.
</>
),
confirmationText: 'Mark All',
})
.then(async () => {
const ids = (
await notificationsApi.getNotifications({ read: false })
).notifications?.map(notification => notification.id);
return notificationsApi
.updateNotifications({
ids,
read: true,
})
.then(onUpdate);
})
.catch(e => {
if (e) {
// if e === undefined, the Cancel button has been hit
alertApi.post({
message: 'Failed to mark all notifications as read',
severity: 'error',
});
}
const doMarkAllRead = useCallback(async () => {
setConfirmDialogOpen(false);
try {
const result = await notificationsApi.getNotifications({ read: false });
const ids =
result.notifications?.map(notification => notification.id) ?? [];
if (ids.length === 0) return;
await notificationsApi.updateNotifications({ ids, read: true });
onUpdate();
} catch {
toastApi.post({
title: t('table.errors.markAllReadFailed'),
status: 'danger',
});
}, [alertApi, confirm, notificationsApi, onUpdate]);
}
}, [notificationsApi, onUpdate, toastApi, t]);
const onMarkAllRead = useCallback(() => {
setConfirmDialogOpen(true);
}, []);
const throttledContainsTextHandler = useMemo(
() => throttle(setContainsText, ThrottleDelayMs),
@@ -190,6 +168,7 @@ export const NotificationsTable = ({
{
/* selection column */
width: '1rem',
cellStyle: { paddingRight: '2.5rem' },
title: showToolbar ? (
<SelectAll
count={selectedNotifications.size}
@@ -203,10 +182,10 @@ export const NotificationsTable = ({
/>
) : undefined,
render: (notification: Notification) => (
<CheckBox
color="primary"
checked={selectedNotifications.has(notification.id)}
onChange={(_, checked) =>
<Checkbox
aria-label="Select notification"
isSelected={selectedNotifications.has(notification.id)}
onChange={checked =>
onNotificationsSelectChange([notification.id], checked)
}
/>
@@ -216,75 +195,66 @@ export const NotificationsTable = ({
/* compact-data column */
customFilterAndSearch: () =>
true /* Keep sorting&filtering on backend due to pagination. */,
cellStyle: { paddingLeft: 0 },
render: (notification: Notification) => {
// Compact content
return (
<Grid container>
<Grid item className={classes.severityItem}>
<Flex gap="4" align="center">
<div className={styles.severityItem}>
<NotificationIcon notification={notification} />
</Grid>
<Grid item xs={11}>
<Box>
<Typography variant="subtitle1">
{notification.payload.link ? (
<Link
to={notification.payload.link}
onClick={() => {
if (markAsReadOnLinkOpen && !notification.read) {
onSwitchReadStatus([notification.id], true);
}
}}
>
{notification.payload.title}
</Link>
) : (
notification.payload.title
)}
</Typography>
{notification.payload.description ? (
<NotificationDescription
description={notification.payload.description}
/>
) : null}
</div>
<Flex direction="column" gap="1">
<Text variant="body-medium">
{notification.payload.link ? (
<Link
to={notification.payload.link}
onClick={() => {
if (markAsReadOnLinkOpen && !notification.read) {
onSwitchReadStatus([notification.id], true);
}
}}
>
{notification.payload.title}
</Link>
) : (
notification.payload.title
)}
</Text>
{notification.payload.description ? (
<NotificationDescription
description={notification.payload.description}
/>
) : null}
<Typography variant="caption">
{!notification.user && (
<>
<BroadcastIcon className={classes.broadcastIcon} />
</>
)}
{notification.origin && (
<>
<Typography
variant="inherit"
className={classes.notificationInfoRow}
>
{notification.origin}
</Typography>
&bull;
</>
)}
{notification.payload.topic && (
<>
<Typography
variant="inherit"
className={classes.notificationInfoRow}
>
{notification.payload.topic}
</Typography>
&bull;
</>
)}
{notification.created && (
<RelativeTime
value={notification.created}
className={classes.notificationInfoRow}
/>
)}
</Typography>
</Box>
</Grid>
</Grid>
<Text variant="body-small" color="secondary">
{!notification.user && (
<RiRssFill size={14} className={styles.broadcastIcon} />
)}
{notification.origin && (
<>
<span className={styles.notificationInfoRow}>
{notification.origin}
</span>
&bull;
</>
)}
{notification.payload.topic && (
<>
<span className={styles.notificationInfoRow}>
{notification.payload.topic}
</span>
&bull;
</>
)}
{notification.created && (
<RelativeTime
value={notification.created}
className={styles.notificationInfoRow}
/>
)}
</Text>
</Flex>
</Flex>
);
},
},
@@ -319,44 +289,63 @@ export const NotificationsTable = ({
onSwitchSavedStatus,
onMarkAllRead,
onNotificationsSelectChange,
classes.severityItem,
classes.broadcastIcon,
classes.notificationInfoRow,
markAsReadOnLinkOpen,
]);
return (
<Table<Notification>
isLoading={isLoading}
options={{
padding: 'dense',
search: true,
paging: true,
pageSize,
header: true,
sorting: false,
}}
title={title}
onPageChange={onPageChange}
onRowsPerPageChange={onRowsPerPageChange}
page={page}
totalCount={totalCount}
onSearchChange={throttledContainsTextHandler}
data={notifications}
columns={compactColumns}
localization={{
body: {
emptyDataSourceMessage: t('table.emptyMessage'),
},
pagination: {
firstTooltip: t('table.pagination.firstTooltip'),
labelDisplayedRows: t('table.pagination.labelDisplayedRows'),
labelRowsSelect: t('table.pagination.labelRowsSelect'),
lastTooltip: t('table.pagination.lastTooltip'),
nextTooltip: t('table.pagination.nextTooltip'),
previousTooltip: t('table.pagination.previousTooltip'),
},
}}
/>
<>
<Table<Notification>
isLoading={isLoading}
options={{
padding: 'dense',
search: true,
paging: true,
pageSize,
header: true,
sorting: false,
}}
title={title}
onPageChange={onPageChange}
onRowsPerPageChange={onRowsPerPageChange}
page={page}
totalCount={totalCount}
onSearchChange={throttledContainsTextHandler}
data={notifications}
columns={compactColumns}
localization={{
body: {
emptyDataSourceMessage: t('table.emptyMessage'),
},
pagination: {
firstTooltip: t('table.pagination.firstTooltip'),
labelDisplayedRows: t('table.pagination.labelDisplayedRows'),
labelRowsSelect: t('table.pagination.labelRowsSelect'),
lastTooltip: t('table.pagination.lastTooltip'),
nextTooltip: t('table.pagination.nextTooltip'),
previousTooltip: t('table.pagination.previousTooltip'),
},
}}
/>
<Dialog
isOpen={confirmDialogOpen}
onOpenChange={setConfirmDialogOpen}
isDismissable
>
<DialogHeader>{t('table.confirmDialog.title')}</DialogHeader>
<DialogBody>
<Text variant="body-medium">
{t('table.confirmDialog.markAllReadDescription')}
</Text>
</DialogBody>
<DialogFooter>
<Button variant="primary" onPress={doMarkAllRead}>
{t('table.confirmDialog.markAllReadConfirmation')}
</Button>
<Button variant="secondary" slot="close">
{t('table.confirmDialog.cancel')}
</Button>
</DialogFooter>
</Dialog>
</>
);
};
@@ -0,0 +1,28 @@
/*
* 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.
*/
@layer components {
.label {
display: flex;
align-items: center;
margin-left: 0;
max-width: 4rem;
gap: var(--bui-space-1);
cursor: pointer;
font-size: var(--bui-font-size-1);
color: var(--bui-fg-primary);
}
}
@@ -13,21 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Checkbox from '@material-ui/core/Checkbox';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import { makeStyles } from '@material-ui/core/styles';
import Tooltip from '@material-ui/core/Tooltip';
const useStyles = makeStyles({
label: {
marginLeft: '0px',
maxWidth: '2rem',
'& span': {
paddingRight: '0px',
marginRight: '2px',
},
},
});
import { Checkbox, Tooltip, TooltipTrigger } from '@backstage/ui';
import styles from './SelectAll.module.css';
export const SelectAll = ({
count,
@@ -38,23 +25,19 @@ export const SelectAll = ({
totalCount: number;
onSelectAll: () => void;
}) => {
const classes = useStyles();
return (
<FormControlLabel
label={count > 0 ? `(${count})` : undefined}
className={classes.label}
control={
<Tooltip title="Select all">
<Checkbox
color="primary"
disabled={!totalCount}
checked={count > 0}
indeterminate={count > 0 && totalCount !== count}
onChange={onSelectAll}
/>
</Tooltip>
}
/>
<TooltipTrigger>
<Checkbox
className={styles.label}
aria-label="Select all"
isDisabled={!totalCount}
isSelected={count > 0}
isIndeterminate={count > 0 && totalCount !== count}
onChange={() => onSelectAll()}
>
{count > 0 ? `(${count})` : undefined}
</Checkbox>
<Tooltip>Select all</Tooltip>
</TooltipTrigger>
);
};
@@ -0,0 +1,33 @@
/*
* 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.
*/
@layer components {
.critical {
color: var(--bui-fg-danger);
}
.high {
color: var(--bui-fg-warning);
}
.normal {
color: var(--bui-fg-success);
}
.low {
color: var(--bui-fg-info);
}
}
@@ -14,43 +14,56 @@
* limitations under the License.
*/
import { NotificationSeverity } from '@backstage/plugin-notifications-common';
import NormalIcon from '@material-ui/icons/CheckOutlined';
import CriticalIcon from '@material-ui/icons/ErrorOutline';
import HighIcon from '@material-ui/icons/WarningOutlined';
import LowIcon from '@material-ui/icons/InfoOutlined';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles(theme => ({
critical: {
color: theme.palette.status.error,
},
high: {
color: theme.palette.status.warning,
},
normal: {
color: theme.palette.status.ok,
},
low: {
color: theme.palette.status.running,
},
}));
import {
RiCheckLine,
RiErrorWarningLine,
RiAlertLine,
RiInformationLine,
} from '@remixicon/react';
import styles from './SeverityIcon.module.css';
export const SeverityIcon = ({
severity,
className,
style,
}: {
severity?: NotificationSeverity;
className?: string;
style?: React.CSSProperties;
}) => {
const classes = useStyles();
switch (severity) {
case 'critical':
return <CriticalIcon className={classes.critical} />;
return (
<RiErrorWarningLine
size={20}
className={[styles.critical, className].filter(Boolean).join(' ')}
style={style}
/>
);
case 'high':
return <HighIcon className={classes.high} />;
return (
<RiAlertLine
size={20}
className={[styles.high, className].filter(Boolean).join(' ')}
style={style}
/>
);
case 'low':
return <LowIcon className={classes.low} />;
return (
<RiInformationLine
size={20}
className={[styles.low, className].filter(Boolean).join(' ')}
style={style}
/>
);
case 'normal':
default:
return <NormalIcon className={classes.normal} />;
return (
<RiCheckLine
size={20}
className={[styles.normal, className].filter(Boolean).join(' ')}
style={style}
/>
);
}
};
@@ -0,0 +1,23 @@
/*
* 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.
*/
@layer components {
.cell {
border-bottom: none;
padding: var(--bui-space-2) var(--bui-space-3);
vertical-align: middle;
}
}
@@ -14,11 +14,17 @@
* limitations under the License.
*/
import { withStyles } from '@material-ui/core/styles';
import MuiTableCell from '@material-ui/core/TableCell';
import { ReactNode } from 'react';
import styles from './NoBorderTableCell.module.css';
export const NoBorderTableCell = withStyles({
root: {
borderBottom: 'none',
},
})(MuiTableCell);
export const NoBorderTableCell = ({
children,
align,
}: {
children?: ReactNode;
align?: 'left' | 'center' | 'right';
}) => (
<td className={styles.cell} style={align ? { textAlign: align } : undefined}>
{children}
</td>
);
@@ -19,12 +19,8 @@ import {
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 { ButtonIcon, Switch, Tooltip, TooltipTrigger } from '@backstage/ui';
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
import { NoBorderTableCell } from './NoBorderTableCell';
import { useNotificationFormat } from './UserNotificationSettingsCard';
@@ -43,45 +39,50 @@ export const OriginRow = (props: {
const { origin, settings, handleChange, open, handleRowToggle } = props;
const { formatOriginName } = useNotificationFormat();
return (
<TableRow>
<tr>
<NoBorderTableCell>
{origin.topics && origin.topics.length > 0 && (
<Tooltip
title={`Show Topics for the ${formatOriginName(origin.id)} origin`}
>
<IconButton
<TooltipTrigger>
<ButtonIcon
aria-label="expand row"
size="small"
onClick={() => handleRowToggle(origin.id)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</Tooltip>
onPress={() => handleRowToggle(origin.id)}
icon={
open ? (
<RiArrowUpSLine size={16} />
) : (
<RiArrowDownSLine size={16} />
)
}
variant="secondary"
/>
<Tooltip>{`Show Topics for the ${formatOriginName(
origin.id,
)} origin`}</Tooltip>
</TooltipTrigger>
)}
</NoBorderTableCell>
<NoBorderTableCell>{formatOriginName(origin.id)}</NoBorderTableCell>
<NoBorderTableCell>all</NoBorderTableCell>
{settings.channels.map(ch => (
<NoBorderTableCell key={ch.id} align="center">
<Tooltip
title={`Enable or disable ${ch.id.toLocaleLowerCase(
'en-US',
)} notifications from ${formatOriginName(origin.id)}`}
>
<TooltipTrigger>
<Switch
checked={isNotificationsEnabledFor(
isSelected={isNotificationsEnabledFor(
settings,
ch.id,
origin.id,
null,
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
handleChange(ch.id, origin.id, null, event.target.checked);
onChange={(isSelected: boolean) => {
handleChange(ch.id, origin.id, null, isSelected);
}}
/>
</Tooltip>
<Tooltip>{`Enable or disable ${ch.id.toLocaleLowerCase(
'en-US',
)} notifications from ${formatOriginName(origin.id)}`}</Tooltip>
</TooltipTrigger>
</NoBorderTableCell>
))}
</TableRow>
</tr>
);
};
@@ -20,19 +20,10 @@ import {
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 { Switch, Tooltip, TooltipTrigger } from '@backstage/ui';
import { NoBorderTableCell } from './NoBorderTableCell';
import { useNotificationFormat } from './UserNotificationSettingsCard';
const TopicTableRow = withStyles({
root: {
paddingLeft: '4px',
},
})(TableRow);
export const TopicRow = (props: {
topic: TopicSetting;
origin: OriginSetting;
@@ -47,33 +38,32 @@ export const TopicRow = (props: {
const { topic, origin, settings, handleChange } = props;
const { formatOriginName, formatTopicName } = useNotificationFormat();
return (
<TopicTableRow>
<tr>
<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)}`}
>
<NoBorderTableCell key={`${ch.id}-${topic.id}`} align="center">
<TooltipTrigger>
<Switch
checked={isNotificationsEnabledFor(
isSelected={isNotificationsEnabledFor(
settings,
ch.id,
origin.id,
topic.id,
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
handleChange(ch.id, origin.id, topic.id, event.target.checked);
onChange={(isSelected: boolean) => {
handleChange(ch.id, origin.id, topic.id, isSelected);
}}
/>
</Tooltip>
<Tooltip>{`Enable or disable ${ch.id.toLocaleLowerCase(
'en-US',
)} notifications for the ${formatTopicName(
topic.id,
)} topic from ${formatOriginName(origin.id)}`}</Tooltip>
</TooltipTrigger>
</NoBorderTableCell>
))}
</TopicTableRow>
</tr>
);
};
@@ -0,0 +1,29 @@
/*
* 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.
*/
@layer components {
.table {
width: 100%;
border-collapse: collapse;
}
.headerCell {
border-bottom: 1px solid var(--bui-border-1);
padding: var(--bui-space-2) var(--bui-space-3);
text-align: left;
font-weight: var(--bui-font-weight-bold);
}
}
@@ -19,21 +19,12 @@ import {
NotificationSettings,
OriginSetting,
} 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 { Text } from '@backstage/ui';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { notificationsTranslationRef } from '../../translation';
import { TopicRow } from './TopicRow';
import { OriginRow } from './OriginRow';
const TableCell = withStyles({
root: {
borderBottom: 'none',
},
})(MuiTableCell);
import styles from './UserNotificationSettingsPanel.module.css';
export const UserNotificationSettingsPanel = (props: {
settings: NotificationSettings;
@@ -42,6 +33,7 @@ export const UserNotificationSettingsPanel = (props: {
topicNames?: Record<string, string>;
}) => {
const { settings, onChange } = props;
const { t } = useTranslationRef(notificationsTranslationRef);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const handleRowToggle = (originId: string) => {
@@ -106,9 +98,7 @@ export const UserNotificationSettingsPanel = (props: {
if (settings.channels.length === 0) {
return (
<Typography variant="body1">
No notification settings available, check back later
</Typography>
<Text variant="body-medium">{t('settings.noSettingsAvailable')}</Text>
);
}
@@ -125,26 +115,29 @@ export const UserNotificationSettingsPanel = (props: {
const uniqueOrigins = Array.from(uniqueOriginsMap);
return (
<Table>
<TableHead>
<TableRow>
<TableCell />
<TableCell>
<Typography variant="subtitle1">Origin</Typography>
</TableCell>
<TableCell>
<Typography variant="subtitle1">Topic</Typography>
</TableCell>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.headerCell} />
<th className={styles.headerCell}>
<Text variant="title-x-small">{t('settings.table.origin')}</Text>
</th>
<th className={styles.headerCell}>
<Text variant="title-x-small">{t('settings.table.topic')}</Text>
</th>
{settings.channels.map(channel => (
<TableCell key={channel.id}>
<Typography variant="subtitle1" align="center">
<th key={channel.id} className={styles.headerCell}>
<Text
variant="title-x-small"
style={{ textAlign: 'center', display: 'block' }}
>
{channel.id}
</Typography>
</TableCell>
</Text>
</th>
))}
</TableRow>
</TableHead>
<TableBody>
</tr>
</thead>
<tbody>
{uniqueOrigins.flatMap(origin => [
<OriginRow
key={origin.id}
@@ -166,7 +159,7 @@ export const UserNotificationSettingsPanel = (props: {
)) || []
: []),
])}
</TableBody>
</Table>
</tbody>
</table>
);
};
+2 -1
View File
@@ -88,8 +88,9 @@ export const notificationsTranslationRef = createTranslationRef({
},
confirmDialog: {
title: 'Are you sure?',
markAllReadDescription: 'Mark <b>all</b> notifications as <b>read</b>.',
markAllReadDescription: 'Mark all notifications as read.',
markAllReadConfirmation: 'Mark All',
cancel: 'Cancel',
},
errors: {
markAllReadFailed: 'Failed to mark all notifications as read',
-14
View File
@@ -6277,14 +6277,11 @@ __metadata:
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/ui": "workspace:^"
"@material-ui/core": "npm:^4.9.13"
"@material-ui/icons": "npm:^4.9.1"
"@remixicon/react": "npm:^4.6.0"
"@testing-library/jest-dom": "npm:^6.0.0"
"@testing-library/react": "npm:^16.0.0"
"@types/react": "npm:^18.0.0"
lodash: "npm:^4.17.21"
material-ui-confirm: "npm:^3.0.12"
msw: "npm:^1.0.0"
notistack: "npm:^3.0.1"
react: "npm:^18.0.2"
@@ -37746,17 +37743,6 @@ __metadata:
languageName: node
linkType: hard
"material-ui-confirm@npm:^3.0.12":
version: 3.0.18
resolution: "material-ui-confirm@npm:3.0.18"
peerDependencies:
"@mui/material": ">= 5.0.0"
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/c2013449b9af11bed9fbf63cb15022d8399379547fedc8ce9f9c4039606536b34cce8b737b82b9628df95291d7b631dba90dd59c6ccbc7c17f9b8dcab50fdccf
languageName: node
linkType: hard
"material-ui-popup-state@npm:^5.3.6":
version: 5.3.6
resolution: "material-ui-popup-state@npm:5.3.6"