diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.tsx index 9a7698e42dceb..e05424567f5c2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/content.tsx @@ -22,7 +22,7 @@ import { HeaderPage } from '../../../common/components/header_page'; import { KPIsSection } from './kpis/kpis_section'; import { FiltersSection } from './filters/filters_section'; import { HeaderSection } from './header/header_section'; -import { SearchBarSection } from './search_bar/search_bar_section'; +import { SearchBarSection } from '../common/search_bar/search_bar_section'; import { TableSection } from './table/table_section'; import type { AssigneesIdsSelection } from '../../../common/components/assignees/types'; import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.test.tsx index 7f177fadd6464..36e4f511cac05 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.test.tsx @@ -7,18 +7,18 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import { - DATA_VIEW_ERROR_TEST_ID, - DATA_VIEW_LOADING_PROMPT_TEST_ID, - SKELETON_TEST_ID, - Wrapper, -} from './wrapper'; +import { Wrapper } from './wrapper'; import { TestProviders } from '../../../common/mock'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useSourcererDataView } from '../../../sourcerer/containers'; import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; +import { + DATA_VIEW_ERROR_TEST_ID, + DATA_VIEW_LOADING_PROMPT_TEST_ID, + SKELETON_TEST_ID, +} from '../common/detections_wrapper'; jest.mock('../../../sourcerer/containers'); jest.mock('../../../common/hooks/use_experimental_features'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.tsx index 3d2f1a83f4aac..6d2ca4349ea52 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/wrapper.tsx @@ -5,33 +5,12 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; -import { - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiSkeletonLoading, - EuiSkeletonRectangle, - EuiSpacer, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; -import { HeaderPage } from '../../../common/components/header_page'; -import { useSourcererDataView } from '../../../sourcerer/containers'; +import React, { memo } from 'react'; + import { SourcererScopeName } from '../../../sourcerer/store/model'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; -import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; import { AlertsPageContent } from './content'; import { PAGE_TITLE } from '../../pages/alerts/translations'; - -export const DATA_VIEW_LOADING_PROMPT_TEST_ID = 'alerts-page-data-view-loading-prompt'; -export const DATA_VIEW_ERROR_TEST_ID = 'alerts-page-data-view-error'; -export const SKELETON_TEST_ID = 'alerts-page-skeleton'; - -const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.alertsPage.dataViewError', { - defaultMessage: 'Unable to retrieve the data view', -}); +import { DetectionsWrapper } from '../common/detections_wrapper'; /** * Retrieves the dataView for the alerts page then renders the alerts page when the dataView is valid. @@ -39,96 +18,16 @@ const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.alertsPage.dataVie * Shows an error message if the dataView is invalid. */ export const Wrapper = memo(() => { - const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); - - const { sourcererDataView: oldSourcererDataViewSpec, loading: oldSourcererDataViewIsLoading } = - useSourcererDataView(SourcererScopeName.detections); - // TODO rename to just dataView and status once we remove the newDataViewPickerEnabled feature flag - const { dataView: experimentalDataView, status: experimentalDataViewStatus } = useDataView( - SourcererScopeName.detections - ); - - const isLoading: boolean = useMemo( - () => - newDataViewPickerEnabled - ? experimentalDataViewStatus === 'loading' || experimentalDataViewStatus === 'pristine' - : oldSourcererDataViewIsLoading, - [experimentalDataViewStatus, newDataViewPickerEnabled, oldSourcererDataViewIsLoading] - ); - - // TODO this will not be needed anymore once we remove the newDataViewPickerEnabled feature flag. - // We currently only need the runtimeMappings in the KPIsSection, so we can just pass down the dataView - // and extract the runtimeMappings from it there using experimentalDataView.getRuntimeMappings() - const runtimeMappings: RunTimeMappings = useMemo( - () => - newDataViewPickerEnabled - ? (experimentalDataView?.getRuntimeMappings() as RunTimeMappings) ?? {} // TODO remove the ? as the dataView should never be undefined - : (oldSourcererDataViewSpec?.runtimeFieldMap as RunTimeMappings) ?? {}, - [newDataViewPickerEnabled, experimentalDataView, oldSourcererDataViewSpec?.runtimeFieldMap] - ); - - const isDataViewInvalid: boolean = useMemo( - () => - newDataViewPickerEnabled - ? experimentalDataViewStatus === 'error' || - (experimentalDataViewStatus === 'ready' && !experimentalDataView.hasMatchedIndices()) - : !oldSourcererDataViewSpec || - !oldSourcererDataViewSpec.id || - !oldSourcererDataViewSpec.title, - [ - experimentalDataView, - experimentalDataViewStatus, - newDataViewPickerEnabled, - oldSourcererDataViewSpec, - ] - ); - return ( - - - - - - - - - - - - - - - - - - - - - - } - loadedContent={ - <> - {isDataViewInvalid ? ( - {DATAVIEW_ERROR}} - /> - ) : ( - - )} - - } - /> + + {({ dataView, oldSourcererDataViewSpec, runtimeMappings }) => ( + + )} + ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx index b0be00096daf1..6546bb211ee53 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.test.tsx @@ -7,14 +7,20 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; + import { TestProviders } from '../../../common/mock'; import { AttacksPageContent, SECURITY_SOLUTION_PAGE_WRAPPER_TEST_ID } from './content'; +const dataView: DataView = createStubDataView({ spec: {} }); +const dataViewSpec: DataViewSpec = createStubDataView({ spec: {} }).toSpec(); + describe('AttacksPageContent', () => { it('should render correctly', async () => { render( - + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx index 77687e6586875..92b84017357dd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/content.tsx @@ -9,11 +9,13 @@ import { EuiHorizontalRule, EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import styled from '@emotion/styled'; import { noop } from 'lodash/fp'; import React, { memo, useRef } from 'react'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; import { PAGE_TITLE } from '../../pages/attacks/translations'; import { HeaderPage } from '../../../common/components/header_page'; import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import { Display } from '../../../explore/hosts/pages/display'; +import { SearchBarSection } from '../common/search_bar/search_bar_section'; export const CONTENT_TEST_ID = 'attacks-page-content'; export const SECURITY_SOLUTION_PAGE_WRAPPER_TEST_ID = 'attacks-page-security-solution-page-wrapper'; @@ -27,29 +29,44 @@ const StyledFullHeightContainer = styled.div` flex: 1 1 auto; `; +export interface AttacksPageContentProps { + /** + * DataView for the alerts page + */ + dataView: DataView; + // TODO remove when we remove the newDataViewPickerEnabled feature flag + /** + * DataViewSpec used to fetch the alerts data when the newDataViewPickerEnabled feature flag is false + */ + oldSourcererDataViewSpec: DataViewSpec; +} + /** * Renders the content of the attacks page: search bar, header, filters, KPIs, and table sections. */ -export const AttacksPageContent = memo(() => { - const containerElement = useRef(null); - - const { globalFullScreen } = useGlobalFullScreen(); - - return ( - - - - - - - - - - - ); -}); +export const AttacksPageContent = memo( + ({ dataView, oldSourcererDataViewSpec }: AttacksPageContentProps) => { + const containerElement = useRef(null); + + const { globalFullScreen } = useGlobalFullScreen(); + + return ( + + + + + + + + + + + + ); + } +); AttacksPageContent.displayName = 'AttacksPageContent'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.test.tsx index b4ad94c854eb4..257ec05d5d45d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.test.tsx @@ -6,49 +6,223 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { DATA_VIEW_ERROR_TEST_ID, Wrapper } from './wrapper'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Wrapper } from './wrapper'; import { TestProviders } from '../../../common/mock'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useSourcererDataView } from '../../../sourcerer/containers'; +import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; +import { + DATA_VIEW_ERROR_TEST_ID, + DATA_VIEW_LOADING_PROMPT_TEST_ID, + SKELETON_TEST_ID, +} from '../common/detections_wrapper'; +jest.mock('../../../sourcerer/containers'); jest.mock('../../../common/hooks/use_experimental_features'); +jest.mock('../../../data_view_manager/hooks/use_data_view'); jest.mock('./content', () => ({ AttacksPageContent: () =>
, })); +const dataView: DataView = createStubDataView({ spec: {} }); +const dataViewSpec: DataViewSpec = createStubDataView({ spec: {} }).toSpec(); + describe('', () => { describe('newDataViewPickerEnabled false', () => { beforeEach(() => { jest.clearAllMocks(); (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + (useDataView as jest.Mock).mockReturnValue({}); + }); + + it('should render a loading skeleton while retrieving the dataViewSpec', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: true, + sourcererDataView: dataViewSpec, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); }); - it('should render an error', async () => { + it('should render an error if the dataViewSpec is undefined', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: undefined, + }); + + render( + + + + ); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render an error if the dataViewSpec is invalid because id is undefined', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: undefined, title: 'title' }, + }); + render( ); + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( 'Unable to retrieve the data view' ); }); + + it('should render an error if the dataViewSpec is invalid because title is empty', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: 'id', title: '' }, + }); + + render( + + + + ); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render the content', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: 'id', title: 'title' }, + }); + + render( + + + + ); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId('attacks-page-content')).toBeInTheDocument(); + }); }); describe('newDataViewPickerEnabled true', () => { beforeEach(() => { jest.clearAllMocks(); (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useSourcererDataView as jest.Mock).mockReturnValue({}); + }); + + it('should render a loading skeleton if the dataView status is pristine', async () => { + (useDataView as jest.Mock).mockReturnValue({ dataView, status: 'pristine' }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render a loading skeleton if the dataView status is loading', async () => { + (useDataView as jest.Mock).mockReturnValue({ dataView, status: 'loading' }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render an error if the dataView status is error', async () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: undefined, + status: 'error', + }); + + render( + + + + ); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render an error if the dataView status is ready but it has no indices', async () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: { + ...dataView, + getRuntimeMappings: jest.fn(), + hasMatchedIndices: jest.fn().mockReturnValue(false), + }, + status: 'ready', + }); + + render( + + + + ); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); }); it('should render the content', async () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: { + ...dataView, + id: 'id', + getIndexPattern: jest.fn().mockReturnValue('title'), + getRuntimeMappings: jest.fn(), + hasMatchedIndices: jest.fn().mockReturnValue(true), + }, + status: 'ready', + }); + render( ); + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); expect(await screen.findByTestId('attacks-page-content')).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.tsx index 07f8110fc3caa..fe7f4c5bcaccc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/wrapper.tsx @@ -6,34 +6,28 @@ */ import React, { memo } from 'react'; -import { EuiEmptyPrompt } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; -import { AttacksPageContent } from './content'; - -export const DATA_VIEW_ERROR_TEST_ID = 'attacks-page-data-view-error'; -const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.attacksPage.dataViewError', { - defaultMessage: 'Unable to retrieve the data view', -}); +import { SourcererScopeName } from '../../../sourcerer/store/model'; +import { AttacksPageContent } from './content'; +import { PAGE_TITLE } from '../../pages/attacks/translations'; +import { DetectionsWrapper } from '../common/detections_wrapper'; +/** + * Retrieves the dataView for the attacks page then renders the attacks page when the dataView is valid. + * Shows a loading skeleton while retrieving. + * Shows an error message if the dataView is invalid. + */ export const Wrapper = memo(() => { - const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); - return ( - <> - {!newDataViewPickerEnabled ? ( - {DATAVIEW_ERROR}} + // TODO: Switch to `SourcererScopeName.attacks` data view scope once available + + {({ dataView, oldSourcererDataViewSpec }) => ( + - ) : ( - )} - + ); }); - Wrapper.displayName = 'Wrapper'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/detections_wrapper/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/detections_wrapper/index.test.tsx new file mode 100644 index 0000000000000..0abc77c47e9b4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/detections_wrapper/index.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; + +import { TestProviders } from '../../../../common/mock'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; +import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; +import { + DATA_VIEW_ERROR_TEST_ID, + DATA_VIEW_LOADING_PROMPT_TEST_ID, + DetectionsWrapper, + SKELETON_TEST_ID, +} from '.'; +import { SourcererScopeName } from '../../../../sourcerer/store/model'; + +jest.mock('../../../../sourcerer/containers'); +jest.mock('../../../../common/hooks/use_experimental_features'); +jest.mock('../../../../data_view_manager/hooks/use_data_view'); + +const dataView: DataView = createStubDataView({ spec: {} }); +const dataViewSpec: DataViewSpec = createStubDataView({ spec: {} }).toSpec(); + +const renderWrapper = () => { + render( + + + {() =>
} + + + ); +}; + +describe('', () => { + describe('newDataViewPickerEnabled false', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + (useDataView as jest.Mock).mockReturnValue({}); + }); + + it('should render a loading skeleton while retrieving the dataViewSpec', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: true, + sourcererDataView: dataViewSpec, + }); + + renderWrapper(); + + await waitFor(() => { + expect(screen.getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render an error if the dataViewSpec is undefined', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: undefined, + }); + + renderWrapper(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render an error if the dataViewSpec is invalid because id is undefined', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: undefined, title: 'title' }, + }); + + renderWrapper(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render an error if the dataViewSpec is invalid because title is empty', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: 'id', title: '' }, + }); + + renderWrapper(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render the content', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: 'id', title: 'title' }, + }); + + renderWrapper(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId('detections-page-content')).toBeInTheDocument(); + }); + }); + + describe('newDataViewPickerEnabled true', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useSourcererDataView as jest.Mock).mockReturnValue({}); + }); + + it('should render a loading skeleton if the dataView status is pristine', async () => { + (useDataView as jest.Mock).mockReturnValue({ dataView, status: 'pristine' }); + + renderWrapper(); + + await waitFor(() => { + expect(screen.getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render a loading skeleton if the dataView status is loading', async () => { + (useDataView as jest.Mock).mockReturnValue({ dataView, status: 'loading' }); + + renderWrapper(); + + await waitFor(() => { + expect(screen.getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render an error if the dataView status is error', async () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: undefined, + status: 'error', + }); + + renderWrapper(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render an error if the dataView status is ready but it has no indices', async () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: { + ...dataView, + getRuntimeMappings: jest.fn(), + hasMatchedIndices: jest.fn().mockReturnValue(false), + }, + status: 'ready', + }); + + renderWrapper(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to retrieve the data view' + ); + }); + + it('should render the content', async () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: { + ...dataView, + id: 'id', + getIndexPattern: jest.fn().mockReturnValue('title'), + getRuntimeMappings: jest.fn(), + hasMatchedIndices: jest.fn().mockReturnValue(true), + }, + status: 'ready', + }); + + renderWrapper(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId('detections-page-content')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/detections_wrapper/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/detections_wrapper/index.tsx new file mode 100644 index 0000000000000..79eec28cc720b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/detections_wrapper/index.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSkeletonLoading, + EuiSkeletonRectangle, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; + +import type { SourcererScopeName } from '../../../../sourcerer/store/model'; +import { HeaderPage } from '../../../../common/components/header_page'; +import { useDetectionsDataView } from '../../../hooks/use_detections_data_view'; + +export const DATA_VIEW_LOADING_PROMPT_TEST_ID = 'detections-page-data-view-loading-prompt'; +export const DATA_VIEW_ERROR_TEST_ID = 'detections-page-data-view-error'; +export const SKELETON_TEST_ID = 'detections-page-skeleton'; + +const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.detectionsPage.dataViewError', { + defaultMessage: 'Unable to retrieve the data view', +}); + +export interface DetectionsWrapperChildrenProps { + /** + * DataView for the detection page + */ + dataView: DataView; + // TODO remove when we remove the newDataViewPickerEnabled feature flag + /** + * DataViewSpec used to fetch the detections data when the newDataViewPickerEnabled feature flag is false + */ + oldSourcererDataViewSpec: DataViewSpec; + // TODO remove when we remove the newDataViewPickerEnabled feature flag + /** + * runTimeMappings used in the KPIsSection, when the newDataViewPickerEnabled feature flag is false + */ + runtimeMappings: RunTimeMappings; +} + +interface DetectionsWrapperProps { + /** + * The data view scope + */ + scope: SourcererScopeName; + /** + * The page title + */ + title: string; + /** + * The page content that is rendered when the valid data view has been retrieved + */ + children: (props: DetectionsWrapperChildrenProps) => React.ReactNode; +} + +/** + * Retrieves the dataView for the detections page then renders the detections page when the dataView is valid. + * Shows a loading skeleton while retrieving. + * Shows an error message if the dataView is invalid. + */ +export const DetectionsWrapper = React.memo( + ({ scope, title, children }) => { + const { isLoading, isDataViewInvalid, dataView, oldSourcererDataViewSpec, runtimeMappings } = + useDetectionsDataView(scope); + + const childrenProps = useMemo(() => { + return { dataView, oldSourcererDataViewSpec, runtimeMappings }; + }, [dataView, oldSourcererDataViewSpec, runtimeMappings]); + + return ( + + + + + + + + + + + + + + + + + + + + +
+ } + loadedContent={ + <> + {isDataViewInvalid ? ( + {DATAVIEW_ERROR}} + /> + ) : ( + children(childrenProps) + )} + + } + /> + ); + } +); +DetectionsWrapper.displayName = 'DetectionsWrapper'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/search_bar/search_bar_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/search_bar/search_bar_section.test.tsx similarity index 95% rename from x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/search_bar/search_bar_section.test.tsx rename to x-pack/solutions/security/plugins/security_solution/public/detections/components/common/search_bar/search_bar_section.test.tsx index 6a2477b883d75..0a6ef64ec8ca2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/search_bar/search_bar_section.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/search_bar/search_bar_section.test.tsx @@ -17,7 +17,7 @@ jest.mock('../../../../common/components/filters_global', () => ({ })); jest.mock('../../../../common/components/search_bar', () => ({ // The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables so we can't use SEARCH_BAR_TEST_ID - SiemSearchBar: () =>
, + SiemSearchBar: () =>
, })); const dataView: DataView = createStubDataView({ spec: {} }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/search_bar/search_bar_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/search_bar/search_bar_section.tsx similarity index 91% rename from x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/search_bar/search_bar_section.tsx rename to x-pack/solutions/security/plugins/security_solution/public/detections/components/common/search_bar/search_bar_section.tsx index c0cfec5256c3d..47591fae4a5f5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/search_bar/search_bar_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/common/search_bar/search_bar_section.tsx @@ -12,7 +12,7 @@ import { FiltersGlobal } from '../../../../common/components/filters_global'; import { SiemSearchBar } from '../../../../common/components/search_bar'; import { useSignalHelpers } from '../../../../sourcerer/containers/use_signal_helpers'; -export const SEARCH_BAR_TEST_ID = 'alerts-page-search-bar'; +export const SEARCH_BAR_TEST_ID = 'detections-search-bar'; export interface SearchBarSectionProps { /** @@ -26,7 +26,7 @@ export interface SearchBarSectionProps { } /** - * UI section of the alerts page that renders the global search bar. + * UI section of the detections page (alerts and attacks) that renders the global search bar. */ export const SearchBarSection = memo( ({ dataView, sourcererDataViewSpec }: SearchBarSectionProps) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/use_detections_data_view.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/use_detections_data_view.test.tsx new file mode 100644 index 0000000000000..c465b3d83e75a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/use_detections_data_view.test.tsx @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; + +import { useDetectionsDataView } from './use_detections_data_view'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { useSourcererDataView } from '../../sourcerer/containers'; +import { useDataView } from '../../data_view_manager/hooks/use_data_view'; +import { SourcererScopeName } from '../../sourcerer/store/model'; + +jest.mock('../../sourcerer/containers'); +jest.mock('../../common/hooks/use_experimental_features'); +jest.mock('../../data_view_manager/hooks/use_data_view'); + +const dataView: DataView = createStubDataView({ spec: {} }); +const dataViewSpec: DataViewSpec = createStubDataView({ spec: {} }).toSpec(); + +describe('useDetectionsDataView', () => { + describe('newDataViewPickerEnabled false', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + (useDataView as jest.Mock).mockReturnValue({}); + }); + + it('returns loading true when old sourcerer is loading', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: true, + sourcererDataView: dataViewSpec, + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns isDataViewInvalid true when old sourcerer data view is undefined', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: undefined, + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isDataViewInvalid).toBe(true); + }); + + it('returns isDataViewInvalid true when old sourcerer data view has no id', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: undefined, title: 'title' }, + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isDataViewInvalid).toBe(true); + }); + + it('returns isDataViewInvalid true when old sourcerer data view has no title', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: 'id', title: '' }, + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isDataViewInvalid).toBe(true); + }); + + it('returns a valid response', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + loading: false, + sourcererDataView: { ...dataViewSpec, id: 'id', title: 'title' }, + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isDataViewInvalid).toBe(false); + expect(result.current.dataView).toBe(undefined); + expect(result.current.oldSourcererDataViewSpec).toEqual({ + ...dataViewSpec, + id: 'id', + title: 'title', + }); + expect(result.current.runtimeMappings).toEqual({}); + }); + }); + + describe('newDataViewPickerEnabled true', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useSourcererDataView as jest.Mock).mockReturnValue({}); + }); + + it('returns loading true when data view status is pristine', () => { + (useDataView as jest.Mock).mockReturnValue({ dataView, status: 'pristine' }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns loading true when data view status is loading', () => { + (useDataView as jest.Mock).mockReturnValue({ dataView, status: 'loading' }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns isDataViewInvalid true when data view status is error', () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: undefined, + status: 'error', + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isDataViewInvalid).toBe(true); + }); + + it('returns isDataViewInvalid true when data view has no matched indices', () => { + (useDataView as jest.Mock).mockReturnValue({ + dataView: { + ...dataView, + getRuntimeMappings: jest.fn().mockReturnValue({}), + hasMatchedIndices: jest.fn().mockReturnValue(false), + }, + status: 'ready', + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isDataViewInvalid).toBe(true); + }); + + it('returns a valid response', () => { + const mockDataView = { + ...dataView, + id: 'id', + title: 'title', + getRuntimeMappings: jest.fn().mockReturnValue({ runtime: 'mappings' }), + hasMatchedIndices: jest.fn().mockReturnValue(true), + }; + (useDataView as jest.Mock).mockReturnValue({ + dataView: mockDataView, + status: 'ready', + }); + + const { result } = renderHook(() => useDetectionsDataView(SourcererScopeName.detections)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isDataViewInvalid).toBe(false); + expect(result.current.dataView).toEqual(mockDataView); + expect(result.current.oldSourcererDataViewSpec).toBe(undefined); + expect(result.current.runtimeMappings).toEqual({ runtime: 'mappings' }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/use_detections_data_view.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/use_detections_data_view.tsx new file mode 100644 index 0000000000000..2048a60246c1e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/use_detections_data_view.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; + +import { useSourcererDataView } from '../../sourcerer/containers'; +import type { SourcererScopeName } from '../../sourcerer/store/model'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { useDataView } from '../../data_view_manager/hooks/use_data_view'; + +export const useDetectionsDataView = (scope: SourcererScopeName) => { + const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + + const { sourcererDataView: oldSourcererDataViewSpec, loading: oldSourcererDataViewIsLoading } = + useSourcererDataView(scope); + // TODO rename to just dataView and status once we remove the newDataViewPickerEnabled feature flag + const { dataView: experimentalDataView, status: experimentalDataViewStatus } = useDataView(scope); + + const isLoading: boolean = useMemo( + () => + newDataViewPickerEnabled + ? experimentalDataViewStatus === 'loading' || experimentalDataViewStatus === 'pristine' + : oldSourcererDataViewIsLoading, + [experimentalDataViewStatus, newDataViewPickerEnabled, oldSourcererDataViewIsLoading] + ); + + // TODO this will not be needed anymore once we remove the newDataViewPickerEnabled feature flag. + // We currently only need the runtimeMappings in the KPIsSection, so we can just pass down the dataView + // and extract the runtimeMappings from it there using experimentalDataView.getRuntimeMappings() + const runtimeMappings: RunTimeMappings = useMemo( + () => + newDataViewPickerEnabled + ? (experimentalDataView?.getRuntimeMappings() as RunTimeMappings) ?? {} // TODO remove the ? as the dataView should never be undefined + : (oldSourcererDataViewSpec?.runtimeFieldMap as RunTimeMappings) ?? {}, + [newDataViewPickerEnabled, experimentalDataView, oldSourcererDataViewSpec?.runtimeFieldMap] + ); + + const isDataViewInvalid: boolean = useMemo( + () => + newDataViewPickerEnabled + ? experimentalDataViewStatus === 'error' || + (experimentalDataViewStatus === 'ready' && !experimentalDataView.hasMatchedIndices()) + : !oldSourcererDataViewSpec || + !oldSourcererDataViewSpec.id || + !oldSourcererDataViewSpec.title, + [ + experimentalDataView, + experimentalDataViewStatus, + newDataViewPickerEnabled, + oldSourcererDataViewSpec, + ] + ); + + return { + isLoading, + isDataViewInvalid, + dataView: experimentalDataView, + oldSourcererDataViewSpec, + runtimeMappings, + }; +}; diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts index fd2953f7a94cc..46c248240a569 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/alerts.ts @@ -276,4 +276,4 @@ export const TAKE_ACTION_GROUPED_ALERTS_BTN = '[data-test-subj="take-action-butt export const ALERT_STATUS_BADGE_BUTTON = '[data-test-subj="rule-status-badge"]'; -export const ALERTS_PAGE_KQL_BAR = '[data-test-subj="alerts-page-search-bar"]'; +export const ALERTS_PAGE_KQL_BAR = '[data-test-subj="detections-search-bar"]';