Skip to content

Commit

Permalink
[GEN-1709]: added a notification manager (#1753)
Browse files Browse the repository at this point in the history
Co-authored-by: Alon Braymok <138359965+alonkeyval@users.noreply.github.com>
  • Loading branch information
BenElferink and alonkeyval authored Nov 17, 2024
1 parent 539888b commit 735ff63
Show file tree
Hide file tree
Showing 25 changed files with 573 additions and 168 deletions.
41 changes: 28 additions & 13 deletions frontend/webapp/components/main/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,54 @@ import React from 'react';
import Image from 'next/image';
import styled from 'styled-components';
import { PlatformTitle } from './cp-title';
import { useConnectionStore } from '@/store';
import { Status } from '@/reuseable-components';
import { NotificationManager } from '@/components/notification';

interface MainHeaderProps {}

const HeaderContainer = styled.div`
const Flex = styled.div`
display: flex;
padding: 12px 0;
align-items: center;
`;

const HeaderContainer = styled(Flex)`
width: 100%;
padding: 12px 0;
background-color: ${({ theme }) => theme.colors.dark_grey};
border-bottom: 1px solid rgba(249, 249, 249, 0.16);
width: 100%;
`;

const Logo = styled.div`
display: flex;
align-items: center;
const AlignLeft = styled(Flex)`
margin-right: auto;
margin-left: 32px;
gap: 16px;
`;

const PlatformTitleWrapper = styled.div`
margin-left: 32px;
const AlignRight = styled(Flex)`
margin-left: auto;
margin-right: 32px;
gap: 16px;
`;

export const MainHeader: React.FC<MainHeaderProps> = () => {
const { connecting, active, title, message } = useConnectionStore();

return (
<HeaderContainer>
<Logo>
<AlignLeft>
<Image src='/brand/transparent-logo-white.svg' alt='logo' width={84} height={20} />
</Logo>
<PlatformTitleWrapper>
<PlatformTitle type='k8s' />
</PlatformTitleWrapper>
<Status title='Connection Alive' isActive withIcon withBackground />
{!connecting && <Status title={title} subtitle={message} isActive={active} withIcon withBackground />}
</AlignLeft>

<AlignRight>
<NotificationManager />
{/* <Flex style={{ gap: '8px' }}>
<Image src='/icons/common/avatar.svg' alt='avatar' width={28} height={28} />
<Text>Full Name</Text>
</Flex> */}
</AlignRight>
</HeaderContainer>
);
};
1 change: 1 addition & 0 deletions frontend/webapp/components/notification/index.tsx
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './notification-manager';
export * from './toast-list';
230 changes: 230 additions & 0 deletions frontend/webapp/components/notification/notification-manager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Image from 'next/image';
import styled from 'styled-components';
import { useClickNotif } from '@/hooks';
import { useNotificationStore } from '@/store';
import { ACTION, getStatusIcon } from '@/utils';
import { useOnClickOutside, useTimeAgo } from '@/hooks';
import theme, { hexPercentValues } from '@/styles/theme';
import { NoDataFound, Text } from '@/reuseable-components';
import type { Notification, NotificationType } from '@/types';

const Icon = styled.div`
position: relative;
width: 36px;
height: 36px;
border-radius: 100%;
background-color: ${({ theme }) => theme.colors.white_opacity['008']};
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
background-color: ${({ theme }) => theme.colors.white_opacity['20']};
}
`;

const LiveBadge = styled.div`
position: absolute;
top: 8px;
right: 8px;
width: 6px;
height: 6px;
border-radius: 100%;
background-color: ${({ theme }) => theme.colors.orange_og};
`;

const RelativeContainer = styled.div`
position: relative;
`;

const AbsoluteContainer = styled.div`
position: absolute;
top: 40px;
right: 0;
z-index: 1;
width: 370px;
height: 400px;
background-color: ${({ theme }) => theme.colors.dropdown_bg};
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 24px;
box-shadow: 0px 10px 15px -3px ${({ theme }) => theme.colors.primary}, 0px 4px 6px -2px ${({ theme }) => theme.colors.primary};
`;

const PopupHeader = styled.div`
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
`;

const PopupBody = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
height: calc(100% - 74px);
border-radius: 24px;
overflow-y: auto;
`;

const PopupShadow = styled.div`
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 45px;
border-radius: 0 0 24px 24px;
background: linear-gradient(0deg, #242424 0%, rgba(36, 36, 36, 0.64) 50%, rgba(36, 36, 36, 0) 100%);
pointer-events: none;
`;

const NewCount = styled(Text)`
background-color: ${({ theme }) => theme.colors.orange_soft};
color: ${({ theme }) => theme.text.primary};
border-radius: 32px;
width: fit-content;
padding: 2px 8px;
`;

export const NotificationManager = () => {
const { notifications, markAsSeen } = useNotificationStore();
const unseen = notifications.filter(({ seen }) => !seen);
const unseenCount = unseen.length;

const [isOpen, setIsOpen] = useState(false);
const toggleOpen = () => setIsOpen((prev) => !prev);

const containerRef = useRef<HTMLDivElement>(null);

useOnClickOutside(containerRef, () => {
if (isOpen) {
setIsOpen(false);
if (!!unseenCount) unseen.forEach(({ id }) => markAsSeen(id));
}
});

return (
<RelativeContainer ref={containerRef}>
<Icon onClick={toggleOpen}>
{!!unseenCount && <LiveBadge />}
<Image src='/icons/common/notification.svg' alt='logo' width={16} height={16} />
</Icon>

{isOpen && (
<AbsoluteContainer>
<PopupHeader>
<Text size={20}>Notifications</Text>{' '}
{!!unseenCount && (
<NewCount size={12} family='secondary'>
{unseenCount} new
</NewCount>
)}
</PopupHeader>
<PopupBody>
{!notifications.length ? (
<NoDataFound title='No notifications' subTitle='' />
) : (
notifications.map((notif) => <NotificationListItem key={`notification-${notif.id}`} {...notif} onClick={() => setIsOpen(false)} />)
)}
</PopupBody>
<PopupShadow />
</AbsoluteContainer>
)}
</RelativeContainer>
);
};

const NotifCard = styled.div`
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 16px;
background-color: ${({ theme }) => theme.colors.white_opacity['004']};
cursor: not-allowed;
&.click-enabled {
cursor: pointer;
&:hover {
background-color: ${({ theme }) => theme.colors.white_opacity['008']};
}
}
`;

const StatusIcon = styled.div<{ type: NotificationType }>`
background-color: ${({ type, theme }) => theme.text[type] + hexPercentValues['012']};
border-radius: 8px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
`;

const NotifTextWrap = styled.div`
width: 290px;
`;

const NotifHeaderTextWrap = styled.div`
margin-bottom: 6px;
`;

const NotifFooterTextWrap = styled.div`
display: flex;
align-items: center;
gap: 6px;
`;

const NotificationListItem: React.FC<Notification & { onClick: () => void }> = ({ onClick, ...props }) => {
const { id, seen, type, title, message, time, crdType, target } = props;
const canClick = !!crdType && !!target;

const isDeleted = useMemo(() => {
const deleteAction = ACTION.DELETE.toLowerCase(),
titleIncludes = title?.toLowerCase().includes(deleteAction),
messageIncludes = message?.toLowerCase().includes(deleteAction);

return titleIncludes || messageIncludes || false;
}, [title, message]);

const timeAgo = useTimeAgo();
const clickNotif = useClickNotif();

return (
<NotifCard
key={`notification-${id}`}
className={canClick ? 'click-enabled' : ''}
onClick={() => {
if (canClick) {
onClick(); // this is to close the popup in a controlled manner, to prevent from all notifications being marked as "seen"
clickNotif(props);
}
}}
>
<StatusIcon type={isDeleted ? 'error' : type}>
<Image src={isDeleted ? '/icons/common/trash.svg' : getStatusIcon(type)} alt='status' width={16} height={16} />
</StatusIcon>

<NotifTextWrap>
<NotifHeaderTextWrap>
<Text size={14}>{message}</Text>
</NotifHeaderTextWrap>

<NotifFooterTextWrap>
<Text size={10} color={theme.text.grey}>
{timeAgo.format(new Date(time))}
</Text>
{!seen && (
<>
<Text size={10}>·</Text>
<Text size={10} color={theme.colors.orange_soft}>
new
</Text>
</>
)}
</NotifFooterTextWrap>
</NotifTextWrap>
</NotifCard>
);
};
66 changes: 8 additions & 58 deletions frontend/webapp/components/notification/toast-list.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React from 'react';
import styled from 'styled-components';
import { getIdFromSseTarget } from '@/utils';
import { Notification } from '@/types';
import { useClickNotif } from '@/hooks';
import { useNotificationStore } from '@/store';
import { NotificationNote } from '@/reuseable-components';
import { Notification, OVERVIEW_ENTITY_TYPES } from '@/types';
import { DrawerBaseItem, useDrawerStore, useNotificationStore } from '@/store';
import { useActionCRUD, useDestinationCRUD, useInstrumentationRuleCRUD, useSourceCRUD } from '@/hooks';

const Container = styled.div`
position: fixed;
Expand All @@ -13,7 +12,7 @@ const Container = styled.div`
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column;
flex-direction: column-reverse;
gap: 6px;
min-width: 600px;
`;
Expand All @@ -32,59 +31,10 @@ export const ToastList: React.FC = () => {
);
};

const Toast: React.FC<Notification> = ({ id, type, title, message, crdType, target }) => {
const Toast: React.FC<Notification> = (props) => {
const { id, type, title, message, crdType, target } = props;
const { markAsDismissed, markAsSeen } = useNotificationStore();

const { sources } = useSourceCRUD();
const { actions } = useActionCRUD();
const { destinations } = useDestinationCRUD();
const { instrumentationRules } = useInstrumentationRuleCRUD();
const setSelectedItem = useDrawerStore(({ setSelectedItem }) => setSelectedItem);

const onClick = () => {
if (crdType && target) {
const drawerItem: Partial<DrawerBaseItem> = {};

switch (crdType) {
case OVERVIEW_ENTITY_TYPES.RULE:
drawerItem['type'] = OVERVIEW_ENTITY_TYPES.RULE;
drawerItem['id'] = getIdFromSseTarget(target, OVERVIEW_ENTITY_TYPES.RULE);
drawerItem['item'] = instrumentationRules.find((item) => item.ruleId === drawerItem['id']);
break;

case OVERVIEW_ENTITY_TYPES.SOURCE:
case 'InstrumentedApplication':
case 'InstrumentationInstance':
drawerItem['type'] = OVERVIEW_ENTITY_TYPES.SOURCE;
drawerItem['id'] = getIdFromSseTarget(target, OVERVIEW_ENTITY_TYPES.SOURCE);
drawerItem['item'] = sources.find((item) => item.kind === drawerItem['id']?.['kind'] && item.name === drawerItem['id']?.['name'] && item.namespace === drawerItem['id']?.['namespace']);
break;

case OVERVIEW_ENTITY_TYPES.ACTION:
drawerItem['type'] = OVERVIEW_ENTITY_TYPES.ACTION;
drawerItem['id'] = getIdFromSseTarget(target, OVERVIEW_ENTITY_TYPES.ACTION);
drawerItem['item'] = actions.find((item) => item.id === drawerItem['id']);
break;

case OVERVIEW_ENTITY_TYPES.DESTINATION:
case 'Destination':
drawerItem['type'] = OVERVIEW_ENTITY_TYPES.DESTINATION;
drawerItem['id'] = getIdFromSseTarget(target, OVERVIEW_ENTITY_TYPES.DESTINATION);
drawerItem['item'] = destinations.find((item) => item.id === drawerItem['id']);
break;

default:
break;
}

if (!!drawerItem.item) {
setSelectedItem(drawerItem as DrawerBaseItem);
}
}

markAsSeen(id);
markAsDismissed(id);
};
const clickNotif = useClickNotif();

const onClose = ({ asSeen }) => {
markAsDismissed(id);
Expand All @@ -101,7 +51,7 @@ const Toast: React.FC<Notification> = ({ id, type, title, message, crdType, targ
crdType && target
? {
label: 'go to details',
onClick,
onClick: () => clickNotif(props, { dismissToast: true }),
}
: undefined
}
Expand Down
Loading

0 comments on commit 735ff63

Please sign in to comment.