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:
parent
e9892a4ec9
commit
ef2f184845
@ -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>
|
||||
);
|
||||
};
|
126
frontend/src/component/common/Notifications/Notification.tsx
Normal file
126
frontend/src/component/common/Notifications/Notification.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>;
|
||||
};
|
@ -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}
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -16,7 +16,6 @@ export const useNotifications = (options: SWRConfiguration = {}) => {
|
||||
mutate().catch(console.warn);
|
||||
}, [mutate]);
|
||||
|
||||
console.log(data);
|
||||
return {
|
||||
notifications: data,
|
||||
error,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user