Skip to content

Commit 29ed52f

Browse files
adhityamamallanAssem-Uber
authored andcommitted
History timeline (#801)
* Add vis-timeline * Fix lockfile * add timeline * Add changes * Add tests and update component * Add tests * Fix border styles and update tests * fix lockfile * Add test for timeline * Move props to types file * Resolve comments * Fix tests
1 parent 3785815 commit 29ed52f

15 files changed

+742
-43
lines changed

src/components/timeline/timeline.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// @ts-expect-error: react-visjs-timeline does not have type declarations available
2+
import VisJSTimeline from 'react-visjs-timeline';
3+
4+
import type { Props } from './timeline.types';
5+
6+
export default function Timeline({ items, height = '400px' }: Props) {
7+
return (
8+
<VisJSTimeline
9+
options={{
10+
height,
11+
verticalScroll: true,
12+
}}
13+
items={items}
14+
/>
15+
);
16+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type TimelineItem = {
2+
start: Date;
3+
end?: Date;
4+
content: string;
5+
title?: string;
6+
type: 'box' | 'point' | 'range' | 'background';
7+
className: string;
8+
};
9+
10+
export type Props = {
11+
items: Array<TimelineItem>;
12+
height?: string;
13+
};
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { type Duration } from '@/__generated__/proto-ts/google/protobuf/Duration';
12
import type { Timestamp } from '@/__generated__/proto-ts/google/protobuf/Timestamp';
23

3-
export default function parseGrpcTimestamp(time: Timestamp): number {
4+
export default function parseGrpcTimestamp(time: Timestamp | Duration): number {
45
return parseInt(time.seconds) * 1000 + time.nanos / 1000000;
56
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
type TimerHistoryGroup,
3+
type ActivityHistoryGroup,
4+
type SingleEventHistoryGroup,
5+
} from '../workflow-history.types';
6+
7+
import { completedActivityTaskEvents } from './workflow-history-activity-events';
8+
import { startWorkflowExecutionEvent } from './workflow-history-single-events';
9+
import { startTimerTaskEvent } from './workflow-history-timer-events';
10+
11+
export const mockActivityEventGroup: ActivityHistoryGroup = {
12+
label: 'Mock event',
13+
groupType: 'Activity',
14+
status: 'COMPLETED',
15+
eventsMetadata: [],
16+
hasMissingEvents: false,
17+
timeMs: 1725747380000,
18+
timeLabel: 'Mock time label',
19+
events: completedActivityTaskEvents,
20+
};
21+
22+
export const mockTimerEventGroup: TimerHistoryGroup = {
23+
label: 'Mock event',
24+
groupType: 'Timer',
25+
status: 'COMPLETED',
26+
eventsMetadata: [],
27+
hasMissingEvents: false,
28+
timeMs: 1725747380000,
29+
timeLabel: 'Mock time label',
30+
events: [startTimerTaskEvent],
31+
};
32+
33+
export const mockSingleEventGroup: SingleEventHistoryGroup = {
34+
label: 'Mock event',
35+
groupType: 'Event',
36+
status: 'COMPLETED',
37+
eventsMetadata: [],
38+
hasMissingEvents: false,
39+
timeMs: 1725747380000,
40+
timeLabel: 'Mock time label',
41+
events: [startWorkflowExecutionEvent],
42+
};

src/views/workflow-history/__tests__/workflow-history.test.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { act, render, screen, userEvent } from '@/test-utils/rtl';
77

88
import { type Props as PageFiltersToggleProps } from '@/components/page-filters/page-filters-toggle/page-filters-toggle.types';
99
import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-history/get-workflow-history.types';
10+
import { describeWorkflowResponse } from '@/views/workflow-page/__fixtures__/describe-workflow-response';
1011

1112
import { completedActivityTaskEvents } from '../__fixtures__/workflow-history-activity-events';
1213
import WorkflowHistory from '../workflow-history';
@@ -21,6 +22,11 @@ jest.mock(
2122
() => jest.fn(() => <div>Timeline group card</div>)
2223
);
2324

25+
jest.mock(
26+
'../workflow-history-timeline-chart/workflow-history-timeline-chart',
27+
() => jest.fn(() => <div>Timeline chart</div>)
28+
);
29+
2430
jest.mock(
2531
'../workflow-history-timeline-load-more/workflow-history-timeline-load-more',
2632
() => jest.fn(() => <div>Load more</div>)
@@ -76,6 +82,16 @@ describe('WorkflowHistory', () => {
7682
}
7783
});
7884

85+
it('throws an error if the workflow summary request fails', async () => {
86+
try {
87+
await act(() => setup({ summaryError: true }));
88+
} catch (error) {
89+
expect((error as Error)?.message).toBe(
90+
'Failed to fetch workflow summary'
91+
);
92+
}
93+
});
94+
7995
it('should render the page initially with filters shown', async () => {
8096
setup({});
8197
expect(await screen.findByText('Filter Fields')).toBeInTheDocument();
@@ -89,9 +105,24 @@ describe('WorkflowHistory', () => {
89105

90106
expect(screen.queryByText('Filter Fields')).not.toBeInTheDocument();
91107
});
108+
109+
it('should show timeline when the Timeline button is clicked', async () => {
110+
const { user } = setup({});
111+
const timelineButton = await screen.findByText('Timeline');
112+
113+
await user.click(timelineButton);
114+
115+
expect(screen.queryByText('Timeline chart')).toBeInTheDocument();
116+
});
92117
});
93118

94-
function setup({ error }: { error?: boolean }) {
119+
function setup({
120+
error,
121+
summaryError,
122+
}: {
123+
error?: boolean;
124+
summaryError?: boolean;
125+
}) {
95126
const user = userEvent.setup();
96127
const renderResult = render(
97128
<Suspense>
@@ -130,6 +161,22 @@ function setup({ error }: { error?: boolean }) {
130161
} satisfies GetWorkflowHistoryResponse,
131162
}),
132163
},
164+
{
165+
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId',
166+
httpMethod: 'GET',
167+
...(summaryError
168+
? {
169+
httpResolver: () => {
170+
return HttpResponse.json(
171+
{ message: 'Failed to fetch workflow summary' },
172+
{ status: 500 }
173+
);
174+
},
175+
}
176+
: {
177+
jsonResponse: describeWorkflowResponse,
178+
}),
179+
},
133180
],
134181
},
135182
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { render, screen, waitFor } from '@/test-utils/rtl';
2+
3+
import {
4+
mockActivityEventGroup,
5+
mockTimerEventGroup,
6+
} from '../../__fixtures__/workflow-history-event-groups';
7+
import WorkflowHistoryTimelineChart from '../workflow-history-timeline-chart';
8+
9+
jest.mock('@/components/timeline/timeline', () =>
10+
jest.fn(() => <div>Mock timeline</div>)
11+
);
12+
13+
jest.mock('../helpers/convert-event-group-to-timeline-item.ts', () =>
14+
jest.fn().mockReturnValue({})
15+
);
16+
17+
describe(WorkflowHistoryTimelineChart.name, () => {
18+
it('renders correctly', async () => {
19+
setup({});
20+
expect(await screen.findByText('Mock timeline')).toBeInTheDocument();
21+
});
22+
23+
it('renders in loading state if isLoading is true', async () => {
24+
setup({ isLoading: true });
25+
expect(await screen.findByText('Mock timeline')).toBeInTheDocument();
26+
expect(await screen.findByText('Loading events')).toBeInTheDocument();
27+
});
28+
29+
it('fetches more events if possible', async () => {
30+
const { mockFetchMoreEvents } = setup({ hasMoreEvents: true });
31+
32+
await waitFor(() => {
33+
expect(mockFetchMoreEvents).toHaveBeenCalled();
34+
});
35+
});
36+
37+
it('does not fetch more events if a fetch is already in progress', async () => {
38+
const { mockFetchMoreEvents } = setup({
39+
hasMoreEvents: true,
40+
isFetchingMoreEvents: true,
41+
});
42+
43+
expect(mockFetchMoreEvents).not.toHaveBeenCalled();
44+
});
45+
});
46+
47+
function setup({
48+
isLoading = false,
49+
hasMoreEvents = false,
50+
isFetchingMoreEvents = false,
51+
}: {
52+
isLoading?: boolean;
53+
hasMoreEvents?: boolean;
54+
isFetchingMoreEvents?: boolean;
55+
}) {
56+
const mockFetchMoreEvents = jest.fn();
57+
render(
58+
<WorkflowHistoryTimelineChart
59+
eventGroupsEntries={[
60+
['Group 1', mockActivityEventGroup],
61+
['Group 2', mockTimerEventGroup],
62+
]}
63+
isLoading={isLoading}
64+
hasMoreEvents={hasMoreEvents}
65+
fetchMoreEvents={mockFetchMoreEvents}
66+
isFetchingMoreEvents={isFetchingMoreEvents}
67+
/>
68+
);
69+
70+
return { mockFetchMoreEvents };
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
mockActivityEventGroup,
3+
mockTimerEventGroup,
4+
} from '@/views/workflow-history/__fixtures__/workflow-history-event-groups';
5+
6+
import convertEventGroupToTimelineItem from '../convert-event-group-to-timeline-item';
7+
8+
jest.mock('../get-class-name-for-event-group', () =>
9+
jest.fn(() => 'mock-class-name')
10+
);
11+
12+
jest.useFakeTimers().setSystemTime(new Date('2024-09-10'));
13+
14+
describe(convertEventGroupToTimelineItem.name, () => {
15+
it('converts an event group to timeline chart item correctly', () => {
16+
expect(
17+
convertEventGroupToTimelineItem(mockActivityEventGroup, {} as any)
18+
).toEqual({
19+
className: 'mock-class-name',
20+
content: 'Mock event',
21+
end: new Date('2024-09-07T22:16:20.000Z'),
22+
start: new Date('2024-09-07T22:16:10.599Z'),
23+
title: 'Mock event: Mock time label',
24+
type: 'range',
25+
});
26+
});
27+
28+
it('returns end time as present when the event is ongoing or waiting', () => {
29+
expect(
30+
convertEventGroupToTimelineItem(
31+
{ ...mockActivityEventGroup, timeMs: null, status: 'ONGOING' },
32+
{} as any
33+
)
34+
).toEqual({
35+
className: 'mock-class-name',
36+
content: 'Mock event',
37+
end: new Date('2024-09-10T00:00:00.000Z'),
38+
start: new Date('2024-09-07T22:16:10.599Z'),
39+
title: 'Mock event: Mock time label',
40+
type: 'range',
41+
});
42+
});
43+
44+
it('returns end time as timer end time when the event is an ongoing timer', () => {
45+
expect(
46+
convertEventGroupToTimelineItem(
47+
{
48+
...mockTimerEventGroup,
49+
timeMs: null,
50+
status: 'ONGOING',
51+
},
52+
{} as any
53+
)
54+
).toEqual({
55+
className: 'mock-class-name',
56+
content: 'Mock event',
57+
end: new Date('2024-09-07T22:32:55.632Z'),
58+
start: new Date('2024-09-07T22:32:50.632Z'),
59+
title: 'Mock event: Mock time label',
60+
type: 'range',
61+
});
62+
});
63+
});

0 commit comments

Comments
 (0)