1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

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.
This commit is contained in:
Nuno Góis 2024-11-05 09:21:19 +00:00 committed by GitHub
parent 2bd2d74f83
commit 38bd50dc8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 222 additions and 142 deletions

View File

@ -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 (
<StyledAIIconContainer demoStepsVisible={demoStepsVisible}>
<Tooltip arrow title='Unleash AI'>
<StyledAIIconButton
size='large'
onClick={() => {
trackEvent('unleash-ai-chat', {
props: {
eventType: 'open',
},
});
setOpen(true);
}}
>
<AIIcon />
</StyledAIIconButton>
<Highlight highlightKey='unleashAI'>
<StyledAIIconButton
size='large'
onClick={() => {
trackEvent('unleash-ai-chat', {
props: {
eventType: 'open',
},
});
setOpen(true);
}}
>
<AIIcon />
</StyledAIIconButton>
</Highlight>
</Tooltip>
</StyledAIIconContainer>
);
@ -219,51 +222,53 @@ export const AIChat = () => {
return (
<StyledAIChatContainer demoStepsVisible={demoStepsVisible}>
<StyledResizable
handlers={['top-left', 'top', 'left']}
minSize={{ width: '270px', height: '250px' }}
maxSize={{ width: '80vw', height: '90vh' }}
defaultSize={{ width: '320px', height: '500px' }}
onResize={() => scrollToEnd({ onlyIfAtEnd: true })}
>
<StyledChat>
<AIChatHeader
onNew={onNewChat}
onClose={() => {
trackEvent('unleash-ai-chat', {
props: {
eventType: 'close',
},
});
setOpen(false);
}}
/>
<StyledChatContent>
<AIChatDisclaimer />
<AIChatMessage from='assistant'>
Hello, how can I assist you?
</AIChatMessage>
{messages.map(({ role, content }, index) => (
<AIChatMessage key={index} from={role}>
{content}
</AIChatMessage>
))}
{loading && (
<Highlight highlightKey='unleashAI'>
<StyledResizable
handlers={['top-left', 'top', 'left']}
minSize={{ width: '270px', height: '250px' }}
maxSize={{ width: '80vw', height: '90vh' }}
defaultSize={{ width: '320px', height: '500px' }}
onResize={() => scrollToEnd({ onlyIfAtEnd: true })}
>
<StyledChat>
<AIChatHeader
onNew={onNewChat}
onClose={() => {
trackEvent('unleash-ai-chat', {
props: {
eventType: 'close',
},
});
setOpen(false);
}}
/>
<StyledChatContent>
<AIChatDisclaimer />
<AIChatMessage from='assistant'>
_Unleash AI is typing..._
Hello, how can I assist you?
</AIChatMessage>
)}
<div ref={chatEndRef} />
</StyledChatContent>
<AIChatInput
onSend={onSend}
loading={loading}
onHeightChange={() =>
scrollToEnd({ onlyIfAtEnd: true })
}
/>
</StyledChat>
</StyledResizable>
{messages.map(({ role, content }, index) => (
<AIChatMessage key={index} from={role}>
{content}
</AIChatMessage>
))}
{loading && (
<AIChatMessage from='assistant'>
_Unleash AI is typing..._
</AIChatMessage>
)}
<div ref={chatEndRef} />
</StyledChatContent>
<AIChatInput
onSend={onSend}
loading={loading}
onHeightChange={() =>
scrollToEnd({ onlyIfAtEnd: true })
}
/>
</StyledChat>
</StyledResizable>
</Highlight>
</StyledAIChatContainer>
);
};

View File

@ -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<{
<ThemeProvider>
<AnnouncerProvider>
<StickyProvider>
<Routes>
<Route
path={pathTemplate}
element={
<MainLayout>{children}</MainLayout>
}
/>
</Routes>
<HighlightProvider>
<Routes>
<Route
path={pathTemplate}
element={
<MainLayout>{children}</MainLayout>
}
/>
</Routes>
</HighlightProvider>
</StickyProvider>
</AnnouncerProvider>
</ThemeProvider>

View File

@ -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<{
<ThemeProvider>
<AnnouncerProvider>
<StickyProvider>
<Routes>
<Route
path={pathTemplate}
element={children}
/>
</Routes>
<HighlightProvider>
<Routes>
<Route
path={pathTemplate}
element={children}
/>
</Routes>
</HighlightProvider>
</StickyProvider>
</AnnouncerProvider>
</ThemeProvider>

View File

@ -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 (
<StyledHighlight highlighted={isHighlighted(highlightKey)}>
{children}
</StyledHighlight>
);
};

View File

@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
import type { IHighlightContext } from './HighlightProvider';
export const HighlightContext = createContext<IHighlightContext | undefined>(
undefined,
);
export const useHighlightContext = (): IHighlightContext => {
const context = useContext(HighlightContext);
if (!context) {
throw new Error(
'useHighlightContext must be used within a HighlightProvider',
);
}
return context;
};

View File

@ -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<HighlightState>(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 (
<HighlightContext.Provider value={contextValue}>
{children}
</HighlightContext.Provider>
);
};

View File

@ -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 = <K extends keyof EventTimelinePersistentState>(
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 (

View File

@ -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: <StyledLinearScaleIcon />,
preview: <EventTimelinePreview />,
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: <StyledAIIcon />,
preview: <AIPreview />,
onCheckItOut: () => highlight('unleashAI'),
show: Boolean(unleashAIAvailable) && unleashAIEnabled,
beta: true,
longDescription: (

View File

@ -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
>
<StyledHeaderEventTimelineButton
highlighted={highlighted}
onClick={() => {
trackEvent('event-timeline', {
props: {
eventType: showTimeline ? 'close' : 'open',
},
});
setShowTimeline(!showTimeline);
}}
size='large'
>
<LinearScaleIcon />
</StyledHeaderEventTimelineButton>
<Highlight highlightKey='eventTimeline'>
<IconButton
onClick={() => {
trackEvent('event-timeline', {
props: {
eventType: showTimeline ? 'close' : 'open',
},
});
setShowTimeline(!showTimeline);
}}
size='large'
>
<LinearScaleIcon />
</IconButton>
</Highlight>
</Tooltip>
);
};

View File

@ -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 = () => {
<FeedbackProvider>
<FeedbackCESProvider>
<StickyProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
<HighlightProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
</HighlightProvider>
</StickyProvider>
</FeedbackCESProvider>
</FeedbackProvider>

View File

@ -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 = (
<ThemeProvider>
<AnnouncerProvider>
<StickyProvider>
{children}
<HighlightProvider>
{children}
</HighlightProvider>
</StickyProvider>
</AnnouncerProvider>
</ThemeProvider>