diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.test.tsx
new file mode 100644
index 0000000000000..53dcf986d395c
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.test.tsx
@@ -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();
+
+ expect(onDownload).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onDownload if no blob is provided', () => {
+ const onDownload = jest.fn();
+ mount();
+
+ expect(onDownload).not.toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.tsx
new file mode 100644
index 0000000000000..9c8280bebe4fd
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.tsx
@@ -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 = ({ blob, name, onDownload }) => {
+ const anchorRef = useRef(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 ;
+};
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx
index 175882de551cb..ff743d1d5090a 100644
--- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx
@@ -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(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(
@@ -47,7 +74,7 @@ describe('ValueListsModal', () => {
container.unmount();
});
- it('renders ValueListsForm and ValueListsTable', () => {
+ it('renders ValueListsForm and an EuiTable', () => {
const container = mount(
@@ -55,7 +82,50 @@ describe('ValueListsModal', () => {
);
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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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' }));
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx
index dc72260439090..4921a98b38bd1 100644
--- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx
@@ -6,6 +6,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
+ EuiBasicTable,
EuiButton,
EuiModal,
EuiModalBody,
@@ -13,7 +14,9 @@ import {
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
+ EuiPanel,
EuiSpacer,
+ EuiText,
} from '@elastic/eui';
import {
@@ -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;
@@ -45,8 +48,9 @@ export const ValueListsModalComponent: React.FC = ({
const { http } = useKibana().services;
const { start: findLists, ...lists } = useFindLists();
const { start: deleteList, result: deleteResult } = useDeleteList();
- const [exportListId, setExportListId] = useState();
const [deletingListIds, setDeletingListIds] = useState([]);
+ const [exportingListIds, setExportingListIds] = useState([]);
+ const [exportDownload, setExportDownload] = useState<{ name?: string; blob?: Blob }>({});
const { addError, addSuccess } = useAppToasts();
const fetchLists = useCallback(() => {
@@ -62,19 +66,26 @@ export const ValueListsModalComponent: React.FC = ({
);
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 } }) => {
@@ -121,8 +132,8 @@ export const ValueListsModalComponent: React.FC = ({
const tableItems = (lists.result?.data ?? []).map((item) => ({
...item,
- isExporting: item.id === exportListId,
isDeleting: deletingListIds.includes(item.id),
+ isExporting: exportingListIds.includes(item.id),
}));
const pagination = {
@@ -131,6 +142,7 @@ export const ValueListsModalComponent: React.FC = ({
totalItemCount: lists.result?.total ?? 0,
hidePerPageOptions: true,
};
+ const columns = buildColumns(handleExport, handleDelete);
return (
@@ -141,14 +153,19 @@ export const ValueListsModalComponent: React.FC = ({
-
+
+
+ {i18n.TABLE_TITLE}
+
+
+
@@ -156,12 +173,10 @@ export const ValueListsModalComponent: React.FC = ({
- setExportDownload({})}
/>
);
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx
deleted file mode 100644
index 2724c0a0696b6..0000000000000
--- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * 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 { act } from 'react-dom/test-utils';
-
-import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock';
-import { ListSchema } from '../../../../../lists/common/schemas/response';
-import { TestProviders } from '../../../common/mock';
-import { ValueListsTable } from './table';
-import { TableItem } from './types';
-
-const buildItems = (lists: ListSchema[]): TableItem[] =>
- lists.map((list) => ({
- ...list,
- isDeleting: false,
- isExporting: false,
- }));
-
-describe('ValueListsTable', () => {
- it('renders a row for each list', () => {
- const lists = Array(3).fill(getListResponseMock());
- const items = buildItems(lists);
- const container = mount(
-
-
-
- );
-
- expect(container.find('tbody tr')).toHaveLength(3);
- });
-
- it('calls onChange when pagination is modified', () => {
- const lists = Array(6).fill(getListResponseMock());
- const items = buildItems(lists);
- const onChange = jest.fn();
- const container = mount(
-
-
-
- );
-
- act(() => {
- container.find('a[data-test-subj="pagination-button-next"]').simulate('click');
- });
-
- expect(onChange).toHaveBeenCalledWith(
- expect.objectContaining({ page: expect.objectContaining({ index: 1 }) })
- );
- });
-
- it('calls onExport when export is clicked', () => {
- const lists = Array(3).fill(getListResponseMock());
- const items = buildItems(lists);
- const onExport = jest.fn();
- const container = mount(
-
-
-
- );
-
- act(() => {
- container
- .find('tbody tr')
- .first()
- .find('button[data-test-subj="action-export-value-list"]')
- .simulate('click');
- });
-
- expect(onExport).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' }));
- });
-
- it('calls onDelete when delete is clicked', () => {
- const lists = Array(3).fill(getListResponseMock());
- const items = buildItems(lists);
- const onDelete = jest.fn();
- const container = mount(
-
-
-
- );
-
- act(() => {
- container
- .find('tbody tr')
- .first()
- .find('button[data-test-subj="action-delete-value-list"]')
- .simulate('click');
- });
-
- expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' }));
- });
-});
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx
deleted file mode 100644
index a2e3b73a0abf0..0000000000000
--- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * 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 { EuiBasicTable, EuiText, EuiPanel } from '@elastic/eui';
-
-import * as i18n from './translations';
-import { buildColumns } from './table_helpers';
-import { TableProps, TableItemCallback } from './types';
-
-export interface ValueListsTableProps {
- items: TableProps['items'];
- loading: boolean;
- onChange: TableProps['onChange'];
- onExport: TableItemCallback;
- onDelete: TableItemCallback;
- pagination: Exclude;
-}
-
-export const ValueListsTableComponent: React.FC = ({
- items,
- loading,
- onChange,
- onExport,
- onDelete,
- pagination,
-}) => {
- const columns = buildColumns(onExport, onDelete);
- return (
-
-
- {i18n.TABLE_TITLE}
-
-
-
- );
-};
-
-ValueListsTableComponent.displayName = 'ValueListsTableComponent';
-
-export const ValueListsTable = React.memo(ValueListsTableComponent);
-
-ValueListsTable.displayName = 'ValueListsTable';
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx
index e90d106636e63..bb3a97749a11a 100644
--- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx
@@ -8,40 +8,18 @@
import React from 'react';
import styled from 'styled-components';
-import { EuiButtonIcon, IconType, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
+import { EuiButtonIcon, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import { ListSchema } from '../../../../../lists/common/schemas/response';
import { FormattedDate } from '../../../common/components/formatted_date';
import * as i18n from './translations';
-import { TableItem, TableItemCallback, TableProps } from './types';
+import { TableItemCallback, TableProps } from './types';
const AlignedSpinner = styled(EuiLoadingSpinner)`
margin: ${({ theme }) => theme.eui.euiSizeXS};
vertical-align: middle;
`;
-const ActionButton: React.FC<{
- content: string;
- dataTestSubj: string;
- icon: IconType;
- isLoading: boolean;
- item: TableItem;
- onClick: TableItemCallback;
-}> = ({ content, dataTestSubj, icon, item, onClick, isLoading }) => (
-
- {isLoading ? (
-
- ) : (
- onClick(item)}
- />
- )}
-
-);
-
export const buildColumns = (
onExport: TableItemCallback,
onDelete: TableItemCallback
@@ -70,26 +48,34 @@ export const buildColumns = (
actions: [
{
render: (item) => (
-
+
+ {item.isExporting ? (
+
+ ) : (
+ onExport(item)}
+ />
+ )}
+
),
},
{
render: (item) => (
-
+
+ {item.isDeleting ? (
+
+ ) : (
+ onDelete(item)}
+ />
+ )}
+
),
},
],
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts
index b7b2cae7b0ad6..7063dca2341ca 100644
--- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts
@@ -65,6 +65,10 @@ export const uploadSuccessMessage = (fileName: string) =>
values: { fileName },
});
+export const EXPORT_ERROR = i18n.translate('xpack.securitySolution.lists.valueListsExportError', {
+ defaultMessage: 'There was an error exporting the value list.',
+});
+
export const COLUMN_FILE_NAME = i18n.translate(
'xpack.securitySolution.lists.valueListsTable.fileNameColumn',
{