1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

Feat/notifications UI followup (#3197)

This PR adds more capabilities to the notification UI. Including: 
* Displaying new notification types
* Update visual expression based on whether it's read or not
* Mark items as read
* Follow the items link to go to the notification destination
* Cleanup and styled components
This commit is contained in:
Fredrik Strand Oseberg 2023-02-27 13:04:09 +01:00 committed by GitHub
parent e9892a4ec9
commit ef2f184845
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 393 additions and 78 deletions

View File

@ -0,0 +1,26 @@
import { Box, Typography, styled } from '@mui/material';
import NotificationsIcon from '@mui/icons-material/Notifications';
const StyledBox = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
minHeight: '150px',
}));
const StyledNotificationsIcon = styled(NotificationsIcon)(({ theme }) => ({
height: '30px',
width: '30px',
color: theme.palette.neutral.main,
marginBottom: theme.spacing(1),
}));
export const EmptyNotifications = () => {
return (
<StyledBox>
<StyledNotificationsIcon />
<Typography color="neutral.main">No new notifications</Typography>
</StyledBox>
);
};

View File

@ -0,0 +1,126 @@
import { useTheme } from '@mui/material';
import { Box, ListItem, Typography, styled } from '@mui/material';
import {
NotificationsSchemaItem,
NotificationsSchemaItemNotificationType,
} from 'openapi';
import { ReactComponent as ChangesAppliedIcon } from 'assets/icons/merge.svg';
import TimeAgo from 'react-timeago';
import { ToggleOffOutlined } from '@mui/icons-material';
const StyledContainerBox = styled(Box, {
shouldForwardProp: prop => prop !== 'readAt',
})<{ readAt: boolean }>(({ theme, readAt }) => ({
padding: theme.spacing(0.5),
backgroundColor: readAt
? theme.palette.neutral.light
: theme.palette.secondary.light,
width: '30px',
height: '30px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: `${theme.shape.borderRadius}px`,
position: 'absolute',
top: 3,
left: 7,
}));
const StyledListItem = styled(ListItem)(({ theme }) => ({
position: 'relative',
cursor: 'pointer',
margin: theme.spacing(2, 0),
'&:not(:last-child)': {
borderBottom: `2px solid ${theme.palette.tertiary.contrast}`,
},
width: '100%',
}));
const StyledNotificationMessageBox = styled(Box)(({ theme }) => ({
marginLeft: theme.spacing(4),
display: 'flex',
flexDirection: 'column',
width: '100%',
}));
const StyledSecondaryInfoBox = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
margin: theme.spacing(1, 0, 1, 0),
}));
const StyledMessageTypography = styled(Typography, {
shouldForwardProp: prop => prop !== 'readAt',
})<{ readAt: boolean }>(({ theme, readAt }) => ({
fontSize: theme.fontSizes.smallBody,
fontWeight: readAt ? 'normal' : 'bold',
textDecoration: 'none',
color: 'inherit',
}));
const StyledTimeAgoTypography = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.neutral.main,
}));
interface INotificationProps {
notification: NotificationsSchemaItem;
onNotificationClick: (notification: NotificationsSchemaItem) => void;
}
export const Notification = ({
notification,
onNotificationClick,
}: INotificationProps) => {
const theme = useTheme();
const { readAt } = notification;
const resolveIcon = (type: NotificationsSchemaItemNotificationType) => {
if (type === 'change-request') {
return (
<StyledContainerBox readAt={Boolean(readAt)}>
<ChangesAppliedIcon
color={
notification.readAt
? theme.palette.neutral.main
: theme.palette.primary.main
}
style={{ transform: 'scale(0.8)' }}
/>
</StyledContainerBox>
);
}
if (type === 'toggle') {
return (
<StyledContainerBox readAt={Boolean(readAt)}>
<ToggleOffOutlined
sx={theme => ({
height: '20px',
width: '20px',
color: Boolean(readAt)
? theme.palette.neutral.main
: theme.palette.primary.main,
})}
/>
</StyledContainerBox>
);
}
};
return (
<StyledListItem onClick={() => onNotificationClick(notification)}>
{resolveIcon(notification.notificationType)}{' '}
<StyledNotificationMessageBox>
<StyledMessageTypography readAt={Boolean(readAt)}>
{notification.message}
</StyledMessageTypography>
<StyledSecondaryInfoBox>
<StyledTimeAgoTypography>
<TimeAgo date={new Date(notification.createdAt)} />
</StyledTimeAgoTypography>
</StyledSecondaryInfoBox>
</StyledNotificationMessageBox>
</StyledListItem>
);
};

View File

