Skip to content

Commit

Permalink
[Security Solution][Detections] Value Lists Modal supports multiple e…
Browse files Browse the repository at this point in the history
…xports (#73532) (#73621)

* Remove need for ValueListsTable

Modifying columns has revealed that they should be exposed as props, at
which point we have no real need for the table component.

* Unroll the ActionButton component

I thought this was useful when I wrote it!

* Handle multiple simultaneous exports on value lists modal

Instead of passing our export function to GenericDownloader, we now
manage the multiple exports ourselves, and when successful we pass the blob to
GenericDownloader.

* tracks a list of exporting IDs instead of single ID
* chains onto the export promise to set local state

* Port useful table tests over to modal tests

These verify that we've wired up our table actions to our API calls. A
little brittle/tied to implementation, but I'd rather have them than
not.

* WIP: Simpler version of GenericDownloader

* Replace use of GenericDownloader with simpler AutoDownload

This component takes a blob and downloads it in a
cross-browser-compatible manner.

* Handle error when uploading value lists

Converts to the try/catch/finally form as well.

* Fix failing cypress test

We lost this test subj during our refactor, oops

* More explicit setting of global DOM function

Our component fails due to this method being undefined, so we mock it
out for these tests. We do not need to reset the mock as it is assigned
fresh on every test.

* Fixes jest failures on CI

Defines a global static method in a more portable way, as the regular
assignment was failing on CI as the property was readonly.

* Simplify our export/delete clicks in jest tests

The less we assume about the UI, the more robust these'll be.

Co-authored-by: Elastic Machine <[email protected]>

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
rylnd and elasticmachine authored Jul 29, 2020
1 parent bb2e167 commit 6250e80
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 246 deletions.
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

0 comments on commit 6250e80

Please sign in to comment.