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;