1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-26 01:17:00 +02:00

chore: implement event grouping in the event timeline (#8254)

https://linear.app/unleash/issue/2-2663/implement-event-grouping-when-multiple-events-happen-in-a-short-period

This PR introduces a grouping logic for timeline events, enhancing the
way events are displayed when they occur close to each other.

We also updated and refactored components to support handling groups of
events rather than individual events.

Also includes some minor code cleanups and optimizations as part of
general refactoring efforts (scouting).


![image](https://github.com/user-attachments/assets/eed74ddd-017c-430d-b919-3cb7e257052d)

---------

Co-authored-by: David Leek <david@getunleash.io>
This commit is contained in:
Nuno Góis 2024-09-26 14:48:52 +01:00 committed by GitHub
parent 86e7bbc85d
commit d161fb49ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 259 additions and 104 deletions

View File

@ -19,6 +19,9 @@ const LinkRenderer = ({
);
};
export const Markdown = (props: ComponentProps<typeof ReactMarkdown>) => (
<ReactMarkdown components={{ a: LinkRenderer }} {...props} />
export const Markdown = ({
components,
...props
}: ComponentProps<typeof ReactMarkdown>) => (
<ReactMarkdown components={{ a: LinkRenderer, ...components }} {...props} />
);

View File

@ -2,15 +2,19 @@ import { styled } from '@mui/material';
import type { EventSchema, EventSchemaType } from 'openapi';
import { startOfDay, sub } from 'date-fns';
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
import { EventTimelineEvent } from './EventTimelineEvent/EventTimelineEvent';
import { EventTimelineEventGroup } from './EventTimelineEventGroup/EventTimelineEventGroup';
import { EventTimelineHeader } from './EventTimelineHeader/EventTimelineHeader';
import { useEventTimeline } from './useEventTimeline';
import { useMemo } from 'react';
export type EnrichedEvent = EventSchema & {
label: string;
summary: string;
timestamp: number;
};
export type TimelineEventGroup = EnrichedEvent[];
const StyledRow = styled('div')({
display: 'flex',
flexDirection: 'row',
@ -88,6 +92,8 @@ export const EventTimeline = () => {
const endDate = new Date();
const startDate = sub(endDate, timeSpan.value);
const endTime = endDate.getTime();
const startTime = startDate.getTime();
const { events: baseEvents } = useEventSearch(
{
@ -98,19 +104,52 @@ export const EventTimeline = () => {
{ refreshInterval: 10 * 1000 },
);
const events = baseEvents as EnrichedEvent[];
const events = useMemo(() => {
return baseEvents.map((event) => ({
...event,
timestamp: new Date(event.createdAt).getTime(),
}));
}, [baseEvents]) as EnrichedEvent[];
const filteredEvents = events.filter(
(event) =>
new Date(event.createdAt).getTime() >= startDate.getTime() &&
new Date(event.createdAt).getTime() <= endDate.getTime() &&
RELEVANT_EVENT_TYPES.includes(event.type) &&
event.timestamp >= startTime &&
event.timestamp <= endTime &&
(!event.environment ||
!environment ||
event.environment === environment.name),
);
const sortedEvents = [...filteredEvents].reverse();
const sortedEvents = filteredEvents.reverse();
const timespanInMs = endTime - startTime;
const groupingThresholdInMs = useMemo(
() => timespanInMs * 0.02,
[timespanInMs],
);
const groups = useMemo(
() =>
sortedEvents.reduce((groups: TimelineEventGroup[], event) => {
if (groups.length === 0) {
groups.push([event]);
} else {
const lastGroup = groups[groups.length - 1];
const lastEventInGroup = lastGroup[lastGroup.length - 1];
if (
event.timestamp - lastEventInGroup.timestamp <=
groupingThresholdInMs
) {
lastGroup.push(event);
} else {
groups.push([event]);
}
}
return groups;
}, []),
[sortedEvents, groupingThresholdInMs],
);
return (
<>
@ -126,10 +165,10 @@ export const EventTimeline = () => {
<StyledTimelineContainer>
<StyledTimeline />
<StyledStart />
{sortedEvents.map((event) => (
<EventTimelineEvent
key={event.id}
event={event}
{groups.map((group) => (
<EventTimelineEventGroup
key={group[0].id}
group={group}
startDate={startDate}
endDate={endDate}
/>

View File

@ -1,50 +0,0 @@
import { styled } from '@mui/material';
import { Markdown } from 'component/common/Markdown/Markdown';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMDHMS } from 'utils/formatDate';
import type { EnrichedEvent } from '../../EventTimeline';
const StyledTooltipHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: theme.spacing(1),
gap: theme.spacing(2),
flexWrap: 'wrap',
}));
const StyledTooltipTitle = styled('div')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.smallBody,
wordBreak: 'break-word',
flex: 1,
}));
const StyledDateTime = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
whiteSpace: 'nowrap',
}));
interface IEventTimelineEventTooltipProps {
event: EnrichedEvent;
}
export const EventTimelineEventTooltip = ({
event,
}: IEventTimelineEventTooltipProps) => {
const { locationSettings } = useLocationSettings();
const eventDateTime = formatDateYMDHMS(
event.createdAt,
locationSettings?.locale,
);
return (
<>
<StyledTooltipHeader>
<StyledTooltipTitle>{event.label}</StyledTooltipTitle>
<StyledDateTime>{eventDateTime}</StyledDateTime>
</StyledTooltipHeader>
<Markdown>{event.summary}</Markdown>
</>
);
};

View File

@ -1,34 +1,22 @@
import type { EventSchemaType } from 'openapi';
import { styled } from '@mui/material';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined';
import SegmentsIcon from '@mui/icons-material/DonutLargeOutlined';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import type { EnrichedEvent } from '../EventTimeline';
import { styled } from '@mui/material';
import type { TimelineEventGroup } from '../EventTimeline';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import type { HTMLAttributes } from 'react';
type DefaultEventVariant = 'secondary';
type CustomEventVariant = 'success' | 'neutral';
type EventVariant = DefaultEventVariant | CustomEventVariant;
const StyledEvent = styled('div', {
shouldForwardProp: (prop) => prop !== 'position',
})<{ position: string }>(({ position }) => ({
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
left: position,
transform: 'translateX(-50%)',
zIndex: 1,
}));
const StyledEventCircle = styled('div', {
shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant: EventVariant }>(({ theme, variant }) => ({
})<{ variant?: EventVariant }>(({ theme, variant = 'secondary' }) => ({
height: theme.spacing(3),
width: theme.spacing(3),
borderRadius: '50%',
@ -76,35 +64,31 @@ const customEventVariants: Partial<
'feature-archived': 'neutral',
};
interface IEventTimelineEventProps {
event: EnrichedEvent;
startDate: Date;
endDate: Date;
interface IEventTimelineEventCircleProps
extends HTMLAttributes<HTMLDivElement> {
group: TimelineEventGroup;
}
export const EventTimelineEvent = ({
event,
startDate,
endDate,
}: IEventTimelineEventProps) => {
const timelineDuration = endDate.getTime() - startDate.getTime();
const eventTime = new Date(event.createdAt).getTime();
export const EventTimelineEventCircle = ({
group,
...props
}: IEventTimelineEventCircleProps) => {
if (group.length === 1) {
const event = group[0];
const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`;
const variant = customEventVariants[event.type] || 'secondary';
return (
<StyledEventCircle
variant={customEventVariants[event.type]}
{...props}
>
{getEventIcon(event.type)}
</StyledEventCircle>
);
}
return (
<StyledEvent position={position}>
<HtmlTooltip
title={<EventTimelineEventTooltip event={event} />}
maxWidth={320}
arrow
>
<StyledEventCircle variant={variant}>
{getEventIcon(event.type)}
</StyledEventCircle>
</HtmlTooltip>
</StyledEvent>
<StyledEventCircle {...props}>
<MoreHorizIcon />
</StyledEventCircle>
);
};

View File

@ -0,0 +1,52 @@
import { Badge, styled } from '@mui/material';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip';
import type { TimelineEventGroup } from '../EventTimeline';
import { EventTimelineEventCircle } from './EventTimelineEventCircle';
const StyledEvent = styled('div', {
shouldForwardProp: (prop) => prop !== 'position',
})<{ position: string }>(({ position }) => ({
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
left: position,
transform: 'translateX(-50%)',
zIndex: 1,
}));
interface IEventTimelineEventProps {
group: TimelineEventGroup;
startDate: Date;
endDate: Date;
}
export const EventTimelineEventGroup = ({
group,
startDate,
endDate,
}: IEventTimelineEventProps) => {
const timelineDuration = endDate.getTime() - startDate.getTime();
const eventTime = new Date(group[0].createdAt).getTime();
const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`;
return (
<StyledEvent position={position}>
<HtmlTooltip
title={<EventTimelineEventTooltip group={group} />}
maxWidth={320}
arrow
>
<Badge
badgeContent={group.length}
color='primary'
invisible={group.length < 2}
>
<EventTimelineEventCircle group={group} />
</Badge>
</HtmlTooltip>
</StyledEvent>
);
};

View File

@ -0,0 +1,116 @@
import { styled } from '@mui/material';
import { Markdown } from 'component/common/Markdown/Markdown';
import { useLocationSettings } from 'hooks/useLocationSettings';
import {
formatDateHMS,
formatDateYMDHMS,
formatDateYMD,
} from 'utils/formatDate';
import type { TimelineEventGroup } from '../../EventTimeline';
import { EventTimelineEventCircle } from '../EventTimelineEventCircle';
const StyledTooltipHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: theme.spacing(1),
gap: theme.spacing(2),
flexWrap: 'wrap',
}));
const StyledTooltipTitle = styled('div')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.smallBody,
wordBreak: 'break-word',
flex: 1,
}));
const StyledDateTime = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
whiteSpace: 'nowrap',
}));
const StyledDate = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
whiteSpace: 'nowrap',
}));
const StyledTooltipItem = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
marginBottom: theme.spacing(1),
}));
const StyledEventTimelineEventCircle = styled(EventTimelineEventCircle)(
({ theme }) => ({
marginTop: theme.spacing(0.5),
height: theme.spacing(2.5),
width: theme.spacing(2.5),
transition: 'none',
'& > svg': {
height: theme.spacing(2),
},
'&:hover': {
transform: 'none',
},
}),
);
interface IEventTimelineEventTooltipProps {
group: TimelineEventGroup;
}
export const EventTimelineEventTooltip = ({
group,
}: IEventTimelineEventTooltipProps) => {
const { locationSettings } = useLocationSettings();
if (group.length === 1) {
const event = group[0];
const eventDateTime = formatDateYMDHMS(
event.createdAt,
locationSettings?.locale,
);
return (
<>
<StyledTooltipHeader>
<StyledTooltipTitle>{event.label}</StyledTooltipTitle>
<StyledDateTime>{eventDateTime}</StyledDateTime>
</StyledTooltipHeader>
<Markdown>{event.summary}</Markdown>
</>
);
}
const firstEvent = group[0];
const eventDate = formatDateYMD(
firstEvent.createdAt,
locationSettings?.locale,
);
return (
<>
<StyledTooltipHeader>
<StyledTooltipTitle>
{group.length} events occurred
</StyledTooltipTitle>
<StyledDate>{eventDate}</StyledDate>
</StyledTooltipHeader>
{group.map((event) => (
<StyledTooltipItem key={event.id}>
<StyledEventTimelineEventCircle group={[event]} />
<div>
<StyledDate>
{formatDateHMS(
event.createdAt,
locationSettings?.locale,
)}
</StyledDate>
<Markdown>{event.summary}</Markdown>
</div>
</StyledTooltipItem>
))}
</>
);
};

View File

@ -49,3 +49,14 @@ export const formatDateHM = (
minute: '2-digit',
});
};
export const formatDateHMS = (
date: number | string | Date,
locale: string,
): string => {
return new Date(date).toLocaleString(locale, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};