1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

chore: add event timeline to new in unleash (#8358)

https://linear.app/unleash/issue/2-2729/add-event-timeline-to-new-in-unleash

Adds the new event timeline to the "New in Unleash" section.

Unlike Signals & Actions, the Event timeline doesn’t have a dedicated
page to link to, as it's a global component within the layout. To
address this, we extend the "check it out" action in the New in Unleash
component by supporting a callback instead of a link. When the user
clicks "check it out" for this new item, the page smoothly scrolls to
the top, ~~the timeline opens (if it's not already)~~, and a temporary
highlight effect is triggered on the timeline header button.

Also includes some scouting / slight UX adjustments.


https://github.com/user-attachments/assets/fe49f21b-5986-46b2-8fc6-acb4daef9d08
This commit is contained in:
Nuno Góis 2024-10-04 08:20:55 +01:00 committed by GitHub
parent ec1fe6278a
commit 52b7e235fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 250 additions and 169 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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<EventTimelineState>(
'event-timeline:v1',
defaultState,
);
const [state, setState] =
useLocalStorageState<EventTimelinePersistentState>(
'event-timeline:v1',
defaultState,
);
const [highlighted, setHighlighted] = useState(false);
const setField = <K extends keyof EventTimelineState>(
const setField = <K extends keyof EventTimelinePersistentState>(
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 (

View File

@ -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 (
<EventTimelineProvider>
<NavigationSidebar />
</EventTimelineProvider>
);
};
test('switch full mode and mini mode', () => {
render(<NavigationSidebar />);
render(<TestNavigationSidebar />);
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(<NavigationSidebar />);
render(<TestNavigationSidebar />);
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(
<Routes>
<Route path={'/search'} element={<NavigationSidebar />} />
<Route path={'/search'} element={<TestNavigationSidebar />} />
</Routes>,
{ 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 <NavigationSidebar />;
};
test('print recent projects and flags', async () => {
testServerRoute(server, `/api/admin/projects/projectA/overview`, {
name: 'projectNameA',
});
render(
<SetupComponent
<TestNavigationSidebar
project={'projectA'}
flags={[{ featureId: 'featureA', projectId: 'projectB' }]}
/>,

View File

@ -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: <StyledSignalsIcon />,
preview: <SignalsPreview />,
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: <StyledLinearScaleIcon />,
preview: <EventTimelinePreview />,
onCheckItOut: () => {
setHighlighted(true);
window.scrollTo({
top: 0,
behavior: 'smooth',
});
},
docsLink: 'https://docs.getunleash.io/reference/events',
show: !isOss() && eventTimelineEnabled,
longDescription: (
<>
<p>
Monitor recent events across all your projects in one
unified timeline.
</p>
<p>
You can access the event timeline from the top menu to
get an overview of changes and quickly identify and
debug any issues.
</p>
</>
),
},
];
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}

View File

@ -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}
>

View File

@ -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<typeof RouterLink | 'a'>)(({ 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 (
<HtmlTooltip
disableFocusListener
disableHoverListener
disableTouchListener
onClose={onClose}
open={open}
maxHeight={800}
maxWidth={350}
arrow
tabIndex={0}
placement='right-end'
title={
<ClickAwayListener onClickAway={onClose}>
<Box>
<Header>
{preview ? (
<BottomPreview>{preview}</BottomPreview>
) : (
<CenteredPreview>
<UnleashLogo />
</CenteredPreview>
)}
</Header>
<Body>
<Title>{title}</Title>
<LongDescription>{longDescription}</LongDescription>
<ReadMore>
<StyledLink
component='a'
href={docsLink}
underline='hover'
rel='noopener noreferrer'
target='_blank'
>
<StyledOpenInNew /> Read more in our
documentation
</StyledLink>
</ReadMore>
<Button
variant='contained'
color='primary'
type='submit'
size='small'
onClick={(event) => {
event.stopPropagation();
onClose();
navigate(link);
}}
}) => (
<HtmlTooltip
disableFocusListener
disableHoverListener
disableTouchListener
onClose={onClose}
open={open}
maxHeight={800}
maxWidth={350}
arrow
tabIndex={0}
placement='right-end'
title={
<ClickAwayListener onClickAway={onClose}>
<Box>
<Header>
{preview ? (
<BottomPreview>{preview}</BottomPreview>
) : (
<CenteredPreview>
<UnleashLogo />
</CenteredPreview>
)}
</Header>
<Body>
<Title>{title}</Title>
<LongDescription>{longDescription}</LongDescription>
<ReadMore>
<StyledLink
component='a'
href={docsLink}
underline='hover'
rel='noopener noreferrer'
target='_blank'
>
Check it out
</Button>
</Body>
</Box>
</ClickAwayListener>
}
>
{children}
</HtmlTooltip>
);
};
<StyledOpenInNew /> Read more in our
documentation
</StyledLink>
</ReadMore>
<Button
variant='contained'
color='primary'
type='submit'
size='small'
onClick={(event) => {
event.stopPropagation();
onClose();
onCheckItOut();
}}
>
Check it out
</Button>
</Body>
</Box>
</ClickAwayListener>
}
>
{children}
</HtmlTooltip>
);

View File

@ -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 = () => {
<StyledNav>
<StyledUserContainer>
<CommandBar />
<ConditionallyRender
condition={eventTimeline}
show={
<Tooltip
title={
showTimeline
? 'Hide timeline'
: 'Show timeline'
}
arrow
>
<StyledIconButton
onClick={() => {
trackEvent('event-timeline', {
props: {
eventType: showTimeline
? 'close'
: 'open',
},
});
setShowTimeline(!showTimeline);
}}
size='large'
>
<LinearScaleIcon />
</StyledIconButton>
</Tooltip>
}
/>
<HeaderEventTimelineButton />
<InviteLinkButton />
<Tooltip
title={

View File

@ -0,0 +1,64 @@
import { alpha, IconButton, styled, 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';
import { useUiFlag } from 'hooks/useUiFlag';
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)',
},
},
}));
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 (
<Tooltip title={showTimeline ? 'Hide timeline' : 'Show timeline'} arrow>
<StyledHeaderEventTimelineButton
highlighted={highlighted}
onClick={() => {
trackEvent('event-timeline', {
props: {
eventType: showTimeline ? 'close' : 'open',
},
});
setShowTimeline(!showTimeline);
}}
size='large'
>
<LinearScaleIcon />
</StyledHeaderEventTimelineButton>
</Tooltip>
);
};

View File

@ -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 = () => {
/>
</StyledLinks>
<StyledUserContainer>
<ConditionallyRender
condition={eventTimeline}
show={
<Tooltip
title={
showTimeline
? 'Hide timeline'
: 'Show timeline'
}
arrow
>
<StyledIconButton
onClick={() =>
setShowTimeline(!showTimeline)
}
size='large'
>
<LinearScaleIcon />
</StyledIconButton>
</Tooltip>
}
/>
<HeaderEventTimelineButton />
<InviteLinkButton />
<Tooltip
title={