Skip to content

Commit 3236871

Browse files
Add badge to workflow history group to display time remaining (#1036)
* Add badge to workflow history group to display time remaining, if waitTimerInfo is set for the event group * Fix the formatDuration util to accept a minimum unit
1 parent 7374138 commit 3236871

14 files changed

+466
-15
lines changed

src/utils/data-formatters/__tests__/format-duration.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,11 @@ describe('formatDuration', () => {
3131
const duration: Duration = { seconds: '31556952', nanos: 0 };
3232
expect(formatDuration(duration, { separator: ' ' })).toBe('1y 5h 49m 12s');
3333
});
34+
35+
it('should format duration with custom min unit', () => {
36+
const duration: Duration = { seconds: '31556952', nanos: 0 };
37+
expect(formatDuration(duration, { separator: ' ', minUnit: 'h' })).toBe(
38+
'1y 5h'
39+
);
40+
});
3441
});

src/utils/data-formatters/format-duration.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { type Duration } from '@/__generated__/proto-ts/google/protobuf/Duration';
22
import dayjs from '@/utils/datetime/dayjs';
33

4+
export type FormatDurationUnitType = 'y' | 'M' | 'd' | 'h' | 'm' | 's' | 'ms';
5+
46
const formatDuration = (
57
duration: Duration | null,
6-
{ separator = ', ' }: { separator?: string } = {}
8+
{
9+
separator = ', ',
10+
minUnit = 'ms',
11+
}: { separator?: string; minUnit?: FormatDurationUnitType } = {}
712
) => {
813
const defaultReturn = '0s';
914
if (!duration) {
@@ -16,7 +21,16 @@ const formatDuration = (
1621
const intMillis = Math.floor(nanosAsMillis);
1722
const remainingNanosAsMillis = nanosAsMillis % 1;
1823
const milliseconds = secondsAsMillis + intMillis;
19-
const units = ['y', 'M', 'd', 'h', 'm', 's', 'ms'] as const;
24+
const allUnits: Array<FormatDurationUnitType> = [
25+
'y',
26+
'M',
27+
'd',
28+
'h',
29+
'm',
30+
's',
31+
'ms',
32+
];
33+
const units = allUnits.slice(0, allUnits.indexOf(minUnit) + 1);
2034
const values: Partial<Record<(typeof units)[number], number>> = {};
2135
let d = dayjs.duration(milliseconds);
2236
units.forEach((unit) => {

src/views/workflow-history/workflow-history-events-duration-badge/helpers/__tests__/get-formatted-events-duration.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import getFormattedEventsDuration from '../get-formatted-events-duration';
33
jest.mock('@/utils/data-formatters/format-duration', () => ({
44
__esModule: true,
55
default: jest.fn(
6-
(duration) => `mocked: ${duration.seconds}s ${duration.nanos / 1000000}ms`
6+
(duration, { minUnit }) =>
7+
`mocked: ${duration.seconds}s${minUnit === 'ms' ? ` ${duration.nanos / 1000000}ms` : ''}`
78
),
89
}));
910

src/views/workflow-history/workflow-history-events-duration-badge/helpers/get-formatted-events-duration.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,8 @@ export default function getFormattedEventsDuration(
1717
seconds: seconds.toString(),
1818
nanos: (durationObj.asMilliseconds() - seconds * 1000) * 1000000,
1919
},
20-
{ separator: ' ' }
20+
{ separator: ' ', minUnit: hideMs && seconds > 0 ? 's' : 'ms' }
2121
);
22-
// TODO: add this functionality to formatDuration in more reusable way
23-
if (hideMs && seconds > 0) {
24-
return duration.replace(/ \d+ms/i, '');
25-
}
2622

2723
return duration;
2824
}

src/views/workflow-history/workflow-history-events-duration-badge/workflow-history-events-duration-badge.styles.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { type BadgeOverrides } from 'baseui/badge/types';
22
import { type Theme } from 'baseui/theme';
33

4-
import themeLight from '@/config/theme/theme-light.config';
5-
64
export const overrides = {
75
Badge: {
86
Badge: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import React from 'react';
2+
3+
import { render, screen, act } from '@/test-utils/rtl';
4+
5+
import { WorkflowExecutionCloseStatus } from '@/__generated__/proto-ts/uber/cadence/api/v1/WorkflowExecutionCloseStatus';
6+
7+
import getFormattedRemainingDuration from '../helpers/get-formatted-remaining-duration';
8+
import WorkflowHistoryRemainingDurationBadge from '../workflow-history-remaining-duration-badge';
9+
import type { Props } from '../workflow-history-remaining-duration-badge.types';
10+
11+
jest.mock('../helpers/get-formatted-remaining-duration');
12+
13+
const mockStartTime = new Date('2024-01-01T10:00:00Z');
14+
const mockNow = new Date('2024-01-01T10:02:00Z');
15+
16+
const mockGetFormattedRemainingDuration =
17+
getFormattedRemainingDuration as jest.MockedFunction<
18+
typeof getFormattedRemainingDuration
19+
>;
20+
21+
describe('WorkflowHistoryRemainingDurationBadge', () => {
22+
beforeEach(() => {
23+
jest.useFakeTimers();
24+
jest.setSystemTime(mockNow);
25+
mockGetFormattedRemainingDuration.mockReturnValue('5m 30s');
26+
});
27+
28+
afterEach(() => {
29+
jest.clearAllMocks();
30+
jest.useRealTimers();
31+
});
32+
33+
it('renders remaining duration badge when duration is available', () => {
34+
setup();
35+
36+
expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();
37+
});
38+
39+
it('does not render badge when loading more events', () => {
40+
setup({
41+
loadingMoreEvents: true,
42+
});
43+
44+
expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
45+
});
46+
47+
it('does not render badge when workflow is archived', () => {
48+
setup({
49+
workflowIsArchived: true,
50+
});
51+
52+
expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
53+
});
54+
55+
it('does not render badge when workflow has close status', () => {
56+
const closeStatuses = [
57+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED,
58+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_FAILED,
59+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_CANCELED,
60+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_TERMINATED,
61+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_CONTINUED_AS_NEW,
62+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_TIMED_OUT,
63+
];
64+
65+
closeStatuses.forEach((status) => {
66+
const { unmount } = setup({
67+
workflowCloseStatus: status,
68+
});
69+
70+
expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
71+
unmount();
72+
});
73+
});
74+
75+
it('does not render badge when helper returns null', () => {
76+
mockGetFormattedRemainingDuration.mockReturnValue(null);
77+
78+
setup();
79+
80+
expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
81+
});
82+
83+
it('updates remaining duration every second', () => {
84+
setup();
85+
86+
expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();
87+
88+
// Mock different return values for subsequent calls
89+
mockGetFormattedRemainingDuration.mockReturnValueOnce('5m 29s');
90+
91+
act(() => {
92+
jest.advanceTimersByTime(1000);
93+
});
94+
95+
expect(screen.getByText('Remaining: 5m 29s')).toBeInTheDocument();
96+
97+
mockGetFormattedRemainingDuration.mockReturnValueOnce('5m 28s');
98+
99+
act(() => {
100+
jest.advanceTimersByTime(1000);
101+
});
102+
103+
expect(screen.getByText('Remaining: 5m 28s')).toBeInTheDocument();
104+
105+
// Verify the helper was called the expected number of times
106+
// Initial call + 2 interval updates = 3 calls
107+
expect(mockGetFormattedRemainingDuration).toHaveBeenCalledTimes(3);
108+
});
109+
110+
it('hides badge when duration becomes null during countdown', () => {
111+
setup();
112+
113+
expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();
114+
115+
// Mock helper to return null (indicating overrun)
116+
mockGetFormattedRemainingDuration.mockReturnValue(null);
117+
118+
act(() => {
119+
jest.advanceTimersByTime(1000);
120+
});
121+
122+
expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
123+
});
124+
125+
it('cleans up interval when component unmounts', () => {
126+
const { unmount } = setup();
127+
128+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
129+
unmount();
130+
131+
expect(clearIntervalSpy).toHaveBeenCalled();
132+
});
133+
134+
it('does not set up interval when shouldHide is true', () => {
135+
const setIntervalSpy = jest.spyOn(global, 'setInterval');
136+
137+
setup({
138+
workflowIsArchived: true,
139+
});
140+
141+
expect(setIntervalSpy).not.toHaveBeenCalled();
142+
});
143+
144+
it('clears existing interval when shouldHide becomes true', () => {
145+
const { rerender } = setup();
146+
147+
expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();
148+
149+
rerender(
150+
<WorkflowHistoryRemainingDurationBadge
151+
startTime={mockStartTime}
152+
expectedEndTime={new Date('2024-01-01T10:07:00Z').getTime()}
153+
prefix="Remaining:"
154+
workflowIsArchived={false}
155+
workflowCloseStatus={
156+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID
157+
}
158+
loadingMoreEvents={true}
159+
/>
160+
);
161+
162+
expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
163+
});
164+
});
165+
166+
function setup({
167+
startTime = mockStartTime,
168+
expectedEndTime = new Date('2024-01-01T10:07:00Z').getTime(), // 5 minutes from mockNow
169+
prefix = 'Remaining:',
170+
workflowIsArchived = false,
171+
workflowCloseStatus = WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID,
172+
loadingMoreEvents = false,
173+
}: Partial<Props> = {}) {
174+
return render(
175+
<WorkflowHistoryRemainingDurationBadge
176+
startTime={startTime}
177+
expectedEndTime={expectedEndTime}
178+
prefix={prefix}
179+
workflowIsArchived={workflowIsArchived}
180+
workflowCloseStatus={workflowCloseStatus}
181+
loadingMoreEvents={loadingMoreEvents}
182+
/>
183+
);
184+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import getFormattedRemainingDuration from '../get-formatted-remaining-duration';
2+
3+
jest.mock('@/utils/data-formatters/format-duration', () => ({
4+
__esModule: true,
5+
default: jest.fn((duration) => `mocked: ${duration.seconds}s`),
6+
}));
7+
8+
const mockNow = new Date('2024-01-01T10:02:00Z');
9+
10+
describe('getFormattedRemainingDuration', () => {
11+
beforeEach(() => {
12+
jest.useFakeTimers();
13+
jest.setSystemTime(mockNow);
14+
});
15+
16+
afterEach(() => {
17+
jest.useRealTimers();
18+
});
19+
20+
it('should return null when expected duration has passed', () => {
21+
const expectedEndTimeMs = new Date('2024-01-01T10:01:00Z').getTime(); // 1 minute ago
22+
23+
const result = getFormattedRemainingDuration(expectedEndTimeMs);
24+
25+
expect(result).toBeNull();
26+
});
27+
28+
it('should return null when expected duration exactly matches current time', () => {
29+
const expectedEndTimeMs = new Date('2024-01-01T10:02:00Z').getTime(); // exactly now
30+
31+
const result = getFormattedRemainingDuration(expectedEndTimeMs);
32+
33+
expect(result).toBeNull();
34+
});
35+
36+
it('should return remaining time when duration has not passed', () => {
37+
const expectedEndTimeMs = new Date('2024-01-01T10:05:00Z').getTime(); // 3 minutes from now
38+
39+
const result = getFormattedRemainingDuration(expectedEndTimeMs);
40+
41+
expect(result).toEqual('mocked: 180s'); // 3 minutes = 180 seconds
42+
});
43+
44+
it('should return 1s when less than 1 second remaining', () => {
45+
const expectedEndTimeMs = new Date('2024-01-01T10:02:00.500Z').getTime(); // 0.5 seconds from now
46+
47+
const result = getFormattedRemainingDuration(expectedEndTimeMs);
48+
49+
expect(result).toEqual('mocked: 1s');
50+
});
51+
52+
it('should work with numeric timestamp for expected end time', () => {
53+
const expectedEndTimeMs = new Date('2024-01-01T10:05:00Z').getTime(); // 3 minutes from now
54+
55+
const result = getFormattedRemainingDuration(expectedEndTimeMs);
56+
57+
expect(result).toEqual('mocked: 180s');
58+
});
59+
60+
it('should round up partial seconds using Math.ceil', () => {
61+
const expectedEndTimeMs = new Date('2024-01-01T10:02:01.300Z').getTime(); // 1.3 seconds from now
62+
63+
const result = getFormattedRemainingDuration(expectedEndTimeMs);
64+
65+
expect(result).toEqual('mocked: 2s'); // Math.ceil(1.3) = 2
66+
});
67+
68+
it('should handle exactly 1 second remaining', () => {
69+
const expectedEndTimeMs = new Date('2024-01-01T10:02:01Z').getTime(); // exactly 1 second from now
70+
71+
const result = getFormattedRemainingDuration(expectedEndTimeMs);
72+
73+
expect(result).toEqual('mocked: 1s');
74+
});
75+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import formatDuration from '@/utils/data-formatters/format-duration';
2+
import dayjs from '@/utils/datetime/dayjs';
3+
4+
export default function getFormattedRemainingDuration(
5+
expectedEndTimeMs: number
6+
): string | null {
7+
const now = dayjs();
8+
const expectedEnd = dayjs(expectedEndTimeMs);
9+
10+
if (now.isAfter(expectedEnd)) {
11+
return null;
12+
}
13+
14+
const remainingDurationMs = expectedEnd.diff(now);
15+
16+
// Round up, to compensate for the rounding-down in the events duration badge
17+
const seconds = Math.ceil(remainingDurationMs / 1000);
18+
if (seconds < 1) {
19+
return null;
20+
}
21+
22+
const duration = formatDuration(
23+
{
24+
seconds: seconds.toString(),
25+
nanos: 0,
26+
},
27+
{ separator: ' ', minUnit: 's' }
28+
);
29+
30+
return duration;
31+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { type BadgeOverrides } from 'baseui/badge/types';
2+
import { type Theme } from 'baseui/theme';
3+
4+
export const overrides = {
5+
badge: {
6+
Badge: {
7+
style: ({
8+
$theme,
9+
$hierarchy,
10+
}: {
11+
$theme: Theme;
12+
$hierarchy: string;
13+
}) => ({
14+
...$theme.typography.LabelXSmall,
15+
...($hierarchy === 'secondary'
16+
? {
17+
color: $theme.colors.contentSecondary,
18+
}
19+
: null),
20+
}),
21+
},
22+
} satisfies BadgeOverrides,
23+
};

0 commit comments

Comments
 (0)