Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: group timeline events by their position on the timeline #8249

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions frontend/src/component/events/EventTimeline/EventTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import {
type TimeSpanOption,
timeSpanOptions,
} from './EventTimelineHeader/EventTimelineHeader';
import type { ISignal } from 'interfaces/signal';

export type EnrichedEvent = EventSchema & {
label: string;
summary: string;
};

export type TimelineEvent = EnrichedEvent | ISignal;

export type TimelineEventGroup = TimelineEvent[];

const StyledRow = styled('div')({
display: 'flex',
flexDirection: 'row',
Expand Down Expand Up @@ -86,6 +91,7 @@ const RELEVANT_EVENT_TYPES: EventSchemaType[] = [
];

const toISODateString = (date: Date) => date.toISOString().split('T')[0];
const TIME_GROUPING_SIZE = 2;

export const EventTimeline = () => {
const [timeSpan, setTimeSpan] = useState<TimeSpanOption>(
Expand All @@ -95,6 +101,19 @@ export const EventTimeline = () => {

const endDate = new Date();
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(
{
Expand All @@ -117,7 +136,35 @@ export const EventTimeline = () => {
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 (
<>
Expand All @@ -133,12 +180,11 @@ export const EventTimeline = () => {
<StyledTimelineContainer>
<StyledTimeline />
<StyledStart />
{sortedEvents.map((event) => (
{sortedEvents.map((entry) => (
<EventTimelineEvent
key={event.id}
event={event}
startDate={startDate}
endDate={endDate}
key={entry.id}
event={entry.event ?? entry.events}
position={`${entry.position}%`}
/>
))}
<StyledEnd />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ 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 type { TimelineEvent, TimelineEventGroup } from '../EventTimeline';
import type { ISignal } from 'interfaces/signal';
import { EventTimelineGroup } from './EventTimelineGroup';
import { EventTimelineSignal } from './EventTimelineSignal';

type DefaultEventVariant = 'secondary';
type CustomEventVariant = 'success' | 'neutral';
type EventVariant = DefaultEventVariant | CustomEventVariant;

const StyledEvent = styled('div', {
export const StyledEvent = styled('div', {
shouldForwardProp: (prop) => prop !== 'position',
})<{ position: string }>(({ position }) => ({
position: 'absolute',
Expand All @@ -26,11 +29,11 @@ const StyledEvent = styled('div', {
zIndex: 1,
}));

const StyledEventCircle = styled('div', {
export const StyledEventCircle = styled('div', {
shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant: EventVariant }>(({ theme, variant }) => ({
height: theme.spacing(3),
width: theme.spacing(3),
height: theme.spacing(3.75),
width: theme.spacing(3.75),
borderRadius: '50%',
backgroundColor: theme.palette[variant].light,
border: `1px solid ${theme.palette[variant].main}`,
Expand All @@ -48,7 +51,7 @@ const StyledEventCircle = styled('div', {
},
}));

const getEventIcon = (type: EventSchemaType) => {
export const getEventIcon = (type: EventSchemaType) => {
if (type === 'feature-environment-enabled') {
return <ToggleOnIcon />;
}
Expand All @@ -68,29 +71,36 @@ const getEventIcon = (type: EventSchemaType) => {
return <QuestionMarkIcon />;
};

const customEventVariants: Partial<
export const customEventVariants: Partial<
Record<EventSchemaType, CustomEventVariant>
> = {
'feature-environment-enabled': 'success',
'feature-environment-disabled': 'neutral',
'feature-archived': 'neutral',
};

interface IEventTimelineEventProps {
event: EnrichedEvent;
startDate: Date;
endDate: Date;
export interface IEventTimelineEventProps {
event: TimelineEvent | TimelineEventGroup;
position: string;
}

export const isSignal = (
event: TimelineEvent | TimelineEventGroup,
): event is ISignal => {
return !Array.isArray(event) && 'source' in event;
};

export const EventTimelineEvent = ({
event,
startDate,
endDate,
position,
}: IEventTimelineEventProps) => {
const timelineDuration = endDate.getTime() - startDate.getTime();
const eventTime = new Date(event.createdAt).getTime();
if (Array.isArray(event)) {
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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ 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';
import type { TimelineEvent, TimelineEventGroup } from '../../EventTimeline';
import { isSignal } from '../EventTimelineEvent';
import { EventTimelineEventTooltipGroupItem } from './EventTimelineEventTooltipGroupItem';

const StyledTooltipHeader = styled('div')(({ theme }) => ({
display: 'flex',
Expand All @@ -26,13 +28,35 @@ const StyledDateTime = styled('div')(({ theme }) => ({
}));

interface IEventTimelineEventTooltipProps {
event: EnrichedEvent;
event: TimelineEvent | TimelineEventGroup;
}

export const EventTimelineEventTooltip = ({
event,
}: IEventTimelineEventTooltipProps) => {
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(
event.createdAt,
locationSettings?.locale,
Expand All @@ -41,10 +65,12 @@ export const EventTimelineEventTooltip = ({
return (
<>
<StyledTooltipHeader>
<StyledTooltipTitle>{event.label}</StyledTooltipTitle>
<StyledTooltipTitle>
{isSignal(event) ? '' : event.label}
</StyledTooltipTitle>
<StyledDateTime>{eventDateTime}</StyledDateTime>
</StyledTooltipHeader>
<Markdown>{event.summary}</Markdown>
<Markdown>{isSignal(event) ? '' : event.summary}</Markdown>
</>
);
};
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading
Loading