Skip to content

Commit e290f21

Browse files
authored
Add export as json button to history page (#684)
* Add export as json button to history page * remove unnecessary comments
1 parent 5575cc6 commit e290f21

File tree

5 files changed

+218
-1
lines changed

5 files changed

+218
-1
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { toaster } from 'baseui/toast';
2+
import { HttpResponse } from 'msw';
3+
4+
import { render, fireEvent, screen, act, waitFor } from '@/test-utils/rtl';
5+
6+
import { type HistoryEvent } from '@/__generated__/proto-ts/uber/cadence/api/v1/HistoryEvent';
7+
import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-history/get-workflow-history.types';
8+
9+
import type { Props as MSWMocksHandlersProps } from '../../../../test-utils/msw-mock-handlers/msw-mock-handlers.types';
10+
import { completedActivityTaskEvents } from '../../__fixtures__/workflow-history-activity-events';
11+
import WorkflowHistoryExportJsonButton from '../workflow-history-export-json-button';
12+
import { type Props } from '../workflow-history-export-json-button.types';
13+
14+
jest.mock('@/utils/logger');
15+
jest.mock('baseui/toast', () => ({
16+
...jest.requireActual('baseui/toast'),
17+
toaster: {
18+
negative: jest.fn(),
19+
},
20+
}));
21+
22+
describe('WorkflowHistoryExportJsonButton', () => {
23+
const originalCreateObjectURL = window.URL.createObjectURL;
24+
25+
afterEach(() => {
26+
jest.clearAllMocks();
27+
window.URL.createObjectURL = originalCreateObjectURL;
28+
});
29+
30+
it('should render the button with "Export JSON"', () => {
31+
setup({});
32+
expect(screen.getByText('Export JSON')).toBeInTheDocument();
33+
});
34+
35+
it('should show spinner when loading', async () => {
36+
const { getRequestResolver } = setup({ wait: true });
37+
fireEvent.click(screen.getByText('Export JSON'));
38+
39+
expect(screen.queryByRole('progressbar')).toBeInTheDocument();
40+
await act(() => {
41+
const resolver = getRequestResolver();
42+
resolver();
43+
});
44+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
45+
});
46+
47+
it('should call request API and download JSON file', async () => {
48+
const createObjectURLMock: jest.Mock = jest.fn();
49+
window.URL.createObjectURL = createObjectURLMock;
50+
51+
setup({});
52+
53+
fireEvent.click(screen.getByText('Export JSON'));
54+
55+
await waitFor(() => {
56+
expect(createObjectURLMock).toHaveBeenCalledWith(expect.any(Blob));
57+
});
58+
});
59+
60+
it('should handle error and show toast', async () => {
61+
setup({ error: true });
62+
fireEvent.click(screen.getByText('Export JSON'));
63+
64+
await waitFor(() => {
65+
expect(toaster.negative).toHaveBeenCalledWith(
66+
'Failed to export workflow history'
67+
);
68+
});
69+
});
70+
});
71+
72+
function setup({
73+
error,
74+
wait,
75+
...overrides
76+
}: Partial<Props> & { error?: boolean; loading?: boolean; wait?: boolean }) {
77+
const defaultProps: Props = {
78+
domain: 'test-domain',
79+
cluster: 'test-cluster',
80+
workflowId: 'test-workflowId',
81+
runId: 'test-runId',
82+
};
83+
const mockEvents: HistoryEvent[] = completedActivityTaskEvents;
84+
const totalEventsCount = mockEvents.length;
85+
let currentEventIndex = 0;
86+
let requestResolver = () => {};
87+
const getRequestResolver = () => requestResolver;
88+
89+
render(<WorkflowHistoryExportJsonButton {...defaultProps} {...overrides} />, {
90+
endpointsMocks: [
91+
{
92+
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId/history',
93+
httpMethod: 'GET',
94+
httpResolver: async () => {
95+
const index = currentEventIndex;
96+
currentEventIndex = currentEventIndex + 1;
97+
if (wait && currentEventIndex === 0) {
98+
await new Promise<void>((resolve) => {
99+
requestResolver = () => {
100+
resolve();
101+
};
102+
});
103+
}
104+
if (error)
105+
return HttpResponse.json(
106+
{ message: 'Failed to fetch workflow history' },
107+
{ status: 500 }
108+
);
109+
110+
return HttpResponse.json(
111+
{
112+
history: {
113+
events: [mockEvents[index]],
114+
},
115+
archived: false,
116+
nextPageToken: index < totalEventsCount - 1 ? '' : `${index + 1}`,
117+
rawHistory: [],
118+
} satisfies GetWorkflowHistoryResponse,
119+
{ status: 200 }
120+
);
121+
},
122+
},
123+
] as MSWMocksHandlersProps['endpointsMocks'],
124+
});
125+
126+
return { getRequestResolver };
127+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client';
2+
import { useRef, useState } from 'react';
3+
4+
import { Button } from 'baseui/button';
5+
import { Spinner } from 'baseui/spinner';
6+
import { ToasterContainer, toaster } from 'baseui/toast';
7+
import queryString from 'query-string';
8+
import { MdOutlineCloudDownload } from 'react-icons/md';
9+
10+
import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-history/get-workflow-history.types';
11+
import logger from '@/utils/logger';
12+
import request from '@/utils/request';
13+
import { RequestError } from '@/utils/request/request-error';
14+
15+
import { type Props } from './workflow-history-export-json-button.types';
16+
17+
export default function WorkflowHistoryExportJsonButton(props: Props) {
18+
const nextPage = useRef<string>();
19+
const [loadingState, setLoadingState] = useState<
20+
'loading' | 'error' | 'idle'
21+
>('idle');
22+
23+
const downloadJSON = (jsonData: any) => {
24+
const blob = new Blob([JSON.stringify(jsonData, null, '\t')], {
25+
type: 'application/json',
26+
});
27+
const url = window.URL.createObjectURL(blob);
28+
const a = document.createElement('a');
29+
a.href = url;
30+
a.download = `history-${props.workflowId}-${props.runId}.json`;
31+
document.body.appendChild(a);
32+
a.click();
33+
document.body.removeChild(a);
34+
};
35+
36+
const handleExport = async () => {
37+
try {
38+
const events = [];
39+
setLoadingState('loading');
40+
do {
41+
const res = await request(
42+
`/api/domains/${props.domain}/${props.cluster}/workflows/${props.workflowId}/${props.runId}/history?${queryString.stringify({ pageSize: 500, nextPage: nextPage.current })}`
43+
);
44+
const data: GetWorkflowHistoryResponse = await res.json();
45+
nextPage.current = data.nextPageToken;
46+
events.push(...(data.history?.events || []));
47+
} while (nextPage.current);
48+
49+
setLoadingState('idle');
50+
downloadJSON(events);
51+
} catch (e) {
52+
if (!(e instanceof RequestError)) {
53+
logger.error(e, 'Failed to export workflow');
54+
}
55+
toaster.negative('Failed to export workflow history');
56+
setLoadingState('error');
57+
}
58+
};
59+
60+
return (
61+
<>
62+
<ToasterContainer autoHideDuration={2000} placement="bottom" />
63+
<Button
64+
$size="mini"
65+
kind="secondary"
66+
startEnhancer={<MdOutlineCloudDownload size={16} />}
67+
onClick={handleExport}
68+
endEnhancer={loadingState === 'loading' && <Spinner $size={16} />}
69+
>
70+
Export JSON
71+
</Button>
72+
</>
73+
);
74+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type Props = {
2+
domain: string;
3+
cluster: string;
4+
workflowId: string;
5+
runId: string;
6+
};

src/views/workflow-history/workflow-history.styles.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ const cssStylesObj = {
88
display: 'flex',
99
flexDirection: 'column',
1010
},
11+
pageHeader: {
12+
display: 'flex',
13+
alignItems: 'center',
14+
justifyContent: 'space-between',
15+
flexWrap: 'wrap',
16+
},
1117
eventsContainer: (theme) => ({
1218
display: 'flex',
1319
marginTop: theme.sizing.scale500,

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { WorkflowPageTabContentProps } from '@/views/workflow-page/workflow
1919

2020
import { groupHistoryEvents } from './helpers/group-history-events';
2121
import WorkflowHistoryCompactEventCard from './workflow-history-compact-event-card/workflow-history-compact-event-card';
22+
import WorkflowHistoryExportJsonButton from './workflow-history-export-json-button/workflow-history-export-json-button';
2223
import WorkflowHistoryTimelineGroup from './workflow-history-timeline-group/workflow-history-timeline-group';
2324
import WorkflowHistoryTimelineLoadMore from './workflow-history-timeline-load-more/workflow-history-timeline-load-more';
2425
import { cssStyles } from './workflow-history.styles';
@@ -82,7 +83,10 @@ export default function WorkflowHistory({
8283
return (
8384
<PageSection>
8485
<div className={cls.pageContainer}>
85-
<HeadingXSmall>Workflow history</HeadingXSmall>
86+
<div className={cls.pageHeader}>
87+
<HeadingXSmall>Workflow history</HeadingXSmall>
88+
<WorkflowHistoryExportJsonButton {...wfhistoryRequestArgs} />
89+
</div>
8690
<div className={cls.eventsContainer}>
8791
<div role="list" className={cls.compactSection}>
8892
<Virtuoso

0 commit comments

Comments
 (0)