-
Notifications
You must be signed in to change notification settings - Fork 126
Add badge to workflow history group to display time remaining #1036
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
adhityamamallan
merged 12 commits into
cadence-workflow:master
from
adhityamamallan:time-remaining-badge
Sep 29, 2025
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
780d329
Add field for expected duration
adhityamamallan 20ff751
Add tests for field for expected duration
adhityamamallan 96b73df
Use wait timer info with label
adhityamamallan d8698b2
Use absolute end time instead of duration
adhityamamallan 50a7475
Fix unit tests
adhityamamallan 1792901
Add badge to show remaining time
adhityamamallan 7ab39f8
Add tests
adhityamamallan a593638
Modify badge to accept new format
adhityamamallan 796d60a
Add unit test for custom unit in formatDuration
adhityamamallan 55f270f
Fix impl to use absolute time
adhityamamallan 85050c3
Fix formatDuration utils
adhityamamallan 6efcf9c
Merge branch 'master' into time-remaining-badge
adhityamamallan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 0 additions & 2 deletions
2
...y/workflow-history-events-duration-badge/workflow-history-events-duration-badge.styles.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
184 changes: 184 additions & 0 deletions
184
...ory-remaining-duration-badge/__tests__/workflow-history-remaining-duration-badge.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', () => { | ||
| 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} | ||
| /> | ||
| ); | ||
| } | ||
75 changes: 75 additions & 0 deletions
75
...story-remaining-duration-badge/helpers/__tests__/get-formatted-remaining-duration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); |
31 changes: 31 additions & 0 deletions
31
...ory/workflow-history-remaining-duration-badge/helpers/get-formatted-remaining-duration.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
23 changes: 23 additions & 0 deletions
23
...flow-history-remaining-duration-badge/workflow-history-remaining-duration-badge.styles.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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