Skip to content

Commit 01b2226

Browse files
feat: History Page V2 - event group row in history grouped table (#1095)
- Add WorkflowHistoryEventGroup component that renders the single row for an event group - Edit upstream components to pass required props - Implement item expansion in History Page V2 using `useExpansionToggle` hook - Fixes to grid styling - Add Workflow Actions Modal to History Page V2 Signed-off-by: Adhitya Mamallan <[email protected]>
1 parent 7a5a363 commit 01b2226

17 files changed

+1132
-27
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import React from 'react';
2+
3+
import dayjs from 'dayjs';
4+
5+
import { render, screen, act } from '@/test-utils/rtl';
6+
7+
import { WorkflowExecutionCloseStatus } from '@/__generated__/proto-ts/uber/cadence/api/v1/WorkflowExecutionCloseStatus';
8+
9+
import getFormattedEventsDuration from '../helpers/get-formatted-events-duration';
10+
import WorkflowHistoryEventGroupDuration from '../workflow-history-event-group-duration';
11+
import type { Props } from '../workflow-history-event-group-duration.types';
12+
13+
jest.mock('../helpers/get-formatted-events-duration', () =>
14+
jest.fn((startTime, endTime) =>
15+
String(dayjs(endTime ?? undefined).diff(dayjs(startTime), 'seconds'))
16+
)
17+
);
18+
19+
const mockStartTime = new Date('2024-01-01T10:00:00Z').getTime();
20+
const mockCloseTime = new Date('2024-01-01T10:01:00Z').getTime();
21+
const mockNow = new Date('2024-01-01T10:02:00Z').getTime();
22+
23+
describe('WorkflowHistoryEventGroupDuration', () => {
24+
beforeEach(() => {
25+
jest.useFakeTimers();
26+
jest.setSystemTime(mockNow);
27+
});
28+
29+
afterEach(() => {
30+
jest.clearAllMocks();
31+
jest.useRealTimers();
32+
});
33+
34+
it('renders duration for completed event', () => {
35+
setup({
36+
closeTime: mockCloseTime,
37+
});
38+
39+
expect(screen.getByText('60')).toBeInTheDocument();
40+
});
41+
42+
it('renders duration for ongoing event', () => {
43+
setup({
44+
closeTime: null,
45+
});
46+
expect(screen.getByText('120')).toBeInTheDocument();
47+
});
48+
49+
it('does not render duration for single event', () => {
50+
setup({
51+
eventsCount: 1,
52+
hasMissingEvents: false,
53+
});
54+
55+
expect(screen.queryByText('60')).not.toBeInTheDocument();
56+
});
57+
58+
it('renders duration for single event with missing events', () => {
59+
setup({
60+
eventsCount: 1,
61+
hasMissingEvents: true,
62+
});
63+
64+
expect(screen.getByText('120')).toBeInTheDocument();
65+
});
66+
67+
it('does not render duration when loading more events', () => {
68+
setup({
69+
loadingMoreEvents: true,
70+
});
71+
72+
expect(screen.queryByText('60')).not.toBeInTheDocument();
73+
});
74+
75+
it('does not render duration when workflow is archived without close time', () => {
76+
setup({
77+
closeTime: null,
78+
workflowIsArchived: true,
79+
});
80+
81+
expect(screen.queryByText('120')).not.toBeInTheDocument();
82+
});
83+
84+
it('does not render duration when workflow has close status without close time', () => {
85+
setup({
86+
workflowCloseStatus:
87+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED,
88+
});
89+
90+
expect(screen.queryByText('60')).not.toBeInTheDocument();
91+
});
92+
93+
it('updates duration for ongoing event every second', () => {
94+
setup({
95+
closeTime: null,
96+
});
97+
expect(screen.getByText('120')).toBeInTheDocument();
98+
99+
(getFormattedEventsDuration as jest.Mock).mockClear();
100+
act(() => {
101+
jest.advanceTimersByTime(1000);
102+
jest.setSystemTime(new Date(mockNow + 1000));
103+
});
104+
expect(screen.getByText('121')).toBeInTheDocument();
105+
act(() => {
106+
jest.advanceTimersByTime(1000);
107+
jest.setSystemTime(new Date(mockNow + 2000));
108+
});
109+
expect(screen.getByText('122')).toBeInTheDocument();
110+
111+
expect(getFormattedEventsDuration).toHaveBeenCalledTimes(2);
112+
});
113+
114+
it('cleans up interval when component unmounts', () => {
115+
const { unmount } = setup();
116+
117+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
118+
unmount();
119+
120+
expect(clearIntervalSpy).toHaveBeenCalled();
121+
});
122+
123+
it('uses workflow close time when close time is not provided', () => {
124+
setup({
125+
closeTime: null,
126+
workflowCloseTime: mockCloseTime,
127+
});
128+
129+
expect(getFormattedEventsDuration).toHaveBeenCalledWith(
130+
mockStartTime,
131+
mockCloseTime,
132+
expect.any(Boolean)
133+
);
134+
expect(screen.getByText('60')).toBeInTheDocument();
135+
});
136+
});
137+
138+
function setup({
139+
startTime = mockStartTime,
140+
closeTime,
141+
eventsCount = 2,
142+
hasMissingEvents = false,
143+
loadingMoreEvents = false,
144+
workflowIsArchived = false,
145+
workflowCloseStatus = WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID,
146+
workflowCloseTime = null,
147+
}: Partial<Props> = {}) {
148+
return render(
149+
<WorkflowHistoryEventGroupDuration
150+
startTime={startTime}
151+
closeTime={closeTime}
152+
eventsCount={eventsCount}
153+
hasMissingEvents={hasMissingEvents}
154+
loadingMoreEvents={loadingMoreEvents}
155+
workflowIsArchived={workflowIsArchived}
156+
workflowCloseStatus={workflowCloseStatus}
157+
workflowCloseTime={workflowCloseTime}
158+
/>
159+
);
160+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import getFormattedEventsDuration from '../get-formatted-events-duration';
2+
3+
jest.mock('@/utils/data-formatters/format-duration', () => ({
4+
__esModule: true,
5+
default: jest.fn(
6+
(duration, { minUnit }) =>
7+
`mocked: ${duration.seconds}s${minUnit === 'ms' ? ` ${duration.nanos / 1000000}ms` : ''}`
8+
),
9+
}));
10+
11+
const mockNow = new Date('2024-01-01T10:02:00Z');
12+
13+
describe('getFormattedEventsDuration', () => {
14+
beforeEach(() => {
15+
jest.useFakeTimers();
16+
jest.setSystemTime(mockNow);
17+
});
18+
19+
afterEach(() => {
20+
jest.useRealTimers();
21+
});
22+
23+
it('should return 0s for identical start and end times', () => {
24+
const duration = getFormattedEventsDuration(
25+
'2021-01-01T00:00:00Z',
26+
'2021-01-01T00:00:00Z'
27+
);
28+
expect(duration).toEqual('mocked: 0s 0ms');
29+
});
30+
31+
it('should return correct duration for 1 minute', () => {
32+
const duration = getFormattedEventsDuration(
33+
'2021-01-01T00:00:00Z',
34+
'2021-01-01T00:01:00Z'
35+
);
36+
expect(duration).toEqual('mocked: 60s 0ms');
37+
});
38+
39+
it('should return correct duration for 1 hour, 2 minutes, 3 seconds', () => {
40+
const duration = getFormattedEventsDuration(
41+
'2021-01-01T01:02:03Z',
42+
'2021-01-01T02:04:06Z'
43+
);
44+
expect(duration).toEqual(`mocked: ${60 * 60 + 2 * 60 + 3}s 0ms`);
45+
});
46+
47+
it('should handle endTime as null (use current time)', () => {
48+
const start = new Date(mockNow.getTime() - 60000).toISOString(); // 1 minute ago
49+
const duration = getFormattedEventsDuration(start, null);
50+
expect(duration).toEqual('mocked: 60s 0ms');
51+
});
52+
53+
it('should handle negative durations (start after end)', () => {
54+
const duration = getFormattedEventsDuration(
55+
'2021-01-01T01:00:00Z',
56+
'2021-01-01T00:00:00Z'
57+
);
58+
expect(duration).toEqual('mocked: -3600s 0ms');
59+
});
60+
61+
it('should handle numeric durations', () => {
62+
const duration = getFormattedEventsDuration(1726652232190, 1726652292194);
63+
expect(duration).toEqual('mocked: 60s 4ms');
64+
});
65+
66+
it('should remove ms from duration when hideMs is true', () => {
67+
const duration = getFormattedEventsDuration(
68+
1726652232190,
69+
1726652292194,
70+
true
71+
);
72+
expect(duration).toEqual('mocked: 60s');
73+
});
74+
75+
it('should not hide ms if there are no bigger units', () => {
76+
const duration = getFormattedEventsDuration(
77+
1726652232190,
78+
1726652232194,
79+
true
80+
);
81+
expect(duration).toEqual('mocked: 0s 4ms');
82+
});
83+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import formatDuration from '@/utils/data-formatters/format-duration';
2+
import dayjs from '@/utils/datetime/dayjs';
3+
4+
export default function getFormattedEventsDuration(
5+
startTime: Date | string | number | null,
6+
endTime: Date | string | number | null | undefined,
7+
hideMs: boolean = false
8+
) {
9+
const end = endTime ? dayjs(endTime) : dayjs();
10+
const start = dayjs(startTime);
11+
const diff = end.diff(start);
12+
const durationObj = dayjs.duration(diff);
13+
const seconds = Math.floor(durationObj.asSeconds());
14+
15+
const duration = formatDuration(
16+
{
17+
seconds: seconds.toString(),
18+
nanos: (durationObj.asMilliseconds() - seconds * 1000) * 1000000,
19+
},
20+
{ separator: ' ', minUnit: hideMs && seconds > 0 ? 's' : 'ms' }
21+
);
22+
23+
return duration;
24+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React, { useEffect, useState } from 'react';
2+
3+
import getFormattedEventsDuration from './helpers/get-formatted-events-duration';
4+
import { type Props } from './workflow-history-event-group-duration.types';
5+
6+
export default function WorkflowHistoryEventGroupDuration({
7+
startTime,
8+
closeTime,
9+
workflowIsArchived,
10+
workflowCloseStatus,
11+
eventsCount,
12+
hasMissingEvents,
13+
loadingMoreEvents,
14+
workflowCloseTime,
15+
}: Props) {
16+
const endTime = closeTime || workflowCloseTime;
17+
const workflowEnded =
18+
workflowIsArchived ||
19+
workflowCloseStatus !== 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID';
20+
const singleEvent = eventsCount === 1 && !hasMissingEvents;
21+
22+
const hideDuration =
23+
loadingMoreEvents || singleEvent || (workflowEnded && !endTime);
24+
const isOngoing = !endTime && !hideDuration;
25+
26+
const [duration, setDuration] = useState<string>(() =>
27+
getFormattedEventsDuration(startTime ?? null, endTime, isOngoing)
28+
);
29+
30+
useEffect(() => {
31+
setDuration(
32+
getFormattedEventsDuration(startTime ?? null, endTime, isOngoing)
33+
);
34+
if (isOngoing) {
35+
const interval = setInterval(() => {
36+
setDuration(
37+
getFormattedEventsDuration(startTime ?? null, endTime, true)
38+
);
39+
}, 1000);
40+
41+
return () => clearInterval(interval);
42+
}
43+
}, [startTime, endTime, isOngoing]);
44+
45+
if (!startTime || hideDuration) {
46+
return null;
47+
}
48+
49+
return <>{duration}</>;
50+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { type WorkflowExecutionCloseStatus } from '@/__generated__/proto-ts/uber/cadence/api/v1/WorkflowExecutionCloseStatus';
2+
3+
export type Props = {
4+
startTime: number | null | undefined;
5+
closeTime: number | null | undefined;
6+
workflowIsArchived: boolean;
7+
workflowCloseStatus: WorkflowExecutionCloseStatus | null | undefined;
8+
eventsCount: number;
9+
loadingMoreEvents: boolean;
10+
hasMissingEvents: boolean;
11+
workflowCloseTime: number | null | undefined;
12+
};

0 commit comments

Comments
 (0)