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).  --------- Co-authored-by: David Leek <david@getunleash.io>
This commit is contained in:
parent
86e7bbc85d
commit
d161fb49ee
@ -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} />
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user