diff --git a/frontend/src/assets/img/eventTimeline.svg b/frontend/src/assets/img/eventTimeline.svg new file mode 100644 index 000000000000..b36d84c5a61f --- /dev/null +++ b/frontend/src/assets/img/eventTimeline.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx b/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx index da3714ea6baf..f7496859b43b 100644 --- a/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimelineProvider.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { EventTimelineContext } from './EventTimelineContext'; import { useLocalStorageState } from 'hooks/useLocalStorageState'; import type { IEnvironment } from 'interfaces/environments'; @@ -10,22 +10,28 @@ type TimeSpanOption = { markers: string[]; }; -type EventTimelineState = { +type EventTimelinePersistentState = { open: boolean; timeSpan: TimeSpanOption; environment?: IEnvironment; 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 EventTimelineState, + extends EventTimelinePersistentState, + EventTimelineTemporaryState, EventTimelineStateSetters {} export const timeSpanOptions: TimeSpanOption[] = [ @@ -77,7 +83,7 @@ export const timeSpanOptions: TimeSpanOption[] = [ }, ]; -const defaultState: EventTimelineState = { +const defaultState: EventTimelinePersistentState = { open: false, timeSpan: timeSpanOptions[0], }; @@ -89,20 +95,30 @@ interface IEventTimelineProviderProps { export const EventTimelineProvider = ({ children, }: IEventTimelineProviderProps) => { - const [state, setState] = useLocalStorageState( - 'event-timeline:v1', - defaultState, - ); + const [state, setState] = + useLocalStorageState( + 'event-timeline:v1', + defaultState, + ); + const [highlighted, setHighlighted] = useState(false); - const setField = ( + const setField = ( key: K, - value: EventTimelineState[K], + value: EventTimelinePersistentState[K], ) => { 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), @@ -110,6 +126,7 @@ export const EventTimelineProvider = ({ setField('environment', environment), setSignalsSuggestionSeen: (seen: boolean) => setField('signalsSuggestionSeen', seen), + setHighlighted: onSetHighlighted, }; return ( diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx index 943f7f08155b..ea0aacaf5876 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx @@ -11,6 +11,7 @@ import { import { type FC, useEffect } from 'react'; import { useLastViewedProject } from 'hooks/useLastViewedProject'; import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { EventTimelineProvider } from 'component/events/EventTimeline/EventTimelineProvider'; const server = testServerSetup(); @@ -18,8 +19,29 @@ beforeEach(() => { window.localStorage.clear(); }); +const TestNavigationSidebar: FC<{ + project?: string; + flags?: LastViewedFlag[]; +}> = ({ project, flags }) => { + const { setLastViewed: setProject } = useLastViewedProject(); + const { setLastViewed: setFlag } = useLastViewedFlags(); + + useEffect(() => { + setProject(project); + flags?.forEach((flag) => { + setFlag(flag); + }); + }, []); + + return ( + + + + ); +}; + test('switch full mode and mini mode', () => { - render(); + render(); expect(screen.queryByText('Projects')).toBeInTheDocument(); expect(screen.queryByText('Applications')).toBeInTheDocument(); @@ -42,7 +64,7 @@ test('switch full mode and mini mode', () => { }); test('persist navigation mode and expansion selection in storage', async () => { - render(); + render(); const { value } = createLocalStorage('navigation-mode:v1', {}); expect(value).toBe('full'); @@ -70,7 +92,7 @@ test('persist navigation mode and expansion selection in storage', async () => { test('select active item', async () => { render( - } /> + } /> , { route: '/search' }, ); @@ -80,30 +102,13 @@ test('select active item', async () => { expect(links[1]).toHaveClass(classes.selected); }); -const SetupComponent: FC<{ project: string; flags: LastViewedFlag[] }> = ({ - project, - flags, -}) => { - const { setLastViewed: setProject } = useLastViewedProject(); - const { setLastViewed: setFlag } = useLastViewedFlags(); - - useEffect(() => { - setProject(project); - flags.forEach((flag) => { - setFlag(flag); - }); - }, []); - - return ; -}; - test('print recent projects and flags', async () => { testServerRoute(server, `/api/admin/projects/projectA/overview`, { name: 'projectNameA', }); render( - , diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx index c61f48490435..b4813f449f60 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleash.tsx @@ -16,6 +16,10 @@ import type { NavigationMode } from 'component/layout/MainLayout/NavigationSideb import { NewInUnleashItem } from './NewInUnleashItem'; 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'; const StyledNewInUnleash = styled('div')(({ theme }) => ({ margin: theme.spacing(2, 0, 1, 0), @@ -67,11 +71,15 @@ const StyledSignalsIcon = styled(Signals)(({ theme }) => ({ color: theme.palette.primary.main, })); +const StyledLinearScaleIcon = styled(LinearScaleIcon)(({ theme }) => ({ + color: theme.palette.primary.main, +})); + type NewItem = { label: string; summary: string; icon: ReactNode; - link: string; + onCheckItOut: () => void; docsLink: string; show: boolean; longDescription: ReactNode; @@ -89,13 +97,17 @@ export const NewInUnleash = ({ onItemClick, onMiniModeClick, }: INewInUnleashProps) => { + const navigate = useNavigate(); const { trackEvent } = usePlausibleTracker(); const [seenItems, setSeenItems] = useLocalStorageState( 'new-in-unleash-seen:v1', new Set(), ); - const { isEnterprise } = useUiConfig(); + const { isOss, isEnterprise } = useUiConfig(); const signalsEnabled = useUiFlag('signals'); + const eventTimelineEnabled = useUiFlag('eventTimeline'); + + const { setHighlighted } = useEventTimelineContext(); const items: NewItem[] = [ { @@ -103,7 +115,7 @@ export const NewInUnleash = ({ summary: 'Listen to signals via Webhooks', icon: , preview: , - link: '/integrations/signals', + onCheckItOut: () => navigate('/integrations/signals'), docsLink: 'https://docs.getunleash.io/reference/signals', show: isEnterprise() && signalsEnabled, longDescription: ( @@ -134,6 +146,35 @@ export const NewInUnleash = ({ ), }, + { + label: 'Event timeline', + summary: 'Keep track of recent events across all your projects', + icon: , + preview: , + onCheckItOut: () => { + setHighlighted(true); + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }, + docsLink: 'https://docs.getunleash.io/reference/events', + show: !isOss() && eventTimelineEnabled, + longDescription: ( + <> +

+ Monitor recent events across all your projects in one + unified timeline. +

+ +

+ You can access the event timeline from the top menu to + get an overview of changes and quickly identify and + debug any issues. +

+ + ), + }, ]; const visibleItems = items.filter( @@ -172,7 +213,7 @@ export const NewInUnleash = ({ ({ label, icon, - link, + onCheckItOut, longDescription, docsLink, preview, @@ -197,7 +238,7 @@ export const NewInUnleash = ({ }} label={label} icon={icon} - link={link} + onCheckItOut={onCheckItOut} preview={preview} longDescription={longDescription} docsLink={docsLink} diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashItem.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashItem.tsx index fb09483d850f..cf79f1fb6420 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashItem.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashItem.tsx @@ -36,7 +36,7 @@ interface INewInUnleashItemProps { onDismiss: () => void; label: string; longDescription: ReactNode; - link: string; + onCheckItOut: () => void; docsLink: string; preview?: ReactNode; summary: string; @@ -62,7 +62,7 @@ export const NewInUnleashItem = ({ onDismiss, label, longDescription, - link, + onCheckItOut, docsLink, preview, summary, @@ -87,7 +87,7 @@ export const NewInUnleashItem = ({ onClose={handleTooltipClose} title={label} longDescription={longDescription} - link={link} + onCheckItOut={onCheckItOut} docsLink={docsLink} preview={preview} > diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashTooltip.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashTooltip.tsx index 027087763fe7..761f994dfb18 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashTooltip.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NewInUnleash/NewInUnleashTooltip.tsx @@ -9,7 +9,7 @@ import { Typography, ClickAwayListener, } from '@mui/material'; -import { type Link as RouterLink, useNavigate } from 'react-router-dom'; +import type { Link as RouterLink } from 'react-router-dom'; import OpenInNew from '@mui/icons-material/OpenInNew'; import { ReactComponent as UnleashLogo } from 'assets/img/logoWithWhiteText.svg'; @@ -22,6 +22,7 @@ const Header = styled(Box)(({ theme }) => ({ const Body = styled(Box)(({ theme }) => ({ padding: theme.spacing(2), + lineHeight: 1.5, })); const StyledLink = styled(Link)(({ theme }) => ({ @@ -57,17 +58,22 @@ const CenteredPreview = styled(Box)(({ theme }) => ({ })); const LongDescription = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), ul: { + margin: 0, paddingLeft: theme.spacing(2), }, })); const Title = styled(Typography)(({ theme }) => ({ padding: theme.spacing(1, 0, 2, 0), + lineHeight: 1.5, })); const ReadMore = styled(Box)(({ theme }) => ({ - padding: theme.spacing(2, 0, 4, 0), + padding: theme.spacing(3, 0), })); export const NewInUnleashTooltip: FC<{ @@ -75,7 +81,7 @@ export const NewInUnleashTooltip: FC<{ title: string; longDescription: ReactNode; docsLink: string; - link: string; + onCheckItOut: () => void; open: boolean; preview?: ReactNode; onClose: () => void; @@ -83,72 +89,68 @@ export const NewInUnleashTooltip: FC<{ children, title, longDescription, - link, + onCheckItOut, docsLink, preview, open, onClose, -}) => { - const navigate = useNavigate(); - - return ( - - -
- {preview ? ( - {preview} - ) : ( - - - - )} -
- - {title} - {longDescription} - - - Read more in our - documentation - - - - -
- - } - > - {children} -
- ); -}; + Read more in our + documentation + + + + + + + } + > + {children} + +); diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx index cd3be43cc682..0e7aff9a59ab 100644 --- a/frontend/src/component/menu/Header/Header.tsx +++ b/frontend/src/component/menu/Header/Header.tsx @@ -33,9 +33,7 @@ import { useAdminRoutes } from 'component/admin/useAdminRoutes'; import InviteLinkButton from './InviteLink/InviteLinkButton/InviteLinkButton'; import { useUiFlag } from 'hooks/useUiFlag'; import { CommandBar } from 'component/commandBar/CommandBar'; -import LinearScaleIcon from '@mui/icons-material/LinearScale'; -import { useEventTimelineContext } from 'component/events/EventTimeline/EventTimelineContext'; -import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { HeaderEventTimelineButton } from './HeaderEventTimelineButton'; const HeaderComponent = styled(AppBar)(({ theme }) => ({ backgroundColor: theme.palette.background.paper, @@ -109,10 +107,6 @@ const Header = () => { const [openDrawer, setOpenDrawer] = useState(false); const toggleDrawer = () => setOpenDrawer((prev) => !prev); const celebatoryUnleash = useUiFlag('celebrateUnleash'); - const eventTimeline = useUiFlag('eventTimeline') && !isOss(); - const { open: showTimeline, setOpen: setShowTimeline } = - useEventTimelineContext(); - const { trackEvent } = usePlausibleTracker(); const routes = getRoutes(); const adminRoutes = useAdminRoutes(); @@ -187,35 +181,7 @@ const Header = () => { - - { - trackEvent('event-timeline', { - props: { - eventType: showTimeline - ? 'close' - : 'open', - }, - }); - setShowTimeline(!showTimeline); - }} - size='large' - > - - - - } - /> + 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)', + }, + }, +})); + +export const HeaderEventTimelineButton = () => { + const { trackEvent } = usePlausibleTracker(); + const { isOss } = useUiConfig(); + const eventTimeline = useUiFlag('eventTimeline') && !isOss(); + const { + open: showTimeline, + setOpen: setShowTimeline, + highlighted, + } = useEventTimelineContext(); + + if (!eventTimeline) return null; + + return ( + + { + trackEvent('event-timeline', { + props: { + eventType: showTimeline ? 'close' : 'open', + }, + }); + setShowTimeline(!showTimeline); + }} + size='large' + > + + + + ); +}; diff --git a/frontend/src/component/menu/Header/OldHeader.tsx b/frontend/src/component/menu/Header/OldHeader.tsx index 8ff9d7975112..d156f6c95d46 100644 --- a/frontend/src/component/menu/Header/OldHeader.tsx +++ b/frontend/src/component/menu/Header/OldHeader.tsx @@ -36,8 +36,7 @@ import { Notifications } from 'component/common/Notifications/Notifications'; import { useAdminRoutes } from 'component/admin/useAdminRoutes'; import InviteLinkButton from './InviteLink/InviteLinkButton/InviteLinkButton'; import { useUiFlag } from 'hooks/useUiFlag'; -import LinearScaleIcon from '@mui/icons-material/LinearScale'; -import { useEventTimelineContext } from 'component/events/EventTimeline/EventTimelineContext'; +import { HeaderEventTimelineButton } from './HeaderEventTimelineButton'; const HeaderComponent = styled(AppBar)(({ theme }) => ({ backgroundColor: theme.palette.background.paper, @@ -148,9 +147,6 @@ const OldHeader = () => { const onAdminClose = () => setAdminRef(null); const onConfigureClose = () => setConfigRef(null); const celebatoryUnleash = useUiFlag('celebrateUnleash'); - const eventTimeline = useUiFlag('eventTimeline') && !isOss(); - const { open: showTimeline, setOpen: setShowTimeline } = - useEventTimelineContext(); const routes = getRoutes(); const adminRoutes = useAdminRoutes(); @@ -250,28 +246,7 @@ const OldHeader = () => { /> - - - setShowTimeline(!showTimeline) - } - size='large' - > - - - - } - /> +