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:
parent
a1a24ea0b1
commit
a93f38d75c
@ -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 />
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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