Skip to content

Commit bf5d49c

Browse files
Add JSON viewer/downloader for DescribeDomain API response (#874)
Add JSON viewer for DescribeDomain API response Move download logic from workflow history JSON download button to a shared util Use shared util to help download DescribeDomain API response as well Fix: change extendedDomainInfoMetadataConfig back to false
1 parent ba58dd8 commit bf5d49c

File tree

10 files changed

+321
-30
lines changed

10 files changed

+321
-30
lines changed

src/config/dynamic/resolvers/extended-domain-info-enabled.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type ExtendedDomainInfoEnabledConfig } from './extended-domain-info-ena
22

33
export default async function extendedDomainInfoEnabled(): Promise<ExtendedDomainInfoEnabledConfig> {
44
return {
5-
metadata: true,
5+
metadata: false,
66
issues: false,
77
};
88
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import downloadJson from '../download-json';
2+
3+
describe('downloadJson', () => {
4+
const originalCreateObjectURL = window.URL.createObjectURL;
5+
6+
afterEach(() => {
7+
jest.clearAllMocks();
8+
window.URL.createObjectURL = originalCreateObjectURL;
9+
});
10+
11+
it('should not execute when window is undefined', () => {
12+
const createObjectURLMock: jest.Mock = jest.fn();
13+
window.URL.createObjectURL = createObjectURLMock;
14+
15+
// Temporarily remove window object
16+
const originalWindow = global.window;
17+
delete (global as any).window;
18+
19+
downloadJson({ test: 'data' }, 'test-file');
20+
21+
// Restore window object
22+
global.window = originalWindow;
23+
24+
expect(createObjectURLMock).not.toHaveBeenCalled();
25+
});
26+
27+
it('should create and trigger download of JSON file', () => {
28+
const createObjectURLMock: jest.Mock = jest.fn();
29+
window.URL.createObjectURL = createObjectURLMock;
30+
31+
const testData = { test: 'data' };
32+
const filename = 'test-file';
33+
const mockBlobUrl = 'blob:test-url';
34+
35+
createObjectURLMock.mockReturnValue(mockBlobUrl);
36+
37+
downloadJson(testData, filename);
38+
39+
expect(createObjectURLMock).toHaveBeenCalled();
40+
});
41+
});

src/utils/download-json.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import losslessJsonStringify from './lossless-json-stringify';
2+
3+
export default function downloadJson(jsonData: any, filename: string) {
4+
if (typeof window === 'undefined') {
5+
return;
6+
}
7+
8+
const blob = new Blob([losslessJsonStringify(jsonData, null, '\t')], {
9+
type: 'application/json',
10+
});
11+
const url = window.URL.createObjectURL(blob);
12+
const a = document.createElement('a');
13+
a.href = url;
14+
a.download = `${filename}.json`;
15+
document.body.appendChild(a);
16+
a.click();
17+
document.body.removeChild(a);
18+
}

src/views/domain-page/config/domain-page-metadata-extended-table.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createElement } from 'react';
22

33
import { type MetadataItem } from '../domain-page-metadata/domain-page-metadata.types';
44
import DomainPageMetadataClusters from '../domain-page-metadata-clusters/domain-page-metadata-clusters';
5+
import DomainPageMetadataViewJson from '../domain-page-metadata-view-json/domain-page-metadata-view-json';
56

67
const domainPageMetadataExtendedTableConfig = [
78
{
@@ -57,8 +58,8 @@ const domainPageMetadataExtendedTableConfig = [
5758
label: 'DescribeDomain response',
5859
description: 'View raw DescribeDomain response as JSON',
5960
kind: 'simple',
60-
// TODO: create a JSON modal component
61-
getValue: () => createElement('div', {}, 'Placeholder for JSON button'),
61+
getValue: ({ domainDescription }) =>
62+
createElement(DomainPageMetadataViewJson, { domainDescription }),
6263
},
6364
] as const satisfies Array<MetadataItem>;
6465

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { type ModalProps } from 'baseui/modal';
2+
3+
import { render, screen, userEvent } from '@/test-utils/rtl';
4+
5+
import { mockDomainDescription } from '../../__fixtures__/domain-description';
6+
import DomainPageMetadataViewJson from '../domain-page-metadata-view-json';
7+
8+
const mockDownloadJson = jest.fn();
9+
jest.mock('@/utils/download-json', () =>
10+
jest.fn((json, filename) => mockDownloadJson(json, filename))
11+
);
12+
13+
jest.mock('baseui/modal', () => ({
14+
...jest.requireActual('baseui/modal'),
15+
Modal: ({ isOpen, children }: ModalProps) =>
16+
isOpen ? (
17+
<div aria-modal aria-label="dialog" role="dialog">
18+
{typeof children === 'function' ? children() : children}
19+
</div>
20+
) : null,
21+
}));
22+
23+
describe(DomainPageMetadataViewJson.name, () => {
24+
it('renders the view JSON button', () => {
25+
setup();
26+
expect(screen.getByText('View JSON')).toBeInTheDocument();
27+
});
28+
29+
it('opens modal when view JSON button is clicked', async () => {
30+
const { user } = setup();
31+
const viewButton = screen.getByText('View JSON');
32+
await user.click(viewButton);
33+
expect(screen.getByText('DescribeDomain response')).toBeInTheDocument();
34+
});
35+
36+
it('displays domain description in JSON format', async () => {
37+
const { user } = setup();
38+
const viewButton = screen.getByText('View JSON');
39+
await user.click(viewButton);
40+
41+
expect(screen.getByText('"mock-domain-staging"')).toBeInTheDocument();
42+
expect(screen.getByText('"mock-domain-staging-uuid"')).toBeInTheDocument();
43+
});
44+
45+
it('downloads JSON when download button is clicked', async () => {
46+
const { user } = setup();
47+
const viewButton = screen.getByText('View JSON');
48+
await user.click(viewButton);
49+
50+
const downloadButton = screen.getByTestId('download-json-button');
51+
await user.click(downloadButton);
52+
53+
expect(mockDownloadJson).toHaveBeenCalledWith(
54+
mockDomainDescription,
55+
'mock-domain-staging-mock-domain-staging-uuid'
56+
);
57+
});
58+
59+
it('closes modal when close button is clicked', async () => {
60+
const { user } = setup();
61+
const viewButton = screen.getByText('View JSON');
62+
await user.click(viewButton);
63+
64+
const closeButton = screen.getByText('Close');
65+
await user.click(closeButton);
66+
67+
expect(
68+
screen.queryByText('DescribeDomain response')
69+
).not.toBeInTheDocument();
70+
});
71+
});
72+
73+
function setup() {
74+
const user = userEvent.setup();
75+
const view = render(
76+
<DomainPageMetadataViewJson domainDescription={mockDomainDescription} />
77+
);
78+
return { user, ...view };
79+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { styled as createStyled, withStyle, type Theme } from 'baseui';
2+
import { type ButtonOverrides } from 'baseui/button';
3+
import {
4+
ModalBody,
5+
ModalFooter,
6+
ModalHeader,
7+
type ModalOverrides,
8+
} from 'baseui/modal';
9+
import { type StyleObject } from 'styletron-react';
10+
11+
export const overrides = {
12+
modal: {
13+
Close: {
14+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
15+
top: $theme.sizing.scale850,
16+
right: $theme.sizing.scale800,
17+
}),
18+
},
19+
} satisfies ModalOverrides,
20+
viewButton: {
21+
BaseButton: {
22+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
23+
paddingLeft: $theme.sizing.scale400,
24+
paddingRight: $theme.sizing.scale400,
25+
}),
26+
},
27+
} satisfies ButtonOverrides,
28+
jsonButton: {
29+
BaseButton: {
30+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
31+
width: $theme.sizing.scale950,
32+
height: $theme.sizing.scale950,
33+
backgroundColor: 'rgba(0, 0, 0, 0)',
34+
}),
35+
},
36+
} satisfies ButtonOverrides,
37+
};
38+
39+
export const styled = {
40+
ViewContainer: createStyled('div', ({ $theme }) => ({
41+
position: 'relative',
42+
gap: $theme.sizing.scale600,
43+
padding: $theme.sizing.scale600,
44+
backgroundColor: $theme.colors.backgroundSecondary,
45+
borderRadius: $theme.borders.radius300,
46+
})),
47+
ButtonsContainer: createStyled('div', {
48+
position: 'absolute',
49+
top: '10px',
50+
right: '10px',
51+
display: 'flex',
52+
}),
53+
ModalHeader: withStyle(ModalHeader, ({ $theme }: { $theme: Theme }) => ({
54+
marginTop: $theme.sizing.scale800,
55+
marginBottom: $theme.sizing.scale700,
56+
})),
57+
ModalBody: withStyle(ModalBody, ({ $theme }: { $theme: Theme }) => ({
58+
display: 'flex',
59+
flexDirection: 'column',
60+
rowGap: $theme.sizing.scale600,
61+
marginBottom: $theme.sizing.scale800,
62+
})),
63+
ModalFooter: withStyle(ModalFooter, ({ $theme }: { $theme: Theme }) => ({
64+
marginBottom: $theme.sizing.scale700,
65+
paddingTop: 0,
66+
paddingBottom: 0,
67+
})),
68+
};
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useMemo, useState } from 'react';
2+
3+
import {
4+
Button,
5+
KIND as BUTTON_KIND,
6+
SIZE as BUTTON_SIZE,
7+
SHAPE as BUTTON_SHAPE,
8+
} from 'baseui/button';
9+
import { Modal, SIZE as MODAL_SIZE, ModalButton } from 'baseui/modal';
10+
import { MdCode, MdCopyAll, MdOutlineCloudDownload } from 'react-icons/md';
11+
12+
import CopyTextButton from '@/components/copy-text-button/copy-text-button';
13+
import PrettyJson from '@/components/pretty-json/pretty-json';
14+
import downloadJson from '@/utils/download-json';
15+
import losslessJsonStringify from '@/utils/lossless-json-stringify';
16+
17+
import { styled, overrides } from './domain-page-metadata-view-json.styles';
18+
import { type Props } from './domain-page-metadata-view-json.types';
19+
20+
export default function DomainPageMetadataViewJson(props: Props) {
21+
const [isOpen, setIsOpen] = useState(false);
22+
23+
const textToCopy = useMemo(() => {
24+
return losslessJsonStringify(props.domainDescription, null, '\t');
25+
}, [props.domainDescription]);
26+
27+
return (
28+
<>
29+
<Button
30+
size={BUTTON_SIZE.mini}
31+
kind={BUTTON_KIND.secondary}
32+
shape={BUTTON_SHAPE.pill}
33+
overrides={overrides.viewButton}
34+
startEnhancer={<MdCode size={20} />}
35+
onClick={() => setIsOpen((v) => !v)}
36+
>
37+
View JSON
38+
</Button>
39+
<Modal
40+
size={MODAL_SIZE.auto}
41+
overrides={overrides.modal}
42+
isOpen={isOpen}
43+
onClose={() => setIsOpen(false)}
44+
closeable
45+
>
46+
<styled.ModalHeader>DescribeDomain response</styled.ModalHeader>
47+
<styled.ModalBody>
48+
<styled.ViewContainer>
49+
<PrettyJson json={props.domainDescription as Record<string, any>} />
50+
<styled.ButtonsContainer>
51+
<Button
52+
data-testid="download-json-button"
53+
size={BUTTON_SIZE.mini}
54+
kind={BUTTON_KIND.secondary}
55+
shape={BUTTON_SHAPE.circle}
56+
overrides={overrides.jsonButton}
57+
onClick={() =>
58+
downloadJson(
59+
props.domainDescription,
60+
`${props.domainDescription.name}-${props.domainDescription.id}`
61+
)
62+
}
63+
>
64+
<MdOutlineCloudDownload size={16} />
65+
</Button>
66+
<CopyTextButton
67+
textToCopy={textToCopy}
68+
overrides={overrides.jsonButton}
69+
>
70+
<MdCopyAll size={16} />
71+
</CopyTextButton>
72+
</styled.ButtonsContainer>
73+
</styled.ViewContainer>
74+
</styled.ModalBody>
75+
<styled.ModalFooter>
76+
<ModalButton
77+
size={BUTTON_SIZE.compact}
78+
type="button"
79+
kind={BUTTON_KIND.primary}
80+
onClick={() => setIsOpen(false)}
81+
>
82+
Close
83+
</ModalButton>
84+
</styled.ModalFooter>
85+
</Modal>
86+
</>
87+
);
88+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { type DomainDescription } from '../domain-page.types';
2+
3+
export type Props = {
4+
domainDescription: DomainDescription;
5+
};

src/views/workflow-history/workflow-history-export-json-button/__tests__/workflow-history-export-json-button.test.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,12 @@ jest.mock('baseui/toast', () => ({
1919
},
2020
}));
2121

22-
describe('WorkflowHistoryExportJsonButton', () => {
23-
const originalCreateObjectURL = window.URL.createObjectURL;
24-
25-
afterEach(() => {
26-
jest.clearAllMocks();
27-
window.URL.createObjectURL = originalCreateObjectURL;
28-
});
22+
const mockDownloadJson = jest.fn();
23+
jest.mock('@/utils/download-json', () =>
24+
jest.fn((json, filename) => mockDownloadJson(json, filename))
25+
);
2926

27+
describe('WorkflowHistoryExportJsonButton', () => {
3028
it('should render the button with "Export JSON"', () => {
3129
setup({});
3230
expect(screen.getByText('Export JSON')).toBeInTheDocument();
@@ -45,15 +43,12 @@ describe('WorkflowHistoryExportJsonButton', () => {
4543
});
4644

4745
it('should call request API and download JSON file', async () => {
48-
const createObjectURLMock: jest.Mock = jest.fn();
49-
window.URL.createObjectURL = createObjectURLMock;
50-
5146
setup({});
5247

5348
fireEvent.click(screen.getByText('Export JSON'));
5449

5550
await waitFor(() => {
56-
expect(createObjectURLMock).toHaveBeenCalledWith(expect.any(Blob));
51+
expect(mockDownloadJson).toHaveBeenCalled();
5752
});
5853
});
5954

0 commit comments

Comments
 (0)