diff --git a/frontend/webapp/components/main/header/index.tsx b/frontend/webapp/components/main/header/index.tsx index 13e83668e..d49ed0725 100644 --- a/frontend/webapp/components/main/header/index.tsx +++ b/frontend/webapp/components/main/header/index.tsx @@ -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 = () => { + const { connecting, active, title, message } = useConnectionStore(); + return ( - + logo - - - - + {!connecting && } + + + + + {/* + avatar + Full Name + */} + ); }; diff --git a/frontend/webapp/components/notification/index.tsx b/frontend/webapp/components/notification/index.tsx index d4a074bf8..fe2628507 100644 --- a/frontend/webapp/components/notification/index.tsx +++ b/frontend/webapp/components/notification/index.tsx @@ -1 +1,2 @@ +export * from './notification-manager'; export * from './toast-list'; diff --git a/frontend/webapp/components/notification/notification-manager.tsx b/frontend/webapp/components/notification/notification-manager.tsx new file mode 100644 index 000000000..ae64849d1 --- /dev/null +++ b/frontend/webapp/components/notification/notification-manager.tsx @@ -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(null); + + useOnClickOutside(containerRef, () => { + if (isOpen) { + setIsOpen(false); + if (!!unseenCount) unseen.forEach(({ id }) => markAsSeen(id)); + } + }); + + return ( + + + {!!unseenCount && } + logo + + + {isOpen && ( + + + Notifications{' '} + {!!unseenCount && ( + + {unseenCount} new + + )} + + + {!notifications.length ? ( + + ) : ( + notifications.map((notif) => setIsOpen(false)} />) + )} + + + + )} + + ); +}; + +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 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 ( + { + if (canClick) { + onClick(); // this is to close the popup in a controlled manner, to prevent from all notifications being marked as "seen" + clickNotif(props); + } + }} + > + + status + + + + + {message} + + + + + {timeAgo.format(new Date(time))} + + {!seen && ( + <> + ยท + + new + + + )} + + + + ); +}; diff --git a/frontend/webapp/components/notification/toast-list.tsx b/frontend/webapp/components/notification/toast-list.tsx index 1c71d4864..ee3bedf99 100644 --- a/frontend/webapp/components/notification/toast-list.tsx +++ b/frontend/webapp/components/notification/toast-list.tsx @@ -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; @@ -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; `; @@ -32,59 +31,10 @@ export const ToastList: React.FC = () => { ); }; -const Toast: React.FC = ({ id, type, title, message, crdType, target }) => { +const Toast: React.FC = (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 = {}; - - 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); @@ -101,7 +51,7 @@ const Toast: React.FC = ({ id, type, title, message, crdType, targ crdType && target ? { label: 'go to details', - onClick, + onClick: () => clickNotif(props, { dismissToast: true }), } : undefined } diff --git a/frontend/webapp/containers/main/overview/multi-source-control/index.tsx b/frontend/webapp/containers/main/overview/multi-source-control/index.tsx index 94fe4e68b..83edd49e3 100644 --- a/frontend/webapp/containers/main/overview/multi-source-control/index.tsx +++ b/frontend/webapp/containers/main/overview/multi-source-control/index.tsx @@ -6,13 +6,13 @@ import { useAppStore } from '@/store'; import styled from 'styled-components'; import { useSourceCRUD } from '@/hooks'; import { DeleteWarning } from '@/components'; -import { Badge, Button, Divider, Text } from '@/reuseable-components'; +import { Badge, Button, Divider, Text, Transition } from '@/reuseable-components'; const Container = styled.div<{ isEntering: boolean; isLeaving: boolean }>` position: fixed; bottom: 0; left: 50%; - transform: translateX(-50%); + transform: translate(-50%, 100%); z-index: 1000; display: flex; align-items: center; @@ -21,7 +21,6 @@ const Container = styled.div<{ isEntering: boolean; isLeaving: boolean }>` border-radius: 32px; border: 1px solid ${({ theme }) => theme.colors.border}; background-color: ${({ theme }) => theme.colors.dropdown_bg}; - animation: ${({ isEntering, isLeaving }) => (isEntering ? slide.in['center'] : isLeaving ? slide.out['center'] : 'none')} 0.3s forwards; `; const MultiSourceControl = () => { @@ -51,7 +50,7 @@ const MultiSourceControl = () => { return ( <> - + Selected sources @@ -69,7 +68,7 @@ const MultiSourceControl = () => { Delete - + setIsWarnModalOpen(false)} /> diff --git a/frontend/webapp/containers/main/sources/choose-sources/choose-sources-body/choose-sources-body-fast/sources-list/index.tsx b/frontend/webapp/containers/main/sources/choose-sources/choose-sources-body/choose-sources-body-fast/sources-list/index.tsx index 10802abd5..e5bff1447 100644 --- a/frontend/webapp/containers/main/sources/choose-sources/choose-sources-body/choose-sources-body-fast/sources-list/index.tsx +++ b/frontend/webapp/containers/main/sources/choose-sources/choose-sources-body/choose-sources-body-fast/sources-list/index.tsx @@ -100,11 +100,8 @@ export const SourcesList: React.FC = ({ onSelectFutureApps, searchText, - setSearchText, - selectAll, onSelectAll, showSelectedOnly, - setShowSelectedOnly, filterSources, }) => { @@ -141,7 +138,14 @@ export const SourcesList: React.FC = ({ onSelectNamespace(namespace)}> - onSelectAll(bool, namespace)} /> + { + if (bool) onSelectNamespace(namespace); + onSelectAll(bool, namespace); + }} + /> {namespace} diff --git a/frontend/webapp/hooks/common/index.ts b/frontend/webapp/hooks/common/index.ts index 095f9e56b..138bf5711 100644 --- a/frontend/webapp/hooks/common/index.ts +++ b/frontend/webapp/hooks/common/index.ts @@ -1,3 +1,4 @@ export * from './useContainerWidth'; export * from './useOnClickOutside'; export * from './useKeyDown'; +export * from './useTimeAgo'; diff --git a/frontend/webapp/hooks/common/useTimeAgo.ts b/frontend/webapp/hooks/common/useTimeAgo.ts new file mode 100644 index 000000000..c7c514f71 --- /dev/null +++ b/frontend/webapp/hooks/common/useTimeAgo.ts @@ -0,0 +1,10 @@ +import TimeAgo from 'javascript-time-ago'; +import en from 'javascript-time-ago/locale/en'; + +TimeAgo.addDefaultLocale(en); + +export const useTimeAgo = () => { + const timeAgo = new TimeAgo('en-US'); + + return timeAgo; +}; diff --git a/frontend/webapp/hooks/notification/index.ts b/frontend/webapp/hooks/notification/index.ts index becc872a6..44d4bfd4e 100644 --- a/frontend/webapp/hooks/notification/index.ts +++ b/frontend/webapp/hooks/notification/index.ts @@ -1,2 +1,3 @@ +export * from './useClickNotif'; export * from './useNotify'; export * from './useSSE'; diff --git a/frontend/webapp/hooks/notification/useClickNotif.ts b/frontend/webapp/hooks/notification/useClickNotif.ts new file mode 100644 index 000000000..02d05a450 --- /dev/null +++ b/frontend/webapp/hooks/notification/useClickNotif.ts @@ -0,0 +1,69 @@ +import { useSourceCRUD } from '../sources'; +import { useActionCRUD } from '../actions'; +import { getIdFromSseTarget } from '@/utils'; +import { useDestinationCRUD } from '../destinations'; +import { type Notification, OVERVIEW_ENTITY_TYPES } from '@/types'; +import { useInstrumentationRuleCRUD } from '../instrumentation-rules'; +import { DrawerBaseItem, useDrawerStore, useNotificationStore } from '@/store'; + +export const useClickNotif = () => { + const { sources } = useSourceCRUD(); + const { actions } = useActionCRUD(); + const { destinations } = useDestinationCRUD(); + const { instrumentationRules } = useInstrumentationRuleCRUD(); + const { markAsDismissed, markAsSeen } = useNotificationStore(); + const setSelectedItem = useDrawerStore(({ setSelectedItem }) => setSelectedItem); + + const clickNotif = (notif: Notification, options?: { dismissToast?: boolean }) => { + const { id, crdType, target } = notif; + const { dismissToast } = options || {}; + + if (crdType && target) { + const drawerItem: Partial = {}; + + 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: + console.warn('notif click not handled for:', { crdType, target }); + break; + } + + if (!!drawerItem.item) { + setSelectedItem(drawerItem as DrawerBaseItem); + } else { + console.warn('notif item not found for:', { crdType, target }); + } + } + + markAsSeen(id); + if (dismissToast) markAsDismissed(id); + }; + + return clickNotif; +}; diff --git a/frontend/webapp/hooks/notification/useSSE.ts b/frontend/webapp/hooks/notification/useSSE.ts index d307cdb85..98f67a4b5 100644 --- a/frontend/webapp/hooks/notification/useSSE.ts +++ b/frontend/webapp/hooks/notification/useSSE.ts @@ -1,11 +1,14 @@ import { useEffect, useRef, useState } from 'react'; -import { API, NOTIFICATION } from '@/utils'; import { useNotify } from './useNotify'; +import { API, NOTIFICATION } from '@/utils'; +import { useConnectionStore } from '@/store'; export function useSSE() { const notify = useNotify(); - const eventBuffer = useRef({}); + const { setConnectionStore } = useConnectionStore(); + const [retryCount, setRetryCount] = useState(0); + const eventBuffer = useRef({}); const maxRetries = 10; useEffect(() => { @@ -26,10 +29,7 @@ export function useSSE() { }; // Check if the event is already in the buffer - if ( - eventBuffer.current[key] && - eventBuffer.current[key].id > Date.now() - 2000 - ) { + if (eventBuffer.current[key] && eventBuffer.current[key].id > Date.now() - 2000) { eventBuffer.current[key] = notification; return; } else { @@ -58,26 +58,24 @@ export function useSSE() { setRetryCount((prevRetryCount) => { if (prevRetryCount < maxRetries) { const newRetryCount = prevRetryCount + 1; - const retryTimeout = Math.min( - 10000, - 1000 * Math.pow(2, newRetryCount) - ); + const retryTimeout = Math.min(10000, 1000 * Math.pow(2, newRetryCount)); - setTimeout(() => { - connect(); - }, retryTimeout); + setTimeout(() => connect(), retryTimeout); return newRetryCount; } else { - console.error( - 'Max retries reached. Could not reconnect to EventSource.' - ); + console.error('Max retries reached. Could not reconnect to EventSource.'); + setConnectionStore({ + connecting: false, + active: false, + title: `Connection lost on ${new Date().toLocaleString()}`, + message: 'Please reboot the application', + }); notify({ type: NOTIFICATION.ERROR, title: 'Connection Error', - message: - 'Connection to the server failed. Please reboot the application.', + message: 'Connection to the server failed. Please reboot the application.', }); return prevRetryCount; @@ -85,6 +83,13 @@ export function useSSE() { }); }; + setConnectionStore({ + connecting: false, + active: true, + title: 'Connection Alive', + message: '', + }); + return eventSource; }; diff --git a/frontend/webapp/hooks/sources/useSourceFormData.ts b/frontend/webapp/hooks/sources/useSourceFormData.ts index cd779e03a..fc637267e 100644 --- a/frontend/webapp/hooks/sources/useSourceFormData.ts +++ b/frontend/webapp/hooks/sources/useSourceFormData.ts @@ -43,6 +43,8 @@ export const useSourceFormData = (params?: UseSourceFormDataParams): UseSourceFo // (this is to persist the values when user navigates back to this page) const appStore = useAppStore((state) => state); + const [selectAll, setSelectAll] = useState(false); + const [selectAllForNamespace, setSelectAllForNamespace] = useState(''); const [selectedNamespace, setSelectedNamespace] = useState(''); const [availableSources, setAvailableSources] = useState(appStore.availableSources); const [selectedSources, setSelectedSources] = useState(appStore.configuredSources); @@ -79,7 +81,6 @@ export const useSourceFormData = (params?: UseSourceFormDataParams): UseSourceFo // form filters const [searchText, setSearchText] = useState(''); - const [selectAll, setSelectAll] = useState(false); const [showSelectedOnly, setShowSelectedOnly] = useState(false); const doSelectAll = () => { @@ -108,7 +109,14 @@ export const useSourceFormData = (params?: UseSourceFormDataParams): UseSourceFo const onSelectAll: UseSourceFormDataResponse['onSelectAll'] = (bool, namespace) => { if (!!namespace) { - setSelectedSources((prev) => ({ ...prev, [namespace]: bool ? availableSources[namespace] : [] })); + const nsAvailableSources = availableSources[namespace]; + const nsSelectedSources = selectedSources[namespace]; + + if (!nsAvailableSources.length && !nsSelectedSources && bool) { + setSelectAllForNamespace(namespace); + } else { + setSelectedSources((prev) => ({ ...prev, [namespace]: bool ? nsAvailableSources : [] })); + } } else { setSelectAll(bool); @@ -120,6 +128,15 @@ export const useSourceFormData = (params?: UseSourceFormDataParams): UseSourceFo } }; + // this is to keep trying "select all" per namespace until the sources are loaded (allows for 1-click, better UX). + // if selectedSources returns an emtpy array, it will stop to prevent inifnite loop where no availableSources ever exist for that namespace + useEffect(() => { + if (!!selectAllForNamespace) { + setSelectAllForNamespace(''); + setTimeout(() => onSelectAll(true, selectAllForNamespace), 100); + } + }, [selectAllForNamespace]); + const onSelectNamespace: UseSourceFormDataResponse['onSelectNamespace'] = (namespace) => { const alreadySelected = selectedNamespace === namespace; diff --git a/frontend/webapp/package.json b/frontend/webapp/package.json index 7404b6aa8..181ee60bb 100644 --- a/frontend/webapp/package.json +++ b/frontend/webapp/package.json @@ -26,6 +26,7 @@ "eslint": "8.42.0", "eslint-config-next": "13.4.5", "graphql": "^16.9.0", + "javascript-time-ago": "^2.5.11", "next": "13.5.4", "postcss": "^8.4.26", "prop-types": "^15.8.1", diff --git a/frontend/webapp/public/icons/common/avatar.svg b/frontend/webapp/public/icons/common/avatar.svg new file mode 100644 index 000000000..a96dd1f80 --- /dev/null +++ b/frontend/webapp/public/icons/common/avatar.svg @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/webapp/public/icons/common/notification.svg b/frontend/webapp/public/icons/common/notification.svg new file mode 100644 index 000000000..f841dd774 --- /dev/null +++ b/frontend/webapp/public/icons/common/notification.svg @@ -0,0 +1,8 @@ + + + diff --git a/frontend/webapp/reuseable-components/index.ts b/frontend/webapp/reuseable-components/index.ts index 85b87b864..4c9ae0197 100644 --- a/frontend/webapp/reuseable-components/index.ts +++ b/frontend/webapp/reuseable-components/index.ts @@ -31,3 +31,4 @@ export * from './drawer'; export * from './input-table'; export * from './status'; export * from './field-label'; +export * from './transition'; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx index 19379f623..544c2454f 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx @@ -3,10 +3,11 @@ import React, { useMemo } from 'react'; import '@xyflow/react/dist/style.css'; import AddNode from './nodes/add-node'; import BaseNode from './nodes/base-node'; -import { ReactFlow } from '@xyflow/react'; +import { Controls, ReactFlow } from '@xyflow/react'; import GroupNode from './nodes/group-node'; import HeaderNode from './nodes/header-node'; import LabeledEdge from './edges/labeled-edge'; +import styled from 'styled-components'; interface NodeBaseDataFlowProps { nodes: any[]; @@ -15,6 +16,29 @@ interface NodeBaseDataFlowProps { nodeWidth: number; } +const FlowWrapper = styled.div` + height: calc(100vh - 160px); + .react-flow__attribution { + visibility: hidden; + } +`; + +const ControllerWrapper = styled.div` + button { + padding: 8px; + margin: 8px; + border-radius: 8px; + border: 1px solid ${({ theme }) => theme.colors.border} !important; + background-color: ${({ theme }) => theme.colors.dropdown_bg}; + path { + fill: #fff; + } + &:hover { + background-color: ${({ theme }) => theme.colors.dropdown_bg_2}; + } + } +`; + export function NodeBaseDataFlow({ nodes, edges, onNodeClick, nodeWidth }: NodeBaseDataFlowProps) { const nodeTypes = useMemo( () => ({ @@ -23,28 +47,24 @@ export function NodeBaseDataFlow({ nodes, edges, onNodeClick, nodeWidth }: NodeB base: (props) => , group: GroupNode, }), - [nodeWidth] + [nodeWidth], ); const edgeTypes = useMemo( () => ({ labeled: LabeledEdge, }), - [] + [], ); return ( -
- -
+ + + + + + + ); } diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx index fee14adbd..a51db7dbb 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx @@ -36,44 +36,44 @@ const ActionsWrapper = styled.div` const HeaderNode = ({ data, nodeWidth }: HeaderNodeProps) => { const { title, icon, tagValue } = data; + const { configuredSources, setConfiguredSources } = useAppStore((state) => state); + const { sources } = useSourceCRUD(); - const renderActions = () => { - if (title !== 'Sources') return null; - - const { configuredSources, setConfiguredSources } = useAppStore((state) => state); - const { sources } = useSourceCRUD(); - - const totalSelected = useMemo(() => { - let num = 0; - - Object.values(configuredSources).forEach((selectedSources) => { - num += selectedSources.length; - }); + const totalSelectedSources = useMemo(() => { + let num = 0; - return num; - }, [configuredSources]); + Object.values(configuredSources).forEach((selectedSources) => { + num += selectedSources.length; + }); - const sourcesToSelect = useMemo(() => { - const payload = {}; + return num; + }, [configuredSources]); - sources.forEach((source) => { - if (!payload[source.namespace]) { - payload[source.namespace] = [source]; - } else { - payload[source.namespace].push(source); - } - }); - - return payload; - }, [sources]); - - const isDisabled = !sources.length; - const isSelected = !isDisabled && sources.length === totalSelected; - const onSelect = (bool: boolean) => setConfiguredSources(bool ? sourcesToSelect : {}); + const renderActions = () => { + if (title !== 'Sources') return null; + if (!sources.length) return null; + + const onSelect = (bool: boolean) => { + if (bool) { + const payload = {}; + + sources.forEach((source) => { + if (!payload[source.namespace]) { + payload[source.namespace] = [source]; + } else { + payload[source.namespace].push(source); + } + }); + + setConfiguredSources(payload); + } else { + setConfiguredSources({}); + } + }; return ( - + ); }; diff --git a/frontend/webapp/reuseable-components/status/index.tsx b/frontend/webapp/reuseable-components/status/index.tsx index 967d90df1..01c39af9f 100644 --- a/frontend/webapp/reuseable-components/status/index.tsx +++ b/frontend/webapp/reuseable-components/status/index.tsx @@ -21,8 +21,7 @@ const StatusWrapper = styled.div` width: fit-content; padding: ${({ withIcon, withBorder, withSmaller }) => (withIcon || withBorder ? (withSmaller ? '4px 8px' : '8px 24px') : '0')}; border-radius: 32px; - border: 1px solid - ${({ withBorder, isActive, theme }) => (withBorder ? (isActive ? theme.colors.dark_green : theme.colors.dark_red) : 'transparent')}; + border: 1px solid ${({ withBorder, isActive, theme }) => (withBorder ? (isActive ? theme.colors.dark_green : theme.colors.dark_red) : 'transparent')}; background: ${({ withBackground, isActive }) => withBackground ? isActive @@ -54,7 +53,7 @@ const SubTitle = styled(Text)` font-weight: 400; font-size: ${({ withSmaller }) => (withSmaller ? '10px' : '12px')}; font-family: ${({ withSpecialFont, theme }) => (withSpecialFont ? theme.font_family.secondary : theme.font_family.primary)}; - color: ${({ isActive, theme }) => (isActive ? theme.colors.green['600'] : theme.colors.red['600'])}; + color: ${({ isActive }) => (isActive ? '#51DB51' : '#DB5151')}; text-transform: ${({ withSpecialFont }) => (withSpecialFont ? 'uppercase' : 'unset')}; `; diff --git a/frontend/webapp/reuseable-components/transition/index.tsx b/frontend/webapp/reuseable-components/transition/index.tsx new file mode 100644 index 000000000..07efa80bf --- /dev/null +++ b/frontend/webapp/reuseable-components/transition/index.tsx @@ -0,0 +1,28 @@ +import React, { PropsWithChildren, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import type { IStyledComponentBase, Keyframes, Substitute } from 'styled-components/dist/types'; + +interface Props { + container: IStyledComponentBase<'web', Substitute, HTMLDivElement>, {}>> & string; + animateIn: Keyframes; + animateOut: Keyframes; + enter: boolean; +} + +export const Transition: React.FC> = ({ container: Container, children, animateIn, animateOut, enter }) => { + const [isEntered, setIsEntered] = useState(false); + + useEffect(() => { + if (enter) setIsEntered(true); + }, [enter]); + + const AnimatedContainer = styled(Container)<{ isEntering: boolean; isLeaving: boolean }>` + animation: ${({ isEntering, isLeaving }) => (isEntering ? animateIn : isLeaving ? animateOut : 'none')} 0.3s forwards; + `; + + return ( + + {children} + + ); +}; diff --git a/frontend/webapp/store/index.ts b/frontend/webapp/store/index.ts index 6c820834c..b35649a7d 100644 --- a/frontend/webapp/store/index.ts +++ b/frontend/webapp/store/index.ts @@ -1,5 +1,6 @@ export * from './useAppStore'; export * from './useBooleanStore'; +export * from './useConnectionStore'; export * from './useDrawerStore'; export * from './useModalStore'; export * from './useNotificationStore'; diff --git a/frontend/webapp/store/useConnectionStore.ts b/frontend/webapp/store/useConnectionStore.ts new file mode 100644 index 000000000..4f012cfeb --- /dev/null +++ b/frontend/webapp/store/useConnectionStore.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +interface StoreStateValues { + connecting: boolean; + active: boolean; + title: string; + message: string; +} + +interface StoreStateSetters { + setConnectionStore: (state: StoreStateValues) => void; + setConnecting: (bool: boolean) => void; + setActive: (bool: boolean) => void; + setTitle: (str: string) => void; + setMessage: (str: string) => void; +} + +export const useConnectionStore = create((set) => ({ + connecting: true, + active: false, + title: '', + message: '', + + setConnectionStore: (state) => set(state), + setConnecting: (bool) => set({ connecting: bool }), + setActive: (bool) => set({ active: bool }), + setTitle: (str) => set({ title: str }), + setMessage: (str) => set({ message: str }), +})); diff --git a/frontend/webapp/store/useNotificationStore.ts b/frontend/webapp/store/useNotificationStore.ts index 9f38037ac..be688cef3 100644 --- a/frontend/webapp/store/useNotificationStore.ts +++ b/frontend/webapp/store/useNotificationStore.ts @@ -3,13 +3,7 @@ import type { Notification } from '@/types'; interface StoreState { notifications: Notification[]; - addNotification: (notif: { - type: Notification['type']; - title: Notification['title']; - message: Notification['message']; - crdType: Notification['crdType']; - target: Notification['target']; - }) => void; + addNotification: (notif: { type: Notification['type']; title: Notification['title']; message: Notification['message']; crdType: Notification['crdType']; target: Notification['target'] }) => void; markAsDismissed: (id?: string) => void; markAsSeen: (id?: string) => void; removeNotification: (id?: string) => void; @@ -24,7 +18,6 @@ export const useNotificationStore = create((set) => ({ return { notifications: [ - ...state.notifications, { ...notif, id: date.getTime().toString(), @@ -32,6 +25,7 @@ export const useNotificationStore = create((set) => ({ dismissed: false, seen: false, }, + ...state.notifications, ], }; }), diff --git a/frontend/webapp/styles/theme.ts b/frontend/webapp/styles/theme.ts index 125df26ef..2f85b2890 100644 --- a/frontend/webapp/styles/theme.ts +++ b/frontend/webapp/styles/theme.ts @@ -118,6 +118,9 @@ const colors = { dropdown_bg_2: '#333333', blank_background: '#111111' + hexPercentValues['000'], + orange_og: '#FE9239', + orange_soft: '#FFB160', + dark_red: '#802828', darker_red: '#611F1F', dark_green: '#2D4323', diff --git a/frontend/webapp/yarn.lock b/frontend/webapp/yarn.lock index ca7c5c5be..08143f452 100644 --- a/frontend/webapp/yarn.lock +++ b/frontend/webapp/yarn.lock @@ -4429,6 +4429,13 @@ iterator.prototype@^1.1.3: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" +javascript-time-ago@^2.5.11: + version "2.5.11" + resolved "https://registry.yarnpkg.com/javascript-time-ago/-/javascript-time-ago-2.5.11.tgz#f2743040ccdec603cb4ec1029eeccb0c595c942a" + integrity sha512-Zeyf5R7oM1fSMW9zsU3YgAYwE0bimEeF54Udn2ixGd8PUwu+z1Yc5t4Y8YScJDMHD6uCx6giLt3VJR5K4CMwbg== + dependencies: + relative-time-format "^1.1.6" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -5248,6 +5255,11 @@ rehackt@^0.1.0: resolved "https://registry.yarnpkg.com/rehackt/-/rehackt-0.1.0.tgz#a7c5e289c87345f70da8728a7eb878e5d03c696b" integrity sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw== +relative-time-format@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/relative-time-format/-/relative-time-format-1.1.6.tgz#724a5fbc3794b8e0471b6b61419af2ce699eb9f1" + integrity sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ== + request-progress@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" @@ -5799,13 +5811,6 @@ ts-invariant@^0.10.3: dependencies: tslib "^2.1.0" -ts-invariant@^0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.10.3.tgz#3e048ff96e91459ffca01304dbc7f61c1f642f6c" - integrity sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ== - dependencies: - tslib "^2.1.0" - tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" @@ -5936,11 +5941,6 @@ undici-types@~6.19.2, undici-types@~6.19.8: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== - unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"