Skip to content

Commit

Permalink
refactor: introduce a highlight reusable component
Browse files Browse the repository at this point in the history
  • Loading branch information
nunogois committed Nov 4, 2024
1 parent 2e99452 commit a08cdc6
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 144 deletions.
145 changes: 87 additions & 58 deletions frontend/src/component/ai/AIChat.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -199,71 +224,75 @@ 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>
);
}

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>
);
};
19 changes: 11 additions & 8 deletions frontend/src/component/changeRequest/ChangeRequest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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>
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/component/common/Highlight/Highlight.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 0 deletions frontend/src/component/common/Highlight/HighlightContext.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
45 changes: 45 additions & 0 deletions frontend/src/component/common/Highlight/HighlightProvider.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit a08cdc6

Please sign in to comment.