mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
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" />
This commit is contained in:
parent
8943cc0a3d
commit
d2e2378481
@ -349,7 +349,10 @@ export const ChangeRequestOverview: FC = () => {
|
||||
<ChangeRequestHeader changeRequest={changeRequest} />
|
||||
<ChangeRequestBody>
|
||||
<StyledAsideBox>
|
||||
<ChangeRequestTimeline {...timelineProps} />
|
||||
<ChangeRequestTimeline
|
||||
{...timelineProps}
|
||||
timestamps={changeRequest.stateTransitionTimestamps}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={approversEnabled}
|
||||
show={
|
||||
|
@ -111,60 +111,63 @@ test('returns success for stages other than Rejected in Rejected state', () => {
|
||||
|
||||
describe('changeRequestScheduleProps', () => {
|
||||
test('returns correct props for a pending schedule', () => {
|
||||
const date = new Date();
|
||||
const schedule = {
|
||||
scheduledAt: new Date().toISOString(),
|
||||
scheduledAt: date.toISOString(),
|
||||
status: 'pending' as const,
|
||||
};
|
||||
|
||||
const time = 'some time string';
|
||||
|
||||
const { title, subtitle, color, reason } = getScheduleProps(
|
||||
schedule,
|
||||
time,
|
||||
);
|
||||
const { title, subtitle, color, reason } = getScheduleProps(schedule);
|
||||
expect(title).toBe('Scheduled');
|
||||
expect(subtitle).toBe(`for ${time}`);
|
||||
expect(color).toBe('warning');
|
||||
expect(reason).toBeNull();
|
||||
|
||||
render(subtitle);
|
||||
screen.getByText('for');
|
||||
const timeElement = screen.getByRole('time');
|
||||
const datetime = timeElement.getAttribute('datetime');
|
||||
expect(new Date(datetime || 1)).toEqual(date);
|
||||
});
|
||||
|
||||
test('returns correct props for a failed schedule', () => {
|
||||
const date = new Date();
|
||||
const schedule = {
|
||||
scheduledAt: new Date().toISOString(),
|
||||
scheduledAt: date.toISOString(),
|
||||
status: 'failed' as const,
|
||||
reason: 'reason',
|
||||
failureReason: 'failure reason',
|
||||
};
|
||||
|
||||
const time = 'some time string';
|
||||
|
||||
const { title, subtitle, color, reason } = getScheduleProps(
|
||||
schedule,
|
||||
time,
|
||||
);
|
||||
const { title, subtitle, color, reason } = getScheduleProps(schedule);
|
||||
expect(title).toBe('Schedule failed');
|
||||
expect(subtitle).toBe(`at ${time}`);
|
||||
expect(color).toBe('error');
|
||||
expect(reason).toBeTruthy();
|
||||
|
||||
render(subtitle);
|
||||
screen.getByText('at');
|
||||
const timeElement = screen.getByRole('time');
|
||||
const datetime = timeElement.getAttribute('datetime');
|
||||
expect(new Date(datetime || 1)).toEqual(date);
|
||||
});
|
||||
|
||||
test('returns correct props for a suspended schedule', () => {
|
||||
const date = new Date();
|
||||
const schedule = {
|
||||
scheduledAt: new Date().toISOString(),
|
||||
scheduledAt: date.toISOString(),
|
||||
status: 'suspended' as const,
|
||||
reason: 'reason',
|
||||
};
|
||||
|
||||
const time = 'some time string';
|
||||
|
||||
const { title, subtitle, color, reason } = getScheduleProps(
|
||||
schedule,
|
||||
time,
|
||||
);
|
||||
const { title, subtitle, color, reason } = getScheduleProps(schedule);
|
||||
expect(title).toBe('Schedule suspended');
|
||||
expect(subtitle).toBe(`was ${time}`);
|
||||
expect(color).toBe('grey');
|
||||
expect(reason).toBeTruthy();
|
||||
|
||||
render(subtitle);
|
||||
screen.getByText('was');
|
||||
const timeElement = screen.getByRole('time');
|
||||
const datetime = timeElement.getAttribute('datetime');
|
||||
expect(new Date(datetime || 1)).toEqual(date);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { Box, Paper, styled, Typography } from '@mui/material';
|
||||
import Timeline from '@mui/lab/Timeline';
|
||||
import TimelineItem, { timelineItemClasses } from '@mui/lab/TimelineItem';
|
||||
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';
|
||||
@ -9,16 +9,16 @@ 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 {
|
||||
type ILocationSettings,
|
||||
useLocationSettings,
|
||||
} from 'hooks/useLocationSettings';
|
||||
import { formatDateYMDHMS } from 'utils/formatDate';
|
||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||
import { formatDateYMDHM } from 'utils/formatDate.ts';
|
||||
|
||||
export type ISuggestChangeTimelineProps =
|
||||
export type ISuggestChangeTimelineProps = {
|
||||
timestamps?: ChangeRequestType['stateTransitionTimestamps']; // todo: update with flag `timestampsInChangeRequestTimeline`
|
||||
} & (
|
||||
| {
|
||||
state: Exclude<ChangeRequestState, 'Scheduled'>;
|
||||
schedule?: undefined;
|
||||
@ -26,7 +26,13 @@ export type ISuggestChangeTimelineProps =
|
||||
| {
|
||||
state: 'Scheduled';
|
||||
schedule: ChangeRequestSchedule;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const StyledTimelineContent = styled(TimelineContent)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}));
|
||||
|
||||
const StyledPaper = styled(Paper)(({ theme }) => ({
|
||||
marginTop: theme.spacing(2),
|
||||
@ -89,6 +95,7 @@ export const determineColor = (
|
||||
export const ChangeRequestTimeline: FC<ISuggestChangeTimelineProps> = ({
|
||||
state,
|
||||
schedule,
|
||||
timestamps,
|
||||
}) => {
|
||||
let data: ChangeRequestState[];
|
||||
switch (state) {
|
||||
@ -102,17 +109,24 @@ export const ChangeRequestTimeline: FC<ISuggestChangeTimelineProps> = ({
|
||||
data = steps;
|
||||
}
|
||||
const activeIndex = data.findIndex((item) => item === state);
|
||||
const { locationSettings } = useLocationSettings();
|
||||
|
||||
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 createTimelineScheduleItem(
|
||||
schedule,
|
||||
locationSettings,
|
||||
return (
|
||||
<TimelineScheduleItem
|
||||
key={title}
|
||||
schedule={schedule}
|
||||
timestamp={timestampComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -132,11 +146,17 @@ export const ChangeRequestTimeline: FC<ISuggestChangeTimelineProps> = ({
|
||||
timelineDotProps = { variant: 'outlined' };
|
||||
}
|
||||
|
||||
return createTimelineItem(
|
||||
color,
|
||||
title,
|
||||
index < data.length - 1,
|
||||
timelineDotProps,
|
||||
return (
|
||||
<TimelineItem
|
||||
key={title}
|
||||
color={color}
|
||||
title={title}
|
||||
shouldConnectToNextItem={
|
||||
index < data.length - 1
|
||||
}
|
||||
timestamp={timestampComponent}
|
||||
timelineDotProps={timelineDotProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledTimeline>
|
||||
@ -145,30 +165,61 @@ export const ChangeRequestTimeline: FC<ISuggestChangeTimelineProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const createTimelineItem = (
|
||||
color: 'primary' | 'success' | 'grey' | 'error' | 'warning',
|
||||
title: string,
|
||||
shouldConnectToNextItem: boolean,
|
||||
timelineDotProps: { [key: string]: string | undefined } = {},
|
||||
) => (
|
||||
<TimelineItem key={title}>
|
||||
<TimelineSeparator>
|
||||
<TimelineDot color={color} {...timelineDotProps} />
|
||||
{shouldConnectToNextItem && <TimelineConnector />}
|
||||
</TimelineSeparator>
|
||||
<TimelineContent>{title}</TimelineContent>
|
||||
</TimelineItem>
|
||||
);
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const getScheduleProps = (
|
||||
schedule: ChangeRequestSchedule,
|
||||
formattedTime: string,
|
||||
) => {
|
||||
switch (schedule.status) {
|
||||
case 'suspended':
|
||||
return {
|
||||
title: 'Schedule suspended',
|
||||
subtitle: `was ${formattedTime}`,
|
||||
subtitle: <Subtitle prefix='was' />,
|
||||
color: 'grey' as const,
|
||||
reason: (
|
||||
<HtmlTooltip title={schedule.reason} arrow>
|
||||
@ -179,7 +230,7 @@ export const getScheduleProps = (
|
||||
case 'failed':
|
||||
return {
|
||||
title: 'Schedule failed',
|
||||
subtitle: `at ${formattedTime}`,
|
||||
subtitle: <Subtitle prefix='at' />,
|
||||
color: 'error' as const,
|
||||
reason: (
|
||||
<HtmlTooltip
|
||||
@ -195,40 +246,38 @@ export const getScheduleProps = (
|
||||
default:
|
||||
return {
|
||||
title: 'Scheduled',
|
||||
subtitle: `for ${formattedTime}`,
|
||||
subtitle: <Subtitle prefix='for' />,
|
||||
color: 'warning' as const,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const createTimelineScheduleItem = (
|
||||
schedule: ChangeRequestSchedule,
|
||||
locationSettings: ILocationSettings,
|
||||
) => {
|
||||
const time = formatDateYMDHMS(
|
||||
new Date(schedule.scheduledAt),
|
||||
locationSettings?.locale,
|
||||
);
|
||||
|
||||
const { title, subtitle, color, reason } = getScheduleProps(schedule, time);
|
||||
const TimelineScheduleItem = ({
|
||||
schedule,
|
||||
timestamp,
|
||||
}: {
|
||||
schedule: ChangeRequestSchedule;
|
||||
timestamp: ReactNode;
|
||||
}) => {
|
||||
const { title, subtitle, color, reason } = getScheduleProps(schedule);
|
||||
|
||||
return (
|
||||
<TimelineItem key={title}>
|
||||
<MuiTimelineItem key={title}>
|
||||
<TimelineSeparator>
|
||||
<TimelineDot color={color} />
|
||||
<TimelineConnector />
|
||||
</TimelineSeparator>
|
||||
<TimelineContent>
|
||||
<StyledTimelineContent>
|
||||
{title}
|
||||
{timestamp}
|
||||
<StyledSubtitle>
|
||||
<Typography
|
||||
color={'text.secondary'}
|
||||
sx={{ mr: 1 }}
|
||||
>{`(${subtitle})`}</Typography>
|
||||
<Typography color={'text.secondary'} sx={{ mr: 1 }}>
|
||||
({subtitle})
|
||||
</Typography>
|
||||
{reason}
|
||||
</StyledSubtitle>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
</StyledTimelineContent>
|
||||
</MuiTimelineItem>
|
||||
);
|
||||
};
|
||||
|
@ -19,6 +19,7 @@ type BaseChangeRequest = {
|
||||
rejections: IChangeRequestApproval[];
|
||||
comments: IChangeRequestComment[];
|
||||
conflict?: string;
|
||||
stateTransitionTimestamps?: Partial<Record<ChangeRequestState, string>>; // todo(timestampsInChangeRequestTimeline): make sure this matches the model and what we return from the API
|
||||
};
|
||||
|
||||
export type UnscheduledChangeRequest = BaseChangeRequest & {
|
||||
|
Loading…
Reference in New Issue
Block a user