Skip to content
Merged
7 changes: 7 additions & 0 deletions src/utils/data-formatters/__tests__/format-duration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,11 @@ describe('formatDuration', () => {
const duration: Duration = { seconds: '31556952', nanos: 0 };
expect(formatDuration(duration, { separator: ' ' })).toBe('1y 5h 49m 12s');
});

it('should format duration with custom min unit', () => {
const duration: Duration = { seconds: '31556952', nanos: 0 };
expect(formatDuration(duration, { separator: ' ', minUnit: 'h' })).toBe(
'1y 5h'
);
});
});
18 changes: 16 additions & 2 deletions src/utils/data-formatters/format-duration.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { type Duration } from '@/__generated__/proto-ts/google/protobuf/Duration';
import dayjs from '@/utils/datetime/dayjs';

export type FormatDurationUnitType = 'y' | 'M' | 'd' | 'h' | 'm' | 's' | 'ms';

const formatDuration = (
duration: Duration | null,
{ separator = ', ' }: { separator?: string } = {}
{
separator = ', ',
minUnit = 'ms',
}: { separator?: string; minUnit?: FormatDurationUnitType } = {}
) => {
const defaultReturn = '0s';
if (!duration) {
Expand All @@ -16,7 +21,16 @@ const formatDuration = (
const intMillis = Math.floor(nanosAsMillis);
const remainingNanosAsMillis = nanosAsMillis % 1;
const milliseconds = secondsAsMillis + intMillis;
const units = ['y', 'M', 'd', 'h', 'm', 's', 'ms'] as const;
const allUnits: Array<FormatDurationUnitType> = [
'y',
'M',
'd',
'h',
'm',
's',
'ms',
];
const units = allUnits.slice(0, allUnits.indexOf(minUnit) + 1);
const values: Partial<Record<(typeof units)[number], number>> = {};
let d = dayjs.duration(milliseconds);
units.forEach((unit) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import getFormattedEventsDuration from '../get-formatted-events-duration';
jest.mock('@/utils/data-formatters/format-duration', () => ({
__esModule: true,
default: jest.fn(
(duration) => `mocked: ${duration.seconds}s ${duration.nanos / 1000000}ms`
(duration, { minUnit }) =>
`mocked: ${duration.seconds}s${minUnit === 'ms' ? ` ${duration.nanos / 1000000}ms` : ''}`
),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,8 @@ export default function getFormattedEventsDuration(
seconds: seconds.toString(),
nanos: (durationObj.asMilliseconds() - seconds * 1000) * 1000000,
},
{ separator: ' ' }
{ separator: ' ', minUnit: hideMs && seconds > 0 ? 's' : 'ms' }
);
// TODO: add this functionality to formatDuration in more reusable way
if (hideMs && seconds > 0) {
return duration.replace(/ \d+ms/i, '');
}

return duration;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { type BadgeOverrides } from 'baseui/badge/types';
import { type Theme } from 'baseui/theme';

import themeLight from '@/config/theme/theme-light.config';

export const overrides = {
Badge: {
Badge: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React from 'react';

import { render, screen, act } from '@/test-utils/rtl';

import { WorkflowExecutionCloseStatus } from '@/__generated__/proto-ts/uber/cadence/api/v1/WorkflowExecutionCloseStatus';

import getFormattedRemainingDuration from '../helpers/get-formatted-remaining-duration';
import WorkflowHistoryRemainingDurationBadge from '../workflow-history-remaining-duration-badge';
import type { Props } from '../workflow-history-remaining-duration-badge.types';

jest.mock('../helpers/get-formatted-remaining-duration');

const mockStartTime = new Date('2024-01-01T10:00:00Z');
const mockNow = new Date('2024-01-01T10:02:00Z');

const mockGetFormattedRemainingDuration =
getFormattedRemainingDuration as jest.MockedFunction<
typeof getFormattedRemainingDuration
>;

describe('WorkflowHistoryRemainingDurationBadge', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(mockNow);
mockGetFormattedRemainingDuration.mockReturnValue('5m 30s');
});

afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
});

it('renders remaining duration badge when duration is available', () => {
setup();

expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();
});

it('does not render badge when loading more events', () => {
setup({
loadingMoreEvents: true,
});

expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
});

it('does not render badge when workflow is archived', () => {
setup({
workflowIsArchived: true,
});

expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
});

it('does not render badge when workflow has close status', () => {
const closeStatuses = [
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED,
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_FAILED,
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_CANCELED,
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_TERMINATED,
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_CONTINUED_AS_NEW,
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_TIMED_OUT,
];

closeStatuses.forEach((status) => {
const { unmount } = setup({
workflowCloseStatus: status,
});

expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
unmount();
});
});

it('does not render badge when helper returns null', () => {
mockGetFormattedRemainingDuration.mockReturnValue(null);

setup();

expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
});

it('updates remaining duration every second', () => {
setup();

expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();

// Mock different return values for subsequent calls
mockGetFormattedRemainingDuration.mockReturnValueOnce('5m 29s');

act(() => {
jest.advanceTimersByTime(1000);
});

expect(screen.getByText('Remaining: 5m 29s')).toBeInTheDocument();

mockGetFormattedRemainingDuration.mockReturnValueOnce('5m 28s');

act(() => {
jest.advanceTimersByTime(1000);
});

expect(screen.getByText('Remaining: 5m 28s')).toBeInTheDocument();

// Verify the helper was called the expected number of times
// Initial call + 2 interval updates = 3 calls
expect(mockGetFormattedRemainingDuration).toHaveBeenCalledTimes(3);
});

it('hides badge when duration becomes null during countdown', () => {
setup();

expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();

// Mock helper to return null (indicating overrun)
mockGetFormattedRemainingDuration.mockReturnValue(null);

act(() => {
jest.advanceTimersByTime(1000);
});

expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
});

it('cleans up interval when component unmounts', () => {
const { unmount } = setup();

const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
unmount();

expect(clearIntervalSpy).toHaveBeenCalled();
});

it('does not set up interval when shouldHide is true', () => {
const setIntervalSpy = jest.spyOn(global, 'setInterval');

setup({
workflowIsArchived: true,
});

expect(setIntervalSpy).not.toHaveBeenCalled();
});

it('clears existing interval when shouldHide becomes true', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: test case title is not clear as interval is used differently in above tests, can be changed to doesn't render duration when shouldHide becomes true

const { rerender } = setup();

expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument();

rerender(
<WorkflowHistoryRemainingDurationBadge
startTime={mockStartTime}
expectedEndTime={new Date('2024-01-01T10:07:00Z').getTime()}
prefix="Remaining:"
workflowIsArchived={false}
workflowCloseStatus={
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID
}
loadingMoreEvents={true}
/>
);

expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument();
});
});

function setup({
startTime = mockStartTime,
expectedEndTime = new Date('2024-01-01T10:07:00Z').getTime(), // 5 minutes from mockNow
prefix = 'Remaining:',
workflowIsArchived = false,
workflowCloseStatus = WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID,
loadingMoreEvents = false,
}: Partial<Props> = {}) {
return render(
<WorkflowHistoryRemainingDurationBadge
startTime={startTime}
expectedEndTime={expectedEndTime}
prefix={prefix}
workflowIsArchived={workflowIsArchived}
workflowCloseStatus={workflowCloseStatus}
loadingMoreEvents={loadingMoreEvents}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import getFormattedRemainingDuration from '../get-formatted-remaining-duration';

jest.mock('@/utils/data-formatters/format-duration', () => ({
__esModule: true,
default: jest.fn((duration) => `mocked: ${duration.seconds}s`),
}));

const mockNow = new Date('2024-01-01T10:02:00Z');

describe('getFormattedRemainingDuration', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(mockNow);
});

afterEach(() => {
jest.useRealTimers();
});

it('should return null when expected duration has passed', () => {
const expectedEndTimeMs = new Date('2024-01-01T10:01:00Z').getTime(); // 1 minute ago

const result = getFormattedRemainingDuration(expectedEndTimeMs);

expect(result).toBeNull();
});

it('should return null when expected duration exactly matches current time', () => {
const expectedEndTimeMs = new Date('2024-01-01T10:02:00Z').getTime(); // exactly now

const result = getFormattedRemainingDuration(expectedEndTimeMs);

expect(result).toBeNull();
});

it('should return remaining time when duration has not passed', () => {
const expectedEndTimeMs = new Date('2024-01-01T10:05:00Z').getTime(); // 3 minutes from now

const result = getFormattedRemainingDuration(expectedEndTimeMs);

expect(result).toEqual('mocked: 180s'); // 3 minutes = 180 seconds
});

it('should return 1s when less than 1 second remaining', () => {
const expectedEndTimeMs = new Date('2024-01-01T10:02:00.500Z').getTime(); // 0.5 seconds from now

const result = getFormattedRemainingDuration(expectedEndTimeMs);

expect(result).toEqual('mocked: 1s');
});

it('should work with numeric timestamp for expected end time', () => {
const expectedEndTimeMs = new Date('2024-01-01T10:05:00Z').getTime(); // 3 minutes from now

const result = getFormattedRemainingDuration(expectedEndTimeMs);

expect(result).toEqual('mocked: 180s');
});

it('should round up partial seconds using Math.ceil', () => {
const expectedEndTimeMs = new Date('2024-01-01T10:02:01.300Z').getTime(); // 1.3 seconds from now

const result = getFormattedRemainingDuration(expectedEndTimeMs);

expect(result).toEqual('mocked: 2s'); // Math.ceil(1.3) = 2
});

it('should handle exactly 1 second remaining', () => {
const expectedEndTimeMs = new Date('2024-01-01T10:02:01Z').getTime(); // exactly 1 second from now

const result = getFormattedRemainingDuration(expectedEndTimeMs);

expect(result).toEqual('mocked: 1s');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import formatDuration from '@/utils/data-formatters/format-duration';
import dayjs from '@/utils/datetime/dayjs';

export default function getFormattedRemainingDuration(
expectedEndTimeMs: number
): string | null {
const now = dayjs();
const expectedEnd = dayjs(expectedEndTimeMs);

if (now.isAfter(expectedEnd)) {
return null;
}

const remainingDurationMs = expectedEnd.diff(now);

// Round up, to compensate for the rounding-down in the events duration badge
const seconds = Math.ceil(remainingDurationMs / 1000);
if (seconds < 1) {
return null;
}

const duration = formatDuration(
{
seconds: seconds.toString(),
nanos: 0,
},
{ separator: ' ', minUnit: 's' }
);

return duration;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type BadgeOverrides } from 'baseui/badge/types';
import { type Theme } from 'baseui/theme';

export const overrides = {
badge: {
Badge: {
style: ({
$theme,
$hierarchy,
}: {
$theme: Theme;
$hierarchy: string;
}) => ({
...$theme.typography.LabelXSmall,
...($hierarchy === 'secondary'
? {
color: $theme.colors.contentSecondary,
}
: null),
}),
},
} satisfies BadgeOverrides,
};
Loading