diff --git a/frontend/src/component/common/Notifications/EmptyNotifications.tsx b/frontend/src/component/common/Notifications/EmptyNotifications.tsx new file mode 100644 index 0000000000..691077575f --- /dev/null +++ b/frontend/src/component/common/Notifications/EmptyNotifications.tsx @@ -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 ( + + + No new notifications + + ); +}; diff --git a/frontend/src/component/common/Notifications/Notification.tsx b/frontend/src/component/common/Notifications/Notification.tsx new file mode 100644 index 0000000000..cafd4ce4b4 --- /dev/null +++ b/frontend/src/component/common/Notifications/Notification.tsx @@ -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 ( + + + + ); + } + + if (type === 'toggle') { + return ( + + ({ + height: '20px', + width: '20px', + color: Boolean(readAt) + ? theme.palette.neutral.main + : theme.palette.primary.main, + })} + /> + + ); + } + }; + + return ( + onNotificationClick(notification)}> + {resolveIcon(notification.notificationType)}{' '} + + + {notification.message} + + + + + + + + + ); +}; diff --git a/frontend/src/component/common/Notifications/Notifications.tsx b/frontend/src/component/common/Notifications/Notifications.tsx index 5e93d094bc..c1a3459590 100644 --- a/frontend/src/component/common/Notifications/Notifications.tsx +++ b/frontend/src/component/common/Notifications/Notifications.tsx @@ -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 ( - ({ - minWidth: '400px', - boxShadow: theme.boxShadows.popup, - borderRadius: `${theme.shape.borderRadiusLarge}px`, - position: 'absolute', - right: -20, - top: 60, - })} - > - ({ - padding: theme.spacing(1, 3), - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - })} + + setShowNotifications(!showNotifications)} > - Notifications + } + /> + + - - - - - - - - ({ - backgroundColor: theme.palette.neutral.light, - padding: theme.spacing(1, 3), - })} - > - ({ - fontWeight: 'bold', - fontSize: theme.fontSizes.smallBody, - color: theme.palette.primary.main, - textAlign: 'center', - })} - > - Mark all as read ({notifications?.length}) - - - - ({ padding: theme.spacing(2, 3) })}> - List goes here - - + setShowNotifications(false)} + > + + + + + + } + />{' '} + } + /> + + {notifications?.map(notification => ( + + ))} + + + + } + /> + ); }; diff --git a/frontend/src/component/common/Notifications/NotificationsHeader.tsx b/frontend/src/component/common/Notifications/NotificationsHeader.tsx new file mode 100644 index 0000000000..298eea2e03 --- /dev/null +++ b/frontend/src/component/common/Notifications/NotificationsHeader.tsx @@ -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 ( + <> + + Notifications + + + + + + + + + ); +}; diff --git a/frontend/src/component/common/Notifications/NotificationsList.tsx b/frontend/src/component/common/Notifications/NotificationsList.tsx new file mode 100644 index 0000000000..5505cd44b5 --- /dev/null +++ b/frontend/src/component/common/Notifications/NotificationsList.tsx @@ -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 {children}; +}; diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx index 65beaaa398..317bd6d958 100644 --- a/frontend/src/component/menu/Header/Header.tsx +++ b/frontend/src/component/menu/Header/Header.tsx @@ -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(null); const [configRef, setConfigRef] = useState(null); - const [showNotifications, setShowNotifications] = useState(false); const [admin, setAdmin] = useState(false); const { permissions } = useAuthPermissions(); @@ -252,6 +250,10 @@ const Header: VFC = () => { /> } /> + } + /> { } /> - - - setShowNotifications( - !showNotifications - ) - } - > - - - - } - /> - - } - /> { + 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, + }; +}; diff --git a/frontend/src/hooks/api/getters/useNotifications/useNotifications.ts b/frontend/src/hooks/api/getters/useNotifications/useNotifications.ts index ce5ebb7c98..6bae26d506 100644 --- a/frontend/src/hooks/api/getters/useNotifications/useNotifications.ts +++ b/frontend/src/hooks/api/getters/useNotifications/useNotifications.ts @@ -16,7 +16,6 @@ export const useNotifications = (options: SWRConfiguration = {}) => { mutate().catch(console.warn); }, [mutate]); - console.log(data); return { notifications: data, error, diff --git a/frontend/src/openapi/models/createEnvironmentSchema.ts b/frontend/src/openapi/models/createEnvironmentSchema.ts index d4d834261b..76000fa398 100644 --- a/frontend/src/openapi/models/createEnvironmentSchema.ts +++ b/frontend/src/openapi/models/createEnvironmentSchema.ts @@ -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; diff --git a/frontend/src/openapi/models/notificationsSchemaItem.ts b/frontend/src/openapi/models/notificationsSchemaItem.ts index a1028340f5..03b3a96863 100644 --- a/frontend/src/openapi/models/notificationsSchemaItem.ts +++ b/frontend/src/openapi/models/notificationsSchemaItem.ts @@ -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;