1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-13 11:17:26 +02:00
unleash.unleash/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestTimeline/ChangeRequestTimeline.tsx
Thomas Heartman d2e2378481
Show change request stage timestamps in UI (#10388)
Adds a timestamp for each state we have time (and that isn't a state
downstream from the current state) in the CR timeline.

<img width="437" height="318" alt="image"
src="https://github.com/user-attachments/assets/a499e73f-c506-46a0-8d1a-7e4eb5ec4f7d"
/>

The timestamp respects the user's preferred locale and uses the `time`
element.

I've used the current name of the API payload's timestamps as it stands
on the enterprise main branch for now. This name is not set in any
schemas yet, so it is likely to change. Because it's not currently
exposed to any users, that will not cause any issues. Name suggestions
are welcome if you have them.

We only show timestamps for states up to and including the current
state. Timestamps for downstream states (such as "approved" if you're in
the "in review" state), will not be shown, even if they exist in the
payload. (There are some decisions to make on whether to include these
in the payload at all or not.)

There's no flags in this PR. They're not necessary If the API payload
doesn't contain the timestamp, we just don't render the timestamp, and
the timeline looks the way it always did:
<img width="447" height="399" alt="image"
src="https://github.com/user-attachments/assets/0062393a-190c-4099-bc16-29f9da82e7ea"
/>


## Bonus work

In the `ChangeRequestTimeline.tsx` file, I've made a few extra changes:
- `createTimelineItem` and `createScheduledTimelineItem` have become
normal React components (`TimelineItem` and `ScheduledTimelineItem`) and
are being called as such (in accordance with [React
recommendations](https://react.dev/reference/rules/react-calls-components-and-hooks#never-call-component-functions-directly)).
- I've updated the subtitles for schedules to also use the time element,
to improve HTML structure.

## Outstanding work

There's a few things that still need to be sorted out (primarily with
UX). Mainly about how we handle scheduled items, which already have time
information. For now, it looks like this:

<img width="426" height="394" alt="image"
src="https://github.com/user-attachments/assets/4bfc4ca2-c738-4954-9251-8d063143371e"
/>

<img width="700" height="246" alt="image"
src="https://github.com/user-attachments/assets/fe688b08-c5c8-40f8-a9d0-fe455e44665f"
/>
2025-07-24 12:42:29 +00:00

284 lines
8.8 KiB
TypeScript

import type { FC, ReactNode } from 'react';
import { Box, Paper, styled, Typography } from '@mui/material';
import Timeline from '@mui/lab/Timeline';
import MuiTimelineItem, { timelineItemClasses } from '@mui/lab/TimelineItem';
import TimelineSeparator from '@mui/lab/TimelineSeparator';
import TimelineDot from '@mui/lab/TimelineDot';
import TimelineConnector from '@mui/lab/TimelineConnector';
import TimelineContent from '@mui/lab/TimelineContent';
import type {
ChangeRequestSchedule,
ChangeRequestState,
ChangeRequestType,
} from '../../changeRequest.types';
import { HtmlTooltip } from '../../../common/HtmlTooltip/HtmlTooltip.tsx';
import ErrorIcon from '@mui/icons-material/Error';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMDHM } from 'utils/formatDate.ts';
export type ISuggestChangeTimelineProps = {
timestamps?: ChangeRequestType['stateTransitionTimestamps']; // todo: update with flag `timestampsInChangeRequestTimeline`
} & (
| {
state: Exclude<ChangeRequestState, 'Scheduled'>;
schedule?: undefined;
}
| {
state: 'Scheduled';
schedule: ChangeRequestSchedule;
}
);
const StyledTimelineContent = styled(TimelineContent)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
}));
const StyledPaper = styled(Paper)(({ theme }) => ({
marginTop: theme.spacing(2),
borderRadius: `${theme.shape.borderRadiusLarge}px`,
}));
const StyledBox = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
marginBottom: `-${theme.spacing(4)}`,
}));
const StyledSubtitle = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-end',
}));
const StyledTimeline = styled(Timeline)(() => ({
[`& .${timelineItemClasses.root}:before`]: {
flex: 0,
padding: 0,
},
}));
const steps: ChangeRequestState[] = [
'Draft',
'In review',
'Approved',
'Applied',
];
const rejectedSteps: ChangeRequestState[] = ['Draft', 'In review', 'Rejected'];
const scheduledSteps: ChangeRequestState[] = [
'Draft',
'In review',
'Approved',
'Scheduled',
'Applied',
];
export const determineColor = (
changeRequestState: ChangeRequestState,
changeRequestStateIndex: number,
displayStage: ChangeRequestState,
displayStageIndex: number,
) => {
if (changeRequestState === 'Cancelled') return 'grey';
if (changeRequestState === 'Rejected')
return displayStage === 'Rejected' ? 'error' : 'success';
if (
changeRequestStateIndex !== -1 &&
changeRequestStateIndex >= displayStageIndex
)
return 'success';
if (changeRequestStateIndex + 1 === displayStageIndex) return 'primary';
return 'grey';
};
export const ChangeRequestTimeline: FC<ISuggestChangeTimelineProps> = ({
state,
schedule,
timestamps,
}) => {
let data: ChangeRequestState[];
switch (state) {
case 'Rejected':
data = rejectedSteps;
break;
case 'Scheduled':
data = scheduledSteps;
break;
default:
data = steps;
}
const activeIndex = data.findIndex((item) => item === state);
return (
<StyledPaper elevation={0}>
<StyledBox>
<StyledTimeline>
{data.map((title, index) => {
const timestampComponent =
index <= activeIndex && timestamps?.[title] ? (
<Time dateTime={timestamps?.[title]} />
) : undefined;
if (schedule && title === 'Scheduled') {
return (
<TimelineScheduleItem
key={title}
schedule={schedule}
timestamp={timestampComponent}
/>
);
}
const color = determineColor(
state,
activeIndex,
title,
index,
);
let timelineDotProps = {};
// Only add the outlined variant if it's the next step after the active one, but not for 'Draft' in 'Cancelled' state
if (
activeIndex + 1 === index &&
!(state === 'Cancelled' && title === 'Draft')
) {
timelineDotProps = { variant: 'outlined' };
}
return (
<TimelineItem
key={title}
color={color}
title={title}
shouldConnectToNextItem={
index < data.length - 1
}
timestamp={timestampComponent}
timelineDotProps={timelineDotProps}
/>
);
})}
</StyledTimeline>
</StyledBox>
</StyledPaper>
);
};
const Time = styled(({ dateTime, ...props }: { dateTime: string }) => {
const { locationSettings } = useLocationSettings();
const displayTime = formatDateYMDHM(
new Date(dateTime || ''),
locationSettings.locale,
);
return (
<time {...props} dateTime={dateTime}>
{displayTime}
</time>
);
})(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize,
}));
const TimelineItem = ({
color,
title,
shouldConnectToNextItem,
timestamp,
timelineDotProps = {},
}: {
color: 'primary' | 'success' | 'grey' | 'error' | 'warning';
title: string;
shouldConnectToNextItem: boolean;
timestamp?: ReactNode;
timelineDotProps: { [key: string]: string | undefined };
}) => {
return (
<MuiTimelineItem key={title}>
<TimelineSeparator>
<TimelineDot color={color} {...timelineDotProps} />
{shouldConnectToNextItem && <TimelineConnector />}
</TimelineSeparator>
<StyledTimelineContent>
{title}
{timestamp}
</StyledTimelineContent>
</MuiTimelineItem>
);
};
export const getScheduleProps = (schedule: ChangeRequestSchedule) => {
const Subtitle = ({ prefix }: { prefix: string }) => (
<>
{prefix} <Time dateTime={schedule.scheduledAt} />
</>
);
switch (schedule.status) {
case 'suspended':
return {
title: 'Schedule suspended',
subtitle: <Subtitle prefix='was' />,
color: 'grey' as const,
reason: (
<HtmlTooltip title={schedule.reason} arrow>
<ErrorIcon color={'disabled'} fontSize={'small'} />
</HtmlTooltip>
),
};
case 'failed':
return {
title: 'Schedule failed',
subtitle: <Subtitle prefix='at' />,
color: 'error' as const,
reason: (
<HtmlTooltip
title={`Schedule failed because of ${
schedule.reason || schedule.failureReason
}`}
arrow
>
<ErrorIcon color={'error'} fontSize={'small'} />
</HtmlTooltip>
),
};
default:
return {
title: 'Scheduled',
subtitle: <Subtitle prefix='for' />,
color: 'warning' as const,
reason: null,
};
}
};
const TimelineScheduleItem = ({
schedule,
timestamp,
}: {
schedule: ChangeRequestSchedule;
timestamp: ReactNode;
}) => {
const { title, subtitle, color, reason } = getScheduleProps(schedule);
return (
<MuiTimelineItem key={title}>
<TimelineSeparator>
<TimelineDot color={color} />
<TimelineConnector />
</TimelineSeparator>
<StyledTimelineContent>
{title}
{timestamp}
<StyledSubtitle>
<Typography color={'text.secondary'} sx={{ mr: 1 }}>
({subtitle})
</Typography>
{reason}
</StyledSubtitle>
</StyledTimelineContent>
</MuiTimelineItem>
);
};