From 38bd50dc8a1ab249356d74bb78b5b686d9eb12f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 5 Nov 2024 09:21:19 +0000 Subject: [PATCH] refactor: introduce a highlight reusable component (#8643) Follow-up to: https://github.com/Unleash/unleash/pull/8642 Introduces a reusable `Highlight` component that leverages the Context API pattern, enabling highlight effects to be triggered from anywhere in the application. This update refactors the existing highlight effect in the event timeline to use the new Highlight component and extends the functionality to include the Unleash AI experiment, triggered by its entry in the "New in Unleash" section. --- frontend/src/component/ai/AIChat.tsx | 117 +++++++++--------- .../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, 222 insertions(+), 142 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..193ee3f34fe3 100644 --- a/frontend/src/component/ai/AIChat.tsx +++ b/frontend/src/component/ai/AIChat.tsx @@ -17,6 +17,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', @@ -199,19 +200,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 +222,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} +