@ -1,61 +1,163 @@
import Settings from '@mui/icons-material/Settings';
import { Paper, Typography, Box, IconButton } from '@mui/material';
import { useState } from 'react';
import {
Paper,
Typography,
Box,
IconButton,
styled,
ClickAwayListener,
Button,
} from '@mui/material';
import { useNotifications } from 'hooks/api/getters/useNotifications/useNotifications';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
import NotificationsIcon from '@mui/icons-material/Notifications';
import { NotificationsHeader } from './NotificationsHeader';
import { NotificationsList } from './NotificationsList';
import { Notification } from './Notification';
import { EmptyNotifications } from './EmptyNotifications';
import { NotificationsSchemaItem } from 'openapi';
import { useNavigate } from 'react-router-dom';
import { useNotificationsApi } from 'hooks/api/actions/useNotificationsApi/useNotificationsApi';
const StyledPrimaryContainerBox = styled(Box)(() => ({
position: 'relative',
}));
const StyledInnerContainerBox = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.neutral.light,
padding: theme.spacing(1, 3),
display: 'flex',
justifyContent: 'center',
}));
const StyledTypography = styled(Typography)(({ theme }) => ({
fontWeight: 'bold',
fontSize: theme.fontSizes.smallBody,
color: theme.palette.primary.main,
textAlign: 'center',
}));
const StyledPaper = styled(Paper)(({ theme }) => ({
minWidth: '400px',
boxShadow: theme.boxShadows.popup,
borderRadius: `${theme.shape.borderRadiusLarge}px`,
position: 'absolute',
right: -20,
top: 60,
}));
const StyledDotBox = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.primary.main,
borderRadius: '100%',
width: '7px',
height: '7px',
position: 'absolute',
top: 7,
right: 4,
}));
export const Notifications = () => {
const { notifications } = useNotifications({ refreshInterval: 15 });
const [showNotifications, setShowNotifications] = useState(false);
const { notifications, refetchNotifications } = useNotifications({
refreshInterval: 15,
});
const navigate = useNavigate();
const { markAsRead } = useNotificationsApi();
const onNotificationClick = (notification: NotificationsSchemaItem) => {
if (notification.link) {
navigate(notification.link);
}
setShowNotifications(false);
// Intentionally not wait for this request. We don't want to hold the user back
// only to mark a notification as read.
try {
markAsRead({
notifications: [notification.id],
});
} catch (e) {
// No need to display this in the UI. Minor inconvinence if this call fails.
console.error('Error marking notification as read: ', e);
}
};
const onMarkAllAsRead = () => {
try {
if (notifications && notifications.length > 0) {
markAsRead({
notifications: notifications.map(
notification => notification.id
),
});
refetchNotifications();
}
} catch (e) {
// No need to display this in the UI. Minor inconvinence if this call fails.
console.error('Error marking all notification as read: ', e);
}
};
const unreadNotifications = notifications?.filter(
notification => notification.readAt === null
);
const hasUnreadNotifications = Boolean(
unreadNotifications && unreadNotifications.length > 0
);
console.log(notifications);
return (
<Paper
elevation={0}
sx={theme => ({
minWidth: '400px',
boxShadow: theme.boxShadows.popup,
borderRadius: `${theme.shape.borderRadiusLarge}px`,
position: 'absolute',
right: -20,
top: 60,
})}
>
<Box
sx={theme => ({
padding: theme.spacing(1, 3),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
<StyledPrimaryContainerBox>
<IconButton
onClick={() => setShowNotifications(!showNotifications)}
>
<Typography fontWeight="bold">Notifications</Typography>
<ConditionallyRender
condition={hasUnreadNotifications}
show={<StyledDotBox />}
/>
<NotificationsIcon />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton>
<Settings />
</IconButton>
</Box>
</Box>
<Box
sx={theme => ({
backgroundColor: theme.palette.neutral.light,
padding: theme.spacing(1, 3),
})}
>
<Typography
sx={theme => ({
fontWeight: 'bold',
fontSize: theme.fontSizes.smallBody,
color: theme.palette.primary.main,
textAlign: 'center',
})}
>
Mark all as read ({notifications?.length})
</Typography>
</Box>
<Box sx={theme => ({ padding: theme.spacing(2, 3) })}>
List goes here
</Box>
</Paper>
<ConditionallyRender
condition={showNotifications}
show={
<ClickAwayListener
onClickAway={() => setShowNotifications(false)}
>
<StyledPaper>
<NotificationsHeader />
<ConditionallyRender
condition={hasUnreadNotifications}
show={
<StyledInnerContainerBox>
<Button onClick={onMarkAllAsRead}>
<StyledTypography>
Mark all as read (
{unreadNotifications?.length})
</StyledTypography>
</Button>
</StyledInnerContainerBox>
}
/>{' '}
<ConditionallyRender
condition={notifications?.length === 0}
show={<EmptyNotifications />}
/>
<NotificationsList>
{notifications?.map(notification => (
<Notification
notification={notification}
key={notification.id}
onNotificationClick={
onNotificationClick
}
/>
))}
</NotificationsList>
</StyledPaper>
</ClickAwayListener>
}
/>
</StyledPrimaryContainerBox>
);
};

View File

@ -0,0 +1,30 @@
import Settings from '@mui/icons-material/Settings';
import { Typography, Box, IconButton, styled } from '@mui/material';
import { flexRow } from 'themes/themeStyles';
const StyledOuterContainerBox = styled(Box)(({ theme }) => ({
padding: theme.spacing(1, 3),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}));
const StyledSettingsContainer = styled(Box)(() => ({
...flexRow,
}));
export const NotificationsHeader = () => {
return (
<>
<StyledOuterContainerBox>
<Typography fontWeight="bold">Notifications</Typography>
<StyledSettingsContainer>
<IconButton>
<Settings />
</IconButton>
</StyledSettingsContainer>
</StyledOuterContainerBox>
</>
);
};

View File

@ -0,0 +1,10 @@
import { List, styled } from '@mui/material';
import { FC } from 'react';
const StyledListContainer = styled(List)(({ theme }) => ({
padding: theme.spacing(2, 3),
}));
export const NotificationsList: FC = ({ children }) => {
return <StyledListContainer>{children}</StyledListContainer>;
};

View File

@ -20,7 +20,6 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import MenuBookIcon from '@mui/icons-material/MenuBook';
import { ReactComponent as UnleashLogo } from 'assets/img/logoDarkWithText.svg';
import { ReactComponent as UnleashLogoWhite } from 'assets/img/logoWithWhiteText.svg';
import NotificationsIcon from '@mui/icons-material/Notifications';
import { DrawerMenu } from './DrawerMenu/DrawerMenu';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
@ -33,7 +32,7 @@ import {
adminMenuRoutes,
getCondensedRoutes,
} from 'component/menu/routes';
import { KeyboardArrowDown, NotificationAdd } from '@mui/icons-material';
import { KeyboardArrowDown } from '@mui/icons-material';
import { filterByConfig } from 'component/common/util';
import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
import { useId } from 'hooks/useId';
@ -118,7 +117,6 @@ const Header: VFC = () => {
const configId = useId();
const [adminRef, setAdminRef] = useState<HTMLButtonElement | null>(null);
const [configRef, setConfigRef] = useState<HTMLButtonElement | null>(null);
const [showNotifications, setShowNotifications] = useState(false);
const [admin, setAdmin] = useState(false);
const { permissions } = useAuthPermissions();
@ -252,6 +250,10 @@ const Header: VFC = () => {
/>
}
/>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.notifications)}
show={<Notifications />}
/>
<Tooltip title="Documentation" arrow>
<IconButton
href="https://docs.getunleash.io/"
@ -287,27 +289,6 @@ const Header: VFC = () => {
</Tooltip>
}
/>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.notifications)}
show={
<Box sx={{ position: 'relative' }}>
<StyledIconButton
onClick={() =>
setShowNotifications(
!showNotifications
)
}
>
<NotificationsIcon />
</StyledIconButton>
<ConditionallyRender
condition={showNotifications}
show={<Notifications />}
/>
</Box>
}
/>
<NavigationMenu
id={adminId}
options={filteredMainRoutes.adminRoutes}

View File

@ -0,0 +1,30 @@
import { NotificationsSchemaItem } from 'openapi';
import useAPI from '../useApi/useApi';
export const useNotificationsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const markAsRead = async (payload: { notifications: number[] }) => {
const path = `api/admin/notifications/read`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res.json();
} catch (e) {
throw e;
}
};
return {
loading,
errors,
markAsRead,
};
};

View File

@ -16,7 +16,6 @@ export const useNotifications = (options: SWRConfiguration = {}) => {
mutate().catch(console.warn);
}, [mutate]);
console.log(data);
return {
notifications: data,
error,

View File

@ -5,10 +5,19 @@
* OpenAPI spec version: 4.22.0-beta.3
*/
/**
* Data required to create a new [environment](https://docs.getunleash.io/reference/environments)
*/
export interface CreateEnvironmentSchema {
/** The name of the environment. Must be a URL-friendly string according to [RFC 3968, section 2.3](https://www.rfc-editor.org/rfc/rfc3986#section-2.3) */
name: string;
/** The type of environment you would like to create (i.e. development or production). */
/** The [type of environment](https://docs.getunleash.io/reference/environments#environment-types) you would like to create. Unleash officially recognizes the following values:
- `development`
- `test`
- `preproduction`
- `production`
If you pass a string that is not one of the recognized values, Unleash will accept it, but it will carry no special semantics. */
type: string;
/** Newly created environments are enabled by default. Set this property to `false` to create the environment in a disabled state. */
enabled?: boolean;

View File

@ -12,6 +12,8 @@ export type NotificationsSchemaItem = {
id: number;
/** The actual notification message */
message: string;
/** The link to change request or feature toggle the notification refers to */
link?: string;
/** The type of the notification used e.g. for the graphical hints */
notificationType: NotificationsSchemaItemNotificationType;
createdBy: NotificationsSchemaItemCreatedBy;