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:
parent
2bd2d74f83
commit
38bd50dc8a
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
43
frontend/src/component/common/Highlight/Highlight.tsx
Normal file
43
frontend/src/component/common/Highlight/Highlight.tsx
Normal 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>
|
||||
);
|
||||
};
|
18
frontend/src/component/common/Highlight/HighlightContext.tsx
Normal file
18
frontend/src/component/common/Highlight/HighlightContext.tsx
Normal 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;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
|
@ -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: (
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user