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', {