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

feat: group timeline events by their position on the timeline

This commit is contained in:
David Leek 2024-09-25 11:57:02 +02:00
parent a1a24ea0b1
commit a93f38d75c
No known key found for this signature in database
GPG Key ID: 515EE0F1BB6D0BE1
6 changed files with 291 additions and 26 deletions

View File

@ -10,12 +10,17 @@ import {
type TimeSpanOption, type TimeSpanOption,
timeSpanOptions, timeSpanOptions,
} from './EventTimelineHeader/EventTimelineHeader'; } from './EventTimelineHeader/EventTimelineHeader';
import type { ISignal } from 'interfaces/signal';
export type EnrichedEvent = EventSchema & { export type EnrichedEvent = EventSchema & {
label: string; label: string;
summary: string; summary: string;
}; };
export type TimelineEvent = EnrichedEvent | ISignal;
export type TimelineEventGroup = TimelineEvent[];
const StyledRow = styled('div')({ const StyledRow = styled('div')({
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
@ -86,6 +91,7 @@ const RELEVANT_EVENT_TYPES: EventSchemaType[] = [
]; ];
const toISODateString = (date: Date) => date.toISOString().split('T')[0]; const toISODateString = (date: Date) => date.toISOString().split('T')[0];
const TIME_GROUPING_SIZE = 2;
export const EventTimeline = () => { export const EventTimeline = () => {
const [timeSpan, setTimeSpan] = useState<TimeSpanOption>( const [timeSpan, setTimeSpan] = useState<TimeSpanOption>(
@ -95,6 +101,19 @@ export const EventTimeline = () => {
const endDate = new Date(); const endDate = new Date();
const startDate = sub(endDate, timeSpan.value); const startDate = sub(endDate, timeSpan.value);
const timelineDuration = endDate.getTime() - startDate.getTime();
const timeGroups = 100 / TIME_GROUPING_SIZE;
const groups = new Array(timeGroups).fill(0).map((_, i) => {
const from = i * TIME_GROUPING_SIZE;
const to = from + TIME_GROUPING_SIZE;
const position = from + TIME_GROUPING_SIZE / 2;
return {
from,
to,
position,
events: [] as { position: number; event: EnrichedEvent }[],
};
});
const { events: baseEvents } = useEventSearch( const { events: baseEvents } = useEventSearch(
{ {
@ -117,7 +136,35 @@ export const EventTimeline = () => {
event.environment === environment.name), event.environment === environment.name),
); );
const sortedEvents = [...filteredEvents].reverse(); filteredEvents.forEach((event) => {
const eventTime = new Date(event.createdAt).getTime();
const position =
((eventTime - startDate.getTime()) / timelineDuration) * 100;
const grp = groups.find(
(group) => group && group.from <= position && group.to > position,
);
grp?.events.push({ position, event });
});
const mappedEvents = groups
.filter((group) => group.events.length > 0)
.map((group) => {
return group.events.length === 1
? {
id: group.events[0].event.id,
position: group.events[0].position,
event: group.events[0].event,
}
: {
position: group.position,
id: group.events[0].event.id,
events: group.events.map(
(ev) => ev.event,
) as TimelineEventGroup,
};
});
const sortedEvents = [...mappedEvents].reverse();
return ( return (
<> <>
@ -133,12 +180,11 @@ export const EventTimeline = () => {
<StyledTimelineContainer> <StyledTimelineContainer>
<StyledTimeline /> <StyledTimeline />
<StyledStart /> <StyledStart />
{sortedEvents.map((event) => ( {sortedEvents.map((entry) => (
<EventTimelineEvent <EventTimelineEvent
key={event.id} key={entry.id}
event={event} event={entry.event ?? entry.events}
startDate={startDate} position={`${entry.position}%`}
endDate={endDate}
/> />
))} ))}
<StyledEnd /> <StyledEnd />

View File

@ -8,13 +8,16 @@ import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined';
import SegmentsIcon from '@mui/icons-material/DonutLargeOutlined'; import SegmentsIcon from '@mui/icons-material/DonutLargeOutlined';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import type { EnrichedEvent } from '../EventTimeline'; import type { TimelineEvent, TimelineEventGroup } from '../EventTimeline';
import type { ISignal } from 'interfaces/signal';
import { EventTimelineGroup } from './EventTimelineGroup';
import { EventTimelineSignal } from './EventTimelineSignal';
type DefaultEventVariant = 'secondary'; type DefaultEventVariant = 'secondary';
type CustomEventVariant = 'success' | 'neutral'; type CustomEventVariant = 'success' | 'neutral';
type EventVariant = DefaultEventVariant | CustomEventVariant; type EventVariant = DefaultEventVariant | CustomEventVariant;
const StyledEvent = styled('div', { export const StyledEvent = styled('div', {
shouldForwardProp: (prop) => prop !== 'position', shouldForwardProp: (prop) => prop !== 'position',
})<{ position: string }>(({ position }) => ({ })<{ position: string }>(({ position }) => ({
position: 'absolute', position: 'absolute',
@ -26,11 +29,11 @@ const StyledEvent = styled('div', {
zIndex: 1, zIndex: 1,
})); }));
const StyledEventCircle = styled('div', { export const StyledEventCircle = styled('div', {
shouldForwardProp: (prop) => prop !== 'variant', shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant: EventVariant }>(({ theme, variant }) => ({ })<{ variant: EventVariant }>(({ theme, variant }) => ({
height: theme.spacing(3), height: theme.spacing(3.75),
width: theme.spacing(3), width: theme.spacing(3.75),
borderRadius: '50%', borderRadius: '50%',
backgroundColor: theme.palette[variant].light, backgroundColor: theme.palette[variant].light,
border: `1px solid ${theme.palette[variant].main}`, border: `1px solid ${theme.palette[variant].main}`,
@ -48,7 +51,7 @@ const StyledEventCircle = styled('div', {
}, },
})); }));
const getEventIcon = (type: EventSchemaType) => { export const getEventIcon = (type: EventSchemaType) => {
if (type === 'feature-environment-enabled') { if (type === 'feature-environment-enabled') {
return <ToggleOnIcon />; return <ToggleOnIcon />;
} }
@ -68,7 +71,7 @@ const getEventIcon = (type: EventSchemaType) => {
return <QuestionMarkIcon />; return <QuestionMarkIcon />;
}; };
const customEventVariants: Partial< export const customEventVariants: Partial<
Record<EventSchemaType, CustomEventVariant> Record<EventSchemaType, CustomEventVariant>
> = { > = {
'feature-environment-enabled': 'success', 'feature-environment-enabled': 'success',
@ -76,21 +79,28 @@ const customEventVariants: Partial<
'feature-archived': 'neutral', 'feature-archived': 'neutral',
}; };
interface IEventTimelineEventProps { export interface IEventTimelineEventProps {
event: EnrichedEvent; event: TimelineEvent | TimelineEventGroup;
startDate: Date; position: string;
endDate: Date;
} }
export const isSignal = (
event: TimelineEvent | TimelineEventGroup,
): event is ISignal => {
return !Array.isArray(event) && 'source' in event;
};
export const EventTimelineEvent = ({ export const EventTimelineEvent = ({
event, event,
startDate, position,
endDate,
}: IEventTimelineEventProps) => { }: IEventTimelineEventProps) => {
const timelineDuration = endDate.getTime() - startDate.getTime(); if (Array.isArray(event)) {
const eventTime = new Date(event.createdAt).getTime(); return <EventTimelineGroup event={event} position={position} />;
}
const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`; if (isSignal(event)) {
return <EventTimelineSignal event={event} position={position} />;
}
const variant = customEventVariants[event.type] || 'secondary'; const variant = customEventVariants[event.type] || 'secondary';

View File

@ -2,7 +2,9 @@ import { styled } from '@mui/material';
import { Markdown } from 'component/common/Markdown/Markdown'; import { Markdown } from 'component/common/Markdown/Markdown';
import { useLocationSettings } from 'hooks/useLocationSettings'; import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMDHMS } from 'utils/formatDate'; import { formatDateYMDHMS } from 'utils/formatDate';
import type { EnrichedEvent } from '../../EventTimeline'; import type { TimelineEvent, TimelineEventGroup } from '../../EventTimeline';
import { isSignal } from '../EventTimelineEvent';
import { EventTimelineEventTooltipGroupItem } from './EventTimelineEventTooltipGroupItem';
const StyledTooltipHeader = styled('div')(({ theme }) => ({ const StyledTooltipHeader = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -26,13 +28,35 @@ const StyledDateTime = styled('div')(({ theme }) => ({
})); }));
interface IEventTimelineEventTooltipProps { interface IEventTimelineEventTooltipProps {
event: EnrichedEvent; event: TimelineEvent | TimelineEventGroup;
} }
export const EventTimelineEventTooltip = ({ export const EventTimelineEventTooltip = ({
event, event,
}: IEventTimelineEventTooltipProps) => { }: IEventTimelineEventTooltipProps) => {
const { locationSettings } = useLocationSettings(); const { locationSettings } = useLocationSettings();
if (Array.isArray(event)) {
const firstEvent = Array.isArray(event)
? event[event.length - 1]
: event;
const eventDateTime = formatDateYMDHMS(
firstEvent.createdAt,
locationSettings?.locale,
);
return (
<>
<StyledTooltipHeader>
<StyledTooltipTitle>
{event.length} events occured
</StyledTooltipTitle>
<StyledDateTime>{eventDateTime}</StyledDateTime>
</StyledTooltipHeader>
{event.map((e) => (
<EventTimelineEventTooltipGroupItem key={e.id} event={e} />
))}
</>
);
}
const eventDateTime = formatDateYMDHMS( const eventDateTime = formatDateYMDHMS(
event.createdAt, event.createdAt,
locationSettings?.locale, locationSettings?.locale,
@ -41,10 +65,12 @@ export const EventTimelineEventTooltip = ({
return ( return (
<> <>
<StyledTooltipHeader> <StyledTooltipHeader>
<StyledTooltipTitle>{event.label}</StyledTooltipTitle> <StyledTooltipTitle>
{isSignal(event) ? '' : event.label}
</StyledTooltipTitle>
<StyledDateTime>{eventDateTime}</StyledDateTime> <StyledDateTime>{eventDateTime}</StyledDateTime>
</StyledTooltipHeader> </StyledTooltipHeader>
<Markdown>{event.summary}</Markdown> <Markdown>{isSignal(event) ? '' : event.summary}</Markdown>
</> </>
); );
}; };

View File

@ -0,0 +1,79 @@
import { styled } from '@mui/material';
import { Markdown } from 'component/common/Markdown/Markdown';
import {
customEventVariants,
getEventIcon,
isSignal,
} from '../EventTimelineEvent';
import type { TimelineEvent } from '../../EventTimeline';
type DefaultEventVariant = 'secondary';
type CustomEventVariant = 'success' | 'neutral';
type EventVariant = DefaultEventVariant | CustomEventVariant;
const StyledEventCircle = styled('div', {
shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant: EventVariant }>(({ theme, variant }) => ({
height: theme.spacing(3),
width: theme.spacing(3),
borderRadius: '50%',
backgroundColor: theme.palette[variant].light,
border: `1px solid ${theme.palette[variant].main}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s',
'& svg': {
color: theme.palette[variant].main,
height: theme.spacing(2.5),
width: theme.spacing(2.5),
},
}));
const StyledTooltipGroupItemHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: theme.spacing(1),
gap: theme.spacing(2),
}));
const StyledTooltipGroupItemIcon = styled('div')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.smallBody,
wordBreak: 'break-word',
}));
const StyledDateGroupItemText = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
wordBreak: 'break-word',
}));
interface IEventTimelineEventTooltipGroupItemProps {
event: TimelineEvent;
}
export const EventTimelineEventTooltipGroupItem = ({
event,
}: IEventTimelineEventTooltipGroupItemProps) => {
if (isSignal(event)) {
return null;
}
const variant = customEventVariants[event.type] || 'secondary';
return (
<>
<StyledTooltipGroupItemHeader>
<StyledTooltipGroupItemIcon>
<StyledEventCircle variant={variant}>
{getEventIcon(event.type)}
</StyledEventCircle>
</StyledTooltipGroupItemIcon>
<StyledDateGroupItemText>
<Markdown key={event.id}>{event.summary}</Markdown>
</StyledDateGroupItemText>
</StyledTooltipGroupItemHeader>
</>
);
};

View File

@ -0,0 +1,59 @@
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { StyledEvent } from './EventTimelineEvent';
import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip';
import { Badge, styled } from '@mui/material';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import type { TimelineEventGroup } from '../EventTimeline';
const StyledEventCircle = styled('div')(({ theme }) => ({
height: theme.spacing(3.75),
width: theme.spacing(3.75),
borderRadius: '50%',
backgroundColor: theme.palette.secondary.light,
border: `1px solid ${theme.palette.primary.main}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s',
'& svg': {
color: theme.palette.primary.main,
height: theme.spacing(2.5),
width: theme.spacing(2.5),
},
'&:hover': {
transform: 'scale(1.5)',
},
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
'& .MuiBadge-badge': {
fontSize: theme.fontSizes.smallerBody,
fontWeight: theme.fontWeight.bold,
},
}));
export interface IEventTimelineGroupProps {
event: TimelineEventGroup;
position: string;
}
export const EventTimelineGroup = ({
event,
position,
}: IEventTimelineGroupProps) => {
return (
<StyledEvent position={position}>
<HtmlTooltip
title={<EventTimelineEventTooltip event={event} />}
maxWidth={320}
arrow
>
<StyledBadge badgeContent={event.length} color='primary'>
<StyledEventCircle>
<MoreHorizIcon />
</StyledEventCircle>
</StyledBadge>
</HtmlTooltip>
</StyledEvent>
);
};

View File

@ -0,0 +1,45 @@
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import {
StyledEvent,
type IEventTimelineEventProps,
} from './EventTimelineEvent';
import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip';
import { styled } from '@mui/material';
const StyledEventCircle = styled('div')(({ theme }) => ({
height: theme.spacing(3.75),
width: theme.spacing(3.75),
borderRadius: '50%',
backgroundColor: theme.palette.warning.light,
border: `1px solid ${theme.palette.warning.main}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s',
'& svg': {
color: theme.palette.warning.main,
height: theme.spacing(2.5),
width: theme.spacing(2.5),
},
'&:hover': {
transform: 'scale(1.5)',
},
}));
export const EventTimelineSignal = ({
event,
position,
}: IEventTimelineEventProps) => {
return (
<StyledEvent position={position}>
<HtmlTooltip
title={<EventTimelineEventTooltip event={event} />}
maxWidth={320}
arrow
>
<StyledEventCircle>
{/*getEventIcon(event.type)*/}
</StyledEventCircle>
</HtmlTooltip>
</StyledEvent>
);
};