Skip to content
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

[Security Solution][Detections] Value Lists Modal supports multiple exports #73532

Merged
merged 13 commits into from
Jul 29, 2020
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import { mount } from 'enzyme';

import { globalNode } from '../../../common/mock';
import { AutoDownload } from './auto_download';

describe('AutoDownload', () => {
beforeEach(() => {
// our DOM environment lacks this function that our component needs
Object.defineProperty(globalNode.window.URL, 'revokeObjectURL', {
writable: true,
value: jest.fn(),
});
});

it('calls onDownload once if a blob is provided', () => {
const onDownload = jest.fn();
mount(<AutoDownload blob={new Blob([''])} onDownload={onDownload} />);

expect(onDownload).toHaveBeenCalledTimes(1);
});

it('does not call onDownload if no blob is provided', () => {
const onDownload = jest.fn();
mount(<AutoDownload blob={undefined} onDownload={onDownload} />);

expect(onDownload).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useEffect, useRef } from 'react';
import styled from 'styled-components';

const InvisibleAnchor = styled.a`
display: none;
`;

interface AutoDownloadProps {
blob: Blob | undefined;
name?: string;
onDownload?: () => void;
}

export const AutoDownload: React.FC<AutoDownloadProps> = ({ blob, name, onDownload }) => {
const anchorRef = useRef<HTMLAnchorElement>(null);

useEffect(() => {
if (blob && anchorRef?.current) {
if (typeof window.navigator.msSaveOrOpenBlob === 'function') {
window.navigator.msSaveBlob(blob);
} else {
const objectURL = window.URL.createObjectURL(blob);
anchorRef.current.href = objectURL;
anchorRef.current.download = name ?? 'download.txt';
anchorRef.current.click();
window.URL.revokeObjectURL(objectURL);
}

if (onDownload) {
onDownload();
}
}
}, [blob, name, onDownload]);

return <InvisibleAnchor ref={anchorRef} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,38 @@

import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';

import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock';
import { exportList, useDeleteList, useFindLists, ListSchema } from '../../../shared_imports';
import { TestProviders } from '../../../common/mock';
import { ValueListsModal } from './modal';

jest.mock('../../../shared_imports', () => {
const actual = jest.requireActual('../../../shared_imports');

return {
...actual,
exportList: jest.fn(),
useDeleteList: jest.fn(),
useFindLists: jest.fn(),
};
});

describe('ValueListsModal', () => {
beforeEach(() => {
// Do not resolve the export in tests as it causes unexpected state updates
(exportList as jest.Mock).mockImplementation(() => new Promise(() => {}));
(useFindLists as jest.Mock).mockReturnValue({
start: jest.fn(),
result: { data: Array<ListSchema>(3).fill(getListResponseMock()), total: 3 },
});
(useDeleteList as jest.Mock).mockReturnValue({
start: jest.fn(),
result: getListResponseMock(),
});
});

it('renders nothing if showModal is false', () => {
const container = mount(
<TestProviders>
Expand Down Expand Up @@ -47,15 +74,58 @@ describe('ValueListsModal', () => {
container.unmount();
});

it('renders ValueListsForm and ValueListsTable', () => {
it('renders ValueListsForm and an EuiTable', () => {
const container = mount(
<TestProviders>
<ValueListsModal showModal={true} onClose={jest.fn()} />
</TestProviders>
);

expect(container.find('ValueListsForm')).toHaveLength(1);
expect(container.find('ValueListsTable')).toHaveLength(1);
expect(container.find('EuiBasicTable')).toHaveLength(1);
container.unmount();
});

describe('modal table actions', () => {
it('calls exportList when export is clicked', () => {
const container = mount(
<TestProviders>
<ValueListsModal showModal={true} onClose={jest.fn()} />
</TestProviders>
);

act(() => {
container
.find('button[data-test-subj="action-export-value-list"]')
.first()
.simulate('click');
container.unmount();
});

expect(exportList).toHaveBeenCalledWith(expect.objectContaining({ listId: 'some-list-id' }));
});

it('calls deleteList when delete is clicked', () => {
const deleteListMock = jest.fn();
(useDeleteList as jest.Mock).mockReturnValue({
start: deleteListMock,
result: getListResponseMock(),
});
const container = mount(
<TestProviders>
<ValueListsModal showModal={true} onClose={jest.fn()} />
</TestProviders>
);

act(() => {
container
.find('button[data-test-subj="action-delete-value-list"]')
.first()
.simulate('click');
container.unmount();
});

expect(deleteListMock).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' }));
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@

import React, { useCallback, useEffect, useState } from 'react';
import {
EuiBasicTable,
EuiButton,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';

import {
Expand All @@ -25,10 +28,10 @@ import {
} from '../../../shared_imports';
import { useKibana } from '../../../common/lib/kibana';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { GenericDownloader } from '../../../common/components/generic_downloader';
import * as i18n from './translations';
import { ValueListsTable } from './table';
import { buildColumns } from './table_helpers';
import { ValueListsForm } from './form';
import { AutoDownload } from './auto_download';

interface ValueListsModalProps {
onClose: () => void;
Expand All @@ -45,8 +48,9 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
const { http } = useKibana().services;
const { start: findLists, ...lists } = useFindLists();
const { start: deleteList, result: deleteResult } = useDeleteList();
const [exportListId, setExportListId] = useState<string>();
const [deletingListIds, setDeletingListIds] = useState<string[]>([]);
const [exportingListIds, setExportingListIds] = useState<string[]>([]);
const [exportDownload, setExportDownload] = useState<{ name?: string; blob?: Blob }>({});
const { addError, addSuccess } = useAppToasts();

const fetchLists = useCallback(() => {
Expand All @@ -62,19 +66,26 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
);

useEffect(() => {
if (deleteResult != null && deletingListIds.length > 0) {
setDeletingListIds([...deletingListIds.filter((id) => id !== deleteResult.id)]);
if (deleteResult != null) {
setDeletingListIds((ids) => [...ids.filter((id) => id !== deleteResult.id)]);
fetchLists();
}
}, [deleteResult, deletingListIds, fetchLists]);
}, [deleteResult, fetchLists]);

const handleExport = useCallback(
async ({ ids }: { ids: string[] }) =>
exportList({ http, listId: ids[0], signal: new AbortController().signal }),
[http]
async ({ id }: { id: string }) => {
try {
setExportingListIds((ids) => [...ids, id]);
const blob = await exportList({ http, listId: id, signal: new AbortController().signal });
setExportDownload({ name: id, blob });
} catch (error) {
addError(error, { title: i18n.EXPORT_ERROR });
} finally {
setExportingListIds((ids) => [...ids.filter((_id) => _id !== id)]);
}
},
[addError, http]
);
const handleExportClick = useCallback(({ id }: { id: string }) => setExportListId(id), []);
const handleExportComplete = useCallback(() => setExportListId(undefined), []);

const handleTableChange = useCallback(
({ page: { index, size } }: { page: { index: number; size: number } }) => {
Expand Down Expand Up @@ -121,8 +132,8 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({

const tableItems = (lists.result?.data ?? []).map((item) => ({
...item,
isExporting: item.id === exportListId,
isDeleting: deletingListIds.includes(item.id),
isExporting: exportingListIds.includes(item.id),
}));

const pagination = {
Expand All @@ -131,6 +142,7 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
totalItemCount: lists.result?.total ?? 0,
hidePerPageOptions: true,
};
const columns = buildColumns(handleExport, handleDelete);

return (
<EuiOverlayMask onClick={onClose}>
Expand All @@ -141,27 +153,30 @@ export const ValueListsModalComponent: React.FC<ValueListsModalProps> = ({
<EuiModalBody>
<ValueListsForm onSuccess={handleUploadSuccess} onError={handleUploadError} />
<EuiSpacer />
<ValueListsTable
items={tableItems}
loading={lists.loading}
onDelete={handleDelete}
onExport={handleExportClick}
onChange={handleTableChange}
pagination={pagination}
/>
<EuiPanel>
<EuiText size="s">
<h2>{i18n.TABLE_TITLE}</h2>
</EuiText>
<EuiBasicTable
data-test-subj="value-lists-table"
columns={columns}
items={tableItems}
loading={lists.loading}
onChange={handleTableChange}
pagination={pagination}
/>
</EuiPanel>
</EuiModalBody>
<EuiModalFooter>
<EuiButton data-test-subj="value-lists-modal-close-action" onClick={onClose}>
{i18n.CLOSE_BUTTON}
</EuiButton>
</EuiModalFooter>
</EuiModal>
<GenericDownloader
filename={exportListId ?? 'download.txt'}
ids={exportListId != null ? [exportListId] : undefined}
onExportSuccess={handleExportComplete}
onExportFailure={handleExportComplete}
exportSelectedData={handleExport}
<AutoDownload
blob={exportDownload.blob}
name={exportDownload.name}
onDownload={() => setExportDownload({})}
/>
</EuiOverlayMask>
);
Expand Down
Loading