mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
feat: group timeline events by their position on the timeline
This commit is contained in:
parent
a1a24ea0b1
commit
a93f38d75c
@ -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',
|
||||
@ -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>(
|
||||
@ -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(
|
||||
{
|
||||
@ -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 (
|
||||
<>
|
||||
@ -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 />
|
||||
|
@ -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',
|
||||
@ -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}`,
|
||||
@ -48,7 +51,7 @@ const StyledEventCircle = styled('div', {
|
||||
},
|
||||
}));
|
||||
|
||||
const getEventIcon = (type: EventSchemaType) => {
|
||||
export const getEventIcon = (type: EventSchemaType) => {
|
||||
if (type === 'feature-environment-enabled') {
|
||||
return <ToggleOnIcon />;
|
||||
}
|
||||
@ -68,7 +71,7 @@ const getEventIcon = (type: EventSchemaType) => {
|
||||
return <QuestionMarkIcon />;
|
||||
};
|
||||
|
||||
const customEventVariants: Partial<
|
||||
export const customEventVariants: Partial<
|
||||
Record<EventSchemaType, CustomEventVariant>
|
||||
> = {
|
||||
'feature-environment-enabled': 'success',
|
||||
@ -76,21 +79,28 @@ const customEventVariants: Partial<
|
||||
'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';
|
||||
|
||||
|
@ -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',
|
||||
@ -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,
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user