From a08cdc6c45773fdb62217be2b0613cdec1fc1902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 4 Nov 2024 15:55:47 +0000 Subject: [PATCH 1/2] refactor: introduce a highlight reusable component --- frontend/src/component/ai/AIChat.tsx | 145 +++++++++++------- .../changeRequest/ChangeRequest.test.tsx | 19 ++- .../ChangeRequestPermissions.test.tsx | 15 +- .../component/common/Highlight/Highlight.tsx | 43 ++++++ .../common/Highlight/HighlightContext.tsx | 18 +++ .../common/Highlight/HighlightProvider.tsx | 45 ++++++ .../EventTimeline/EventTimelineProvider.tsx | 18 +-- .../NewInUnleash/NewInUnleash.tsx | 8 +- .../menu/Header/HeaderEventTimelineButton.tsx | 65 +++----- frontend/src/index.tsx | 11 +- frontend/src/utils/testRenderer.tsx | 5 +- 11 files changed, 248 insertions(+), 144 deletions(-) create mode 100644 frontend/src/component/common/Highlight/Highlight.tsx create mode 100644 frontend/src/component/common/Highlight/HighlightContext.tsx create mode 100644 frontend/src/component/common/Highlight/HighlightProvider.tsx diff --git a/frontend/src/component/ai/AIChat.tsx b/frontend/src/component/ai/AIChat.tsx index 77656797ff80..0e9c32fd58ee 100644 --- a/frontend/src/component/ai/AIChat.tsx +++ b/frontend/src/component/ai/AIChat.tsx @@ -1,6 +1,12 @@ import { mutate } from 'swr'; import { ReactComponent as AIIcon } from 'assets/icons/AI.svg'; -import { IconButton, styled, Tooltip, useMediaQuery } from '@mui/material'; +import { + alpha, + IconButton, + styled, + Tooltip, + useMediaQuery, +} from '@mui/material'; import { useEffect, useRef, useState } from 'react'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; @@ -17,6 +23,7 @@ import { Resizable } from 'component/common/Resizable/Resizable'; import { AIChatDisclaimer } from './AIChatDisclaimer'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import theme from 'themes/theme'; +import { Highlight } from 'component/common/Highlight/Highlight'; const AI_ERROR_MESSAGE = { role: 'assistant', @@ -60,9 +67,27 @@ const StyledAIChatContainer = styled(StyledAIIconContainer, { }), })); -const StyledResizable = styled(Resizable)(({ theme }) => ({ +const StyledResizable = styled(Resizable, { + shouldForwardProp: (prop) => prop !== 'highlighted', +})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({ boxShadow: theme.boxShadows.popup, borderRadius: theme.shape.borderRadiusLarge, + animation: highlighted ? 'pulse 1.5s infinite linear' : 'none', + zIndex: highlighted ? theme.zIndex.tooltip : 'auto', + '@keyframes pulse': { + '0%': { + boxShadow: `0 0 0 0px ${alpha(theme.palette.primary.main, 0.5)}`, + transform: 'scale(1)', + }, + '50%': { + boxShadow: `0 0 0 15px ${alpha(theme.palette.primary.main, 0.2)}`, + transform: 'scale(1.1)', + }, + '100%': { + boxShadow: `0 0 0 30px ${alpha(theme.palette.primary.main, 0)}`, + transform: 'scale(1)', + }, + }, })); const StyledAIIconButton = styled(IconButton)(({ theme }) => ({ @@ -199,19 +224,21 @@ export const AIChat = () => { return ( - { - trackEvent('unleash-ai-chat', { - props: { - eventType: 'open', - }, - }); - setOpen(true); - }} - > - - + + { + trackEvent('unleash-ai-chat', { + props: { + eventType: 'open', + }, + }); + setOpen(true); + }} + > + + + ); @@ -219,51 +246,53 @@ export const AIChat = () => { return ( - scrollToEnd({ onlyIfAtEnd: true })} - > - - { - trackEvent('unleash-ai-chat', { - props: { - eventType: 'close', - }, - }); - setOpen(false); - }} - /> - - - - Hello, how can I assist you? - - {messages.map(({ role, content }, index) => ( - - {content} - - ))} - {loading && ( + + scrollToEnd({ onlyIfAtEnd: true })} + > + + { + trackEvent('unleash-ai-chat', { + props: { + eventType: 'close', + }, + }); + setOpen(false); + }} + /> + + - _Unleash AI is typing..._ + Hello, how can I assist you? - )} -
- - - scrollToEnd({ onlyIfAtEnd: true }) - } - /> - - + {messages.map(({ role, content }, index) => ( + + {content} + + ))} + {loading && ( + + _Unleash AI is typing..._ + + )} +
+ + + scrollToEnd({ onlyIfAtEnd: true }) + } + /> + + + ); }; diff --git a/frontend/src/component/changeRequest/ChangeRequest.test.tsx b/frontend/src/component/changeRequest/ChangeRequest.test.tsx index 7c3a2230c7a4..5d63df8b3de5 100644 --- a/frontend/src/component/changeRequest/ChangeRequest.test.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest.test.tsx @@ -10,6 +10,7 @@ import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/Announc import { testServerRoute, testServerSetup } from '../../utils/testServer'; import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer'; import { StickyProvider } from 'component/common/Sticky/StickyProvider'; +import { HighlightProvider } from 'component/common/Highlight/HighlightProvider'; const server = testServerSetup(); @@ -233,14 +234,16 @@ const UnleashUiSetup: FC<{ - - {children} - } - /> - + + + {children} + } + /> + + diff --git a/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx b/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx index 5c48470a171a..65af54ff10c8 100644 --- a/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx @@ -12,6 +12,7 @@ import type { IPermission } from '../../interfaces/user'; import { SWRConfig } from 'swr'; import type { ProjectMode } from '../project/Project/hooks/useProjectEnterpriseSettingsForm'; import { StickyProvider } from 'component/common/Sticky/StickyProvider'; +import { HighlightProvider } from 'component/common/Highlight/HighlightProvider'; const server = testServerSetup(); @@ -196,12 +197,14 @@ const UnleashUiSetup: FC<{ - - - + + + + + diff --git a/frontend/src/component/common/Highlight/Highlight.tsx b/frontend/src/component/common/Highlight/Highlight.tsx new file mode 100644 index 000000000000..b4e66197f977 --- /dev/null +++ b/frontend/src/component/common/Highlight/Highlight.tsx @@ -0,0 +1,43 @@ +import { alpha, styled } from '@mui/material'; +import type { ReactNode } from 'react'; +import { useHighlightContext } from './HighlightContext'; +import type { HighlightKey } from './HighlightProvider'; + +const StyledHighlight = styled('div', { + shouldForwardProp: (prop) => prop !== 'highlighted', +})<{ highlighted: boolean }>(({ theme, highlighted }) => ({ + '&&& > *': { + animation: highlighted ? 'pulse 1.5s infinite linear' : 'none', + zIndex: highlighted ? theme.zIndex.tooltip : 'auto', + transition: 'box-shadow 0.3s ease', + '@keyframes pulse': { + '0%': { + boxShadow: `0 0 0 0px ${alpha(theme.palette.primary.main, 0.5)}`, + transform: 'scale(1)', + }, + '50%': { + boxShadow: `0 0 0 15px ${alpha(theme.palette.primary.main, 0.2)}`, + transform: 'scale(1.05)', + }, + '100%': { + boxShadow: `0 0 0 30px ${alpha(theme.palette.primary.main, 0)}`, + transform: 'scale(1)', + }, + }, + }, +})); + +interface IHighlightProps { + highlightKey: HighlightKey; + children: ReactNode; +} + +export const Highlight = ({ highlightKey, children }: IHighlightProps) => { + const { isHighlighted } = useHighlightContext(); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/common/Highlight/HighlightContext.tsx b/frontend/src/component/common/Highlight/HighlightContext.tsx new file mode 100644 index 000000000000..7bd757aece8b --- /dev/null +++ b/frontend/src/component/common/Highlight/HighlightContext.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; +import type { IHighlightContext } from './HighlightProvider'; + +export const HighlightContext = createContext( + undefined, +); + +export const useHighlightContext = (): IHighlightContext => { + const context = useContext(HighlightContext); + + if (!context) { + throw new Error( + 'useHighlightContext must be used within a HighlightProvider', + ); + } + + return context; +}; diff --git a/frontend/src/component/common/Highlight/HighlightProvider.tsx b/frontend/src/component/common/Highlight/HighlightProvider.tsx new file mode 100644 index 000000000000..e18bbd2458af --- /dev/null +++ b/frontend/src/component/common/Highlight/HighlightProvider.tsx @@ -0,0 +1,45 @@ +import { useState, type ReactNode } from 'react'; +import { HighlightContext } from './HighlightContext'; + +const defaultState = { + eventTimeline: false, + unleashAI: false, +}; + +export type HighlightKey = keyof typeof defaultState; +type HighlightState = typeof defaultState; + +export interface IHighlightContext { + isHighlighted: (key: HighlightKey) => boolean; + highlight: (key: HighlightKey, timeout?: number) => void; +} + +interface IHighlightProviderProps { + children: ReactNode; +} + +export const HighlightProvider = ({ children }: IHighlightProviderProps) => { + const [state, setState] = useState(defaultState); + + const isHighlighted = (key: HighlightKey) => state[key]; + + const setHighlight = (key: HighlightKey, value: boolean) => { + setState((prevState) => ({ ...prevState, [key]: value })); + }; + + const highlight = (key: HighlightKey, timeout = 3000) => { + setHighlight(key, true); + setTimeout(() => setHighlight(key, false), timeout); + }; + + const contextValue: IHighlightContext = { + isHighlighted, + highlight, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx b/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx index f7496859b43b..2aa5740009c8 100644 --- a/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { EventTimelineContext } from './EventTimelineContext'; import { useLocalStorageState } from 'hooks/useLocalStorageState'; import type { IEnvironment } from 'interfaces/environments'; @@ -17,21 +17,15 @@ type EventTimelinePersistentState = { signalsSuggestionSeen?: boolean; }; -type EventTimelineTemporaryState = { - highlighted: boolean; -}; - type EventTimelineStateSetters = { setOpen: (open: boolean) => void; setTimeSpan: (timeSpan: TimeSpanOption) => void; setEnvironment: (environment: IEnvironment) => void; setSignalsSuggestionSeen: (seen: boolean) => void; - setHighlighted: (highlighted: boolean) => void; }; export interface IEventTimelineContext extends EventTimelinePersistentState, - EventTimelineTemporaryState, EventTimelineStateSetters {} export const timeSpanOptions: TimeSpanOption[] = [ @@ -100,7 +94,6 @@ export const EventTimelineProvider = ({ 'event-timeline:v1', defaultState, ); - const [highlighted, setHighlighted] = useState(false); const setField = ( key: K, @@ -109,16 +102,8 @@ export const EventTimelineProvider = ({ setState((prevState) => ({ ...prevState, [key]: value })); }; - const onSetHighlighted = (highlighted: boolean) => { - setHighlighted(highlighted); - if (highlighted) { - setTimeout(() => setHighlighted(false), 3000); - } - }; - const contextValue: IEventTimelineContext = { ...state, - highlighted, setOpen: (open: boolean) => setField('open', open), setTimeSpan: (timeSpan: TimeSpanOption) => setField('timeSpan', timeSpan), @@ -126,7 +111,6 @@ export const EventTimelineProvider = ({ setField('environment', environment), setSignalsSuggestionSeen: (seen: boolean) => setField('signalsSuggestionSeen', seen), - setHighlighted: onSetHighlighted, }; return ( diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx index a4341f495591..2872f662d363 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx @@ -20,10 +20,10 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { ReactComponent as SignalsPreview } from 'assets/img/signals.svg'; import LinearScaleIcon from '@mui/icons-material/LinearScale'; import { useNavigate } from 'react-router-dom'; -import { useEventTimelineContext } from 'component/events/EventTimeline/EventTimelineContext'; import { ReactComponent as EventTimelinePreview } from 'assets/img/eventTimeline.svg'; import { ReactComponent as AIIcon } from 'assets/icons/AI.svg'; import { ReactComponent as AIPreview } from 'assets/img/aiPreview.svg'; +import { useHighlightContext } from 'component/common/Highlight/HighlightContext'; const StyledNewInUnleash = styled('div')(({ theme }) => ({ margin: theme.spacing(2, 0, 1, 0), @@ -95,6 +95,7 @@ export const NewInUnleash = ({ onMiniModeClick, }: INewInUnleashProps) => { const navigate = useNavigate(); + const { highlight } = useHighlightContext(); const { trackEvent } = usePlausibleTracker(); const [seenItems, setSeenItems] = useLocalStorageState( 'new-in-unleash-seen:v1', @@ -108,8 +109,6 @@ export const NewInUnleash = ({ const signalsEnabled = useUiFlag('signals'); const unleashAIEnabled = useUiFlag('unleashAI'); - const { setHighlighted } = useEventTimelineContext(); - const items: NewInUnleashItemDetails[] = [ { label: 'Signals & Actions', @@ -153,7 +152,7 @@ export const NewInUnleash = ({ icon: , preview: , onCheckItOut: () => { - setHighlighted(true); + highlight('eventTimeline'); window.scrollTo({ top: 0, behavior: 'smooth', @@ -183,6 +182,7 @@ export const NewInUnleash = ({ 'Enhance your Unleash experience with the help of the Unleash AI assistant', icon: , preview: , + onCheckItOut: () => highlight('unleashAI'), show: Boolean(unleashAIAvailable) && unleashAIEnabled, beta: true, longDescription: ( diff --git a/frontend/src/component/menu/Header/HeaderEventTimelineButton.tsx b/frontend/src/component/menu/Header/HeaderEventTimelineButton.tsx index 9340c166db69..ab587c2b7c61 100644 --- a/frontend/src/component/menu/Header/HeaderEventTimelineButton.tsx +++ b/frontend/src/component/menu/Header/HeaderEventTimelineButton.tsx @@ -1,43 +1,15 @@ -import { alpha, IconButton, styled, Tooltip } from '@mui/material'; +import { IconButton, Tooltip } from '@mui/material'; import LinearScaleIcon from '@mui/icons-material/LinearScale'; import { useEventTimelineContext } from 'component/events/EventTimeline/EventTimelineContext'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; - -const StyledHeaderEventTimelineButton = styled(IconButton, { - shouldForwardProp: (prop) => prop !== 'highlighted', -})<{ - component?: 'a' | 'button'; - href?: string; - target?: string; - highlighted?: boolean; -}>(({ theme, highlighted }) => ({ - animation: highlighted ? 'pulse 1.5s infinite linear' : 'none', - zIndex: highlighted ? theme.zIndex.tooltip : 'auto', - '@keyframes pulse': { - '0%': { - boxShadow: `0 0 0 0px ${alpha(theme.palette.primary.main, 0.5)}`, - transform: 'scale(1)', - }, - '50%': { - boxShadow: `0 0 0 15px ${alpha(theme.palette.primary.main, 0.2)}`, - transform: 'scale(1.1)', - }, - '100%': { - boxShadow: `0 0 0 30px ${alpha(theme.palette.primary.main, 0)}`, - transform: 'scale(1)', - }, - }, -})); +import { Highlight } from 'component/common/Highlight/Highlight'; export const HeaderEventTimelineButton = () => { const { trackEvent } = usePlausibleTracker(); const { isOss } = useUiConfig(); - const { - open: showTimeline, - setOpen: setShowTimeline, - highlighted, - } = useEventTimelineContext(); + const { open: showTimeline, setOpen: setShowTimeline } = + useEventTimelineContext(); if (isOss()) return null; @@ -46,20 +18,21 @@ export const HeaderEventTimelineButton = () => { title={showTimeline ? 'Hide event timeline' : 'Show event timeline'} arrow > - { - trackEvent('event-timeline', { - props: { - eventType: showTimeline ? 'close' : 'open', - }, - }); - setShowTimeline(!showTimeline); - }} - size='large' - > - - + + { + trackEvent('event-timeline', { + props: { + eventType: showTimeline ? 'close' : 'open', + }, + }); + setShowTimeline(!showTimeline); + }} + size='large' + > + + + ); }; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index ac9bcc73676a..6165f97fe490 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -21,6 +21,7 @@ import { PlausibleProvider } from 'component/providers/PlausibleProvider/Plausib import { Error as LayoutError } from './component/layout/Error/Error'; import { ErrorBoundary } from 'react-error-boundary'; import { useRecordUIErrorApi } from 'hooks/api/actions/useRecordUIErrorApi/useRecordUiErrorApi'; +import { HighlightProvider } from 'component/common/Highlight/HighlightProvider'; window.global ||= window; @@ -56,10 +57,12 @@ const ApplicationRoot = () => { - - - - + + + + + + diff --git a/frontend/src/utils/testRenderer.tsx b/frontend/src/utils/testRenderer.tsx index 1b578f34abf4..54e33cd7c396 100644 --- a/frontend/src/utils/testRenderer.tsx +++ b/frontend/src/utils/testRenderer.tsx @@ -14,6 +14,7 @@ import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; import { QueryParamProvider } from 'use-query-params'; import { FeedbackProvider } from 'component/feedbackNew/FeedbackProvider'; import { StickyProvider } from 'component/common/Sticky/StickyProvider'; +import { HighlightProvider } from 'component/common/Highlight/HighlightProvider'; export const render = ( ui: JSX.Element, @@ -50,7 +51,9 @@ export const render = ( - {children} + + {children} + From dbeef673348d75db9c073c9b0446b77e7c9c807b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 4 Nov 2024 16:20:22 +0000 Subject: [PATCH 2/2] refactor: remove unused highlighted prop in styled resizable --- frontend/src/component/ai/AIChat.tsx | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/frontend/src/component/ai/AIChat.tsx b/frontend/src/component/ai/AIChat.tsx index 0e9c32fd58ee..193ee3f34fe3 100644 --- a/frontend/src/component/ai/AIChat.tsx +++ b/frontend/src/component/ai/AIChat.tsx @@ -1,12 +1,6 @@ import { mutate } from 'swr'; import { ReactComponent as AIIcon } from 'assets/icons/AI.svg'; -import { - alpha, - IconButton, - styled, - Tooltip, - useMediaQuery, -} from '@mui/material'; +import { IconButton, styled, Tooltip, useMediaQuery } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; @@ -67,27 +61,9 @@ const StyledAIChatContainer = styled(StyledAIIconContainer, { }), })); -const StyledResizable = styled(Resizable, { - shouldForwardProp: (prop) => prop !== 'highlighted', -})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({ +const StyledResizable = styled(Resizable)(({ theme }) => ({ boxShadow: theme.boxShadows.popup, borderRadius: theme.shape.borderRadiusLarge, - animation: highlighted ? 'pulse 1.5s infinite linear' : 'none', - zIndex: highlighted ? theme.zIndex.tooltip : 'auto', - '@keyframes pulse': { - '0%': { - boxShadow: `0 0 0 0px ${alpha(theme.palette.primary.main, 0.5)}`, - transform: 'scale(1)', - }, - '50%': { - boxShadow: `0 0 0 15px ${alpha(theme.palette.primary.main, 0.2)}`, - transform: 'scale(1.1)', - }, - '100%': { - boxShadow: `0 0 0 30px ${alpha(theme.palette.primary.main, 0)}`, - transform: 'scale(1)', - }, - }, })); const StyledAIIconButton = styled(IconButton)(({ theme }) => ({