diff --git a/src/plugins/unified_search/public/filter_bar/filter_bar.tsx b/src/plugins/unified_search/public/filter_bar/filter_bar.tsx index 769d6bfcaf48f..a9c53d9f89121 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_bar.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_bar.tsx @@ -55,6 +55,8 @@ const FilterBarUI = React.memo(function FilterBarUI(props: Props) { gutterSize="none" // We use `gap` in the styles instead for better truncation of badges alignItems="center" tabIndex={-1} + data-test-subj="filter-items-group" + className={`filter-items-group ${props.className ?? ''}`} > {props.prepend} = ({ browserFields }, [activeTimelineDataProviders, browserFields, dataProviders, dispatch, onAddedToTimeline] ); + return ( - + {children} ); @@ -168,12 +168,12 @@ export const DragDropContextWrapper = React.memo( DragDropContextWrapper.displayName = 'DragDropContextWrapper'; -const onBeforeCapture = (before: BeforeCapture) => { - if (!draggableIsField(before)) { +const onBeforeDragStart = (start: DragStart) => { + if (!draggableIsField(start)) { document.body.classList.add(IS_DRAGGING_CLASS_NAME); } - if (draggableIsField(before)) { + if (draggableIsField(start)) { document.body.classList.add(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME); } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx index ee47fca53543b..d4652c4f5b629 100644 --- a/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx @@ -7,16 +7,11 @@ import { EuiButton, EuiWindowEvent } from '@elastic/eui'; import React, { useCallback } from 'react'; -import styled from 'styled-components'; import * as i18n from './translations'; export const EXIT_FULL_SCREEN_CLASS_NAME = 'exit-full-screen'; -const StyledEuiButton = styled(EuiButton)` - margin: ${({ theme }) => theme.eui.euiSizeS}; -`; - interface Props { fullScreen: boolean; setFullScreen: (fullScreen: boolean) => void; @@ -45,16 +40,17 @@ const ExitFullScreenComponent: React.FC = ({ fullScreen, setFullScreen }) return ( <> - {i18n.EXIT_FULL_SCREEN} - + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index 00732ec7b82e8..46c33d5102feb 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -37,7 +37,7 @@ exports[`HeaderSection it renders 1`] = ` >

= ({ stackHeader, subtitle, title, - titleSize = 'm', + titleSize = 'l', toggleQuery, toggleStatus = true, tooltip, @@ -173,7 +173,6 @@ const HeaderSectionComponent: React.FC = ({ {title} {tooltip && ( <> - {' '} void; queryId: string; showInspectButton?: boolean; - title: string | React.ReactElement | React.ReactNode; + title?: string | React.ReactElement | React.ReactNode; } const InspectButtonComponent: React.FC = ({ @@ -80,9 +80,6 @@ const InspectButtonComponent: React.FC = ({ className={BUTTON_CLASS} aria-label={i18n.INSPECT} data-test-subj="inspect-empty-button" - color="text" - iconSide="left" - iconType="inspect" isDisabled={isButtonDisabled} isLoading={loading} onClick={handleClick} diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx index fe21d973c86b8..08818172bca5a 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx @@ -167,7 +167,6 @@ export const QueryBar = memo( savedQuery={savedQuery} displayStyle={displayStyle} isDisabled={isDisabled} - hideTextBasedRunQueryLabel /> ); } diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/alerts_sourcerer.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/alerts_sourcerer.test.tsx new file mode 100644 index 0000000000000..da0bc5699882d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/alerts_sourcerer.test.tsx @@ -0,0 +1,139 @@ +/* + * 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 { Sourcerer } from '.'; +import { sourcererModel } from '../../store/sourcerer'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../mock'; +import { createStore } from '../../store'; +import { useSourcererDataView } from '../../containers/sourcerer'; +import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const mockDispatch = jest.fn(); + +jest.mock('../../containers/sourcerer'); +jest.mock('../../containers/sourcerer/use_signal_helpers'); +const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true); +jest.mock('./use_update_data_view', () => ({ + useUpdateDataView: () => mockUseUpdateDataView, +})); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + + return { + ...original, + toMountPoint: jest.fn(), + }; +}); + +const mockUpdateUrlParam = jest.fn(); +jest.mock('../../utils/global_query_string', () => { + const original = jest.requireActual('../../utils/global_query_string'); + + return { + ...original, + useUpdateUrlParam: () => mockUpdateUrlParam, + }; +}); + +let store: ReturnType; +const sourcererDataView = { + indicesExist: true, + loading: false, +}; +describe('sourcerer on alerts page or rules details page', () => { + const { storage } = createSecuritySolutionStorageMock(); + store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + const testProps = { + scope: sourcererModel.SourcererScopeName.detections, + }; + + const pollForSignalIndexMock = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + + (useSignalHelpers as jest.Mock).mockReturnValue({ + pollForSignalIndex: pollForSignalIndexMock, + signalIndexNeedsInit: false, + }); + + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + indicesExist: true, + }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('sourcerer-trigger')); + await waitFor(() => { + expect(screen.getByTestId('sourcerer-advanced-options-toggle')).toBeVisible(); + }); + fireEvent.click(screen.getByTestId('sourcerer-advanced-options-toggle')); + }); + + it('renders an alerts badge in sourcerer button', () => { + expect(screen.getByTestId('sourcerer-advanced-options-toggle')).toHaveTextContent( + /Advanced options/ + ); + }); + + it('renders a callout', () => { + expect(screen.getByTestId('sourcerer-callout')).toHaveTextContent( + 'Data view cannot be modified on this page' + ); + }); + + it('disable data view selector', () => { + expect(screen.getByTestId('sourcerer-select')).toBeDisabled(); + }); + + it('data view selector is default to Security Data View', () => { + expect(screen.getByTestId('sourcerer-select')).toHaveTextContent(/security data view/i); + }); + + it('renders an alert badge in data view selector', () => { + expect(screen.getByTestId('security-alerts-option-badge')).toHaveTextContent('Alerts'); + }); + + it('disable index pattern selector', () => { + expect(screen.getByTestId('sourcerer-combo-box')).toHaveAttribute('disabled'); + }); + + it('shows signal index as index pattern option', () => { + expect(screen.getByTestId('euiComboBoxPill')).toHaveTextContent('.siem-signals-spacename'); + }); + + it('does not render reset button', () => { + expect(screen.queryByTestId('sourcerer-reset')).toBeFalsy(); + }); + + it('does not render save button', () => { + expect(screen.queryByTestId('sourcerer-save')).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/helpers.tsx index 87874da00ced9..3556e32196573 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/helpers.tsx @@ -10,6 +10,7 @@ import type { EuiSuperSelectOption, EuiFormRowProps } from '@elastic/eui'; import { EuiIcon, EuiBadge, EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; import styled, { css } from 'styled-components'; +import { euiThemeVars } from '@kbn/ui-theme'; import type { sourcererModel } from '../../store/sourcerer'; import * as i18n from './translations'; @@ -23,7 +24,7 @@ export const StyledFormRow = styled(EuiFormRow)` max-width: none; `; -export const StyledButton = styled(EuiButtonEmpty)` +export const StyledButtonEmpty = styled(EuiButtonEmpty)` &:enabled:focus, &:focus { background-color: transparent; @@ -43,7 +44,7 @@ export const PopoverContent = styled.div` `; export const StyledBadge = styled(EuiBadge)` - margin-left: 8px; + margin-left: ${euiThemeVars.euiSizeXS}; &, .euiBadge__text { cursor: pointer; diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx index ebda7e6748ebd..f91ebef48d7a7 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx @@ -8,9 +8,8 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; -import { cloneDeep } from 'lodash'; -import { initialSourcererState, SourcererScopeName } from '../../store/sourcerer/model'; +import { SourcererScopeName } from '../../store/sourcerer/model'; import { Sourcerer } from '.'; import { sourcererActions, sourcererModel } from '../../store/sourcerer'; import { @@ -22,11 +21,9 @@ import { } from '../../mock'; import { createStore } from '../../store'; import type { EuiSuperSelectOption } from '@elastic/eui/src/components/form/super_select/super_select_control'; -import { waitFor } from '@testing-library/react'; +import { fireEvent, waitFor, render } from '@testing-library/react'; import { useSourcererDataView } from '../../containers/sourcerer'; import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers'; -import { TimelineId } from '../../../../common/types/timeline'; -import { TimelineType } from '../../../../common/api/timeline'; import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; import { sortWithExcludesAtEnd } from '../../../../common/utils/sourcerer'; @@ -93,6 +90,7 @@ const sourcererDataView = { describe('Sourcerer component', () => { const { storage } = createSecuritySolutionStorageMock(); const pollForSignalIndexMock = jest.fn(); + let wrapper: ReactWrapper; beforeEach(() => { jest.clearAllMocks(); store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -100,8 +98,12 @@ describe('Sourcerer component', () => { (useSignalHelpers as jest.Mock).mockReturnValue({ signalIndexNeedsInit: false }); }); + afterEach(() => { + if (wrapper && wrapper.exists()) wrapper.unmount(); + }); + it('renders data view title', () => { - const wrapper = mount( + wrapper = mount( @@ -117,7 +119,7 @@ describe('Sourcerer component', () => { ...defaultProps, showAlertsOnlyCheckbox: true, }; - const wrapper = mount( + wrapper = mount( @@ -129,7 +131,7 @@ describe('Sourcerer component', () => { }); it('renders tooltip', () => { - const wrapper = mount( + wrapper = mount( @@ -140,7 +142,7 @@ describe('Sourcerer component', () => { }); it('renders popover button inside tooltip', () => { - const wrapper = mount( + wrapper = mount( @@ -156,7 +158,7 @@ describe('Sourcerer component', () => { // Using props callback instead of simulating clicks, // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects it('Mounts with all options selected', () => { - const wrapper = mount( + wrapper = mount( @@ -206,7 +208,7 @@ describe('Sourcerer component', () => { kibanaObservable, storage ); - const wrapper = mount( + wrapper = mount( @@ -256,7 +258,7 @@ describe('Sourcerer component', () => { kibanaObservable, storage ); - const wrapper = mount( + wrapper = mount( @@ -305,7 +307,7 @@ describe('Sourcerer component', () => { }; store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const wrapper = mount( + wrapper = mount( @@ -318,7 +320,7 @@ describe('Sourcerer component', () => { optionsSelected: true, }); }); - it('Mounts with multiple options selected - timeline', () => { + it('Mounts with multiple options selected - timeline', async () => { const state2 = { ...mockGlobalState, sourcerer: { @@ -350,17 +352,22 @@ describe('Sourcerer component', () => { }; store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const wrapper = mount( + const { getByTestId, queryByTitle, queryAllByTestId } = render( ); - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="comboBoxInput"]`).first().simulate('click'); - expect(checkOptionsAndSelections(wrapper, patternList.slice(0, 2))).toEqual({ - // should show every option except fakebeat-* - availableOptionCount: title.split(',').length - 2, - optionsSelected: true, + + fireEvent.click(getByTestId('timeline-sourcerer-trigger')); + await waitFor(() => { + for (const pattern of patternList.slice(0, 2)) { + expect(queryByTitle(pattern)).toBeInTheDocument(); + } + }); + + fireEvent.click(getByTestId('comboBoxInput')); + await waitFor(() => { + expect(queryAllByTestId('sourcerer-combo-option')).toHaveLength(title.split(',').length - 2); }); }); it('onSave dispatches setSelectedDataView', async () => { @@ -392,7 +399,7 @@ describe('Sourcerer component', () => { kibanaObservable, storage ); - const wrapper = mount( + wrapper = mount( @@ -450,7 +457,7 @@ describe('Sourcerer component', () => { storage ); - const wrapper = mount( + wrapper = mount( @@ -464,7 +471,7 @@ describe('Sourcerer component', () => { }); it('resets to default index pattern', async () => { - const wrapper = mount( + wrapper = mount( @@ -517,7 +524,7 @@ describe('Sourcerer component', () => { kibanaObservable, storage ); - const wrapper = mount( + wrapper = mount( @@ -526,7 +533,14 @@ describe('Sourcerer component', () => { wrapper.find('[data-test-subj="comboBoxClearButton"]').first().simulate('click'); expect(wrapper.find('[data-test-subj="sourcerer-save"]').first().prop('disabled')).toBeTruthy(); }); - it('Does display signals index on timeline sourcerer', () => { + it('Does display signals index on timeline sourcerer', async () => { + /* + * Since both enzyme and RTL share JSDOM when running these tests, + * and enzyme does not clears jsdom after each test, because of this + * `screen` of RTL does not work as expect, please avoid using screen + * till all the tests have been converted to RTL + * + * */ const state2 = { ...mockGlobalState, sourcerer: { @@ -559,16 +573,20 @@ describe('Sourcerer component', () => { }; store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const wrapper = mount( + const el = render( ); - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="comboBoxToggleListButton"]`).first().simulate('click'); - expect(wrapper.find(`[data-test-subj="sourcerer-combo-option"]`).at(0).text()).toEqual( - mockGlobalState.sourcerer.signalIndexName - ); + + fireEvent.click(el.getByTestId('timeline-sourcerer-trigger')); + fireEvent.click(el.getByTestId('comboBoxToggleListButton')); + + await waitFor(() => { + expect(el.queryAllByTestId('sourcerer-combo-option')[0].textContent).toBe( + mockGlobalState.sourcerer.signalIndexName + ); + }); }); it('Does not display signals index on default sourcerer', () => { const state2 = { @@ -603,7 +621,7 @@ describe('Sourcerer component', () => { }; store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const wrapper = mount( + wrapper = mount( @@ -677,617 +695,3 @@ describe('Sourcerer component', () => { expect(pollForSignalIndexMock).toHaveBeenCalledTimes(1); }); }); - -describe('sourcerer on alerts page or rules details page', () => { - let wrapper: ReactWrapper; - const { storage } = createSecuritySolutionStorageMock(); - store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const testProps = { - scope: sourcererModel.SourcererScopeName.detections, - }; - - beforeAll(() => { - wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="sourcerer-advanced-options-toggle"]`).first().simulate('click'); - }); - - it('renders an alerts badge in sourcerer button', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-alerts-badge"]`).first().text()).toEqual( - 'Alerts' - ); - }); - - it('renders a callout', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-callout"]`).first().text()).toEqual( - 'Data view cannot be modified on this page' - ); - }); - - it('disable data view selector', () => { - expect( - wrapper.find(`[data-test-subj="sourcerer-select"]`).first().prop('disabled') - ).toBeTruthy(); - }); - - it('data view selector is default to Security Data View', () => { - expect( - wrapper.find(`[data-test-subj="sourcerer-select"]`).first().prop('valueOfSelected') - ).toEqual('security-solution'); - }); - - it('renders an alert badge in data view selector', () => { - expect(wrapper.find(`[data-test-subj="security-alerts-option-badge"]`).first().text()).toEqual( - 'Alerts' - ); - }); - - it('disable index pattern selector', () => { - expect( - wrapper.find(`[data-test-subj="sourcerer-combo-box"]`).first().prop('disabled') - ).toBeTruthy(); - }); - - it('shows signal index as index pattern option', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-combo-box"]`).first().prop('options')).toEqual([ - { disabled: false, label: '.siem-signals-spacename', value: '.siem-signals-spacename' }, - ]); - }); - - it('does not render reset button', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-reset"]`).exists()).toBeFalsy(); - }); - - it('does not render save button', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-save"]`).exists()).toBeFalsy(); - }); -}); - -describe('timeline sourcerer', () => { - let wrapper: ReactWrapper; - const { storage } = createSecuritySolutionStorageMock(); - store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const testProps = { - scope: sourcererModel.SourcererScopeName.timeline, - }; - - beforeAll(() => { - (useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView); - wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - wrapper - .find( - `[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-advanced-options-toggle"]` - ) - .first() - .simulate('click'); - }); - - it('renders "alerts only" checkbox, unchecked', () => { - wrapper - .find( - `[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-alert-only-checkbox"]` - ) - .first() - .simulate('click'); - expect(wrapper.find(`[data-test-subj="sourcerer-alert-only-checkbox"]`).first().text()).toEqual( - 'Show only detection alerts' - ); - expect( - wrapper.find(`[data-test-subj="sourcerer-alert-only-checkbox"] input`).first().prop('checked') - ).toEqual(false); - }); - - it('data view selector is enabled', () => { - expect( - wrapper - .find(`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-select"]`) - .first() - .prop('disabled') - ).toBeFalsy(); - }); - - it('data view selector is default to Security Default Data View', () => { - expect( - wrapper - .find(`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-select"]`) - .first() - .prop('valueOfSelected') - ).toEqual('security-solution'); - }); - - it('index pattern selector is enabled', () => { - expect( - wrapper - .find( - `[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-combo-box"]` - ) - .first() - .prop('disabled') - ).toBeFalsy(); - }); - - it('render reset button', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-reset"]`).exists()).toBeTruthy(); - }); - - it('render save button', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-save"]`).exists()).toBeTruthy(); - }); - - it('Checks box when only alerts index is selected in timeline', () => { - const state2 = { - ...mockGlobalState, - sourcerer: { - ...mockGlobalState.sourcerer, - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.timeline]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], - loading: false, - selectedDataViewId: id, - selectedPatterns: [`${mockGlobalState.sourcerer.signalIndexName}`], - }, - }, - }, - }; - - store = createStore( - state2, - SUB_PLUGINS_REDUCER, - - kibanaObservable, - storage - ); - - wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - expect( - wrapper.find(`[data-test-subj="sourcerer-alert-only-checkbox"] input`).first().prop('checked') - ).toEqual(true); - }); -}); - -describe('Sourcerer integration tests', () => { - const state = { - ...mockGlobalState, - sourcerer: { - ...mockGlobalState.sourcerer, - kibanaDataViews: [ - mockGlobalState.sourcerer.defaultDataView, - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '1234', - title: 'fakebeat-*,neatbeat-*', - patternList: ['fakebeat-*'], - }, - ], - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.default]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], - loading: false, - selectedDataViewId: id, - selectedPatterns: patternListNoSignals.slice(0, 2), - }, - }, - }, - }; - - const { storage } = createSecuritySolutionStorageMock(); - - beforeEach(() => { - (useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView); - store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - jest.clearAllMocks(); - }); - - it('Selects a different index pattern', async () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); - wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click'); - - wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click'); - expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({ - availableOptionCount: 0, - optionsSelected: true, - }); - wrapper.find(`button[data-test-subj="sourcerer-save"]`).first().simulate('click'); - - expect(mockDispatch).toHaveBeenCalledWith( - sourcererActions.setSelectedDataView({ - id: SourcererScopeName.default, - selectedDataViewId: '1234', - selectedPatterns: ['fakebeat-*'], - }) - ); - }); -}); - -describe('No data', () => { - const mockNoIndicesState = { - ...mockGlobalState, - sourcerer: { - ...initialSourcererState, - }, - }; - - const { storage } = createSecuritySolutionStorageMock(); - - beforeEach(() => { - (useSourcererDataView as jest.Mock).mockReturnValue({ - ...sourcererDataView, - indicesExist: false, - }); - store = createStore(mockNoIndicesState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - jest.clearAllMocks(); - }); - - test('Hide sourcerer - default ', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false); - }); - test('Hide sourcerer - detections ', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false); - }); - test('Hide sourcerer - timeline ', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).exists()).toEqual(true); - }); -}); - -describe('Update available', () => { - const { storage } = createSecuritySolutionStorageMock(); - const state2 = { - ...mockGlobalState, - sourcerer: { - ...mockGlobalState.sourcerer, - kibanaDataViews: [ - mockGlobalState.sourcerer.defaultDataView, - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '1234', - title: 'auditbeat-*', - patternList: ['auditbeat-*'], - }, - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '12347', - title: 'packetbeat-*', - patternList: ['packetbeat-*'], - }, - ], - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.timeline]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], - loading: false, - patternList, - selectedDataViewId: null, - selectedPatterns: ['myFakebeat-*'], - missingPatterns: ['myFakebeat-*'], - }, - }, - }, - }; - - let wrapper: ReactWrapper; - - beforeEach(() => { - (useSourcererDataView as jest.Mock).mockReturnValue({ - ...sourcererDataView, - activePatterns: ['myFakebeat-*'], - }); - store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - - wrapper = mount( - - - - ); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - test('Show Update available label', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-badge"]`).exists()).toBeTruthy(); - }); - - test('Show correct tooltip', () => { - expect(wrapper.find(`[data-test-subj="sourcerer-tooltip"]`).prop('content')).toEqual( - 'myFakebeat-*' - ); - }); - - test('Show UpdateDefaultDataViewModal', () => { - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); - - expect(wrapper.find(`UpdateDefaultDataViewModal`).prop('isShowing')).toEqual(true); - }); - - test('Show UpdateDefaultDataViewModal Callout', () => { - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); - - expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( - 'This timeline uses a legacy data view selector' - ); - - expect( - wrapper.find(`[data-test-subj="sourcerer-current-patterns-message"]`).first().text() - ).toEqual('The active index patterns in this timeline are: myFakebeat-*'); - - expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-message"]`).first().text()).toEqual( - "We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here." - ); - }); - - test('Show Add index pattern in UpdateDefaultDataViewModal', () => { - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); - - expect(wrapper.find(`button[data-test-subj="sourcerer-update-data-view"]`).text()).toEqual( - 'Add index pattern' - ); - }); - - test('Set all the index patterns from legacy timeline to sourcerer, after clicking on "Add index pattern"', async () => { - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-update-data-view"]`).simulate('click'); - - await waitFor(() => wrapper.update()); - expect(mockDispatch).toHaveBeenCalledWith( - sourcererActions.setSelectedDataView({ - id: SourcererScopeName.timeline, - selectedDataViewId: 'security-solution', - selectedPatterns: ['myFakebeat-*'], - shouldValidateSelectedPatterns: false, - }) - ); - }); -}); - -describe('Update available for timeline template', () => { - const { storage } = createSecuritySolutionStorageMock(); - const state2 = { - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.active]: { - ...mockGlobalState.timeline.timelineById.test, - timelineType: TimelineType.template, - }, - }, - }, - sourcerer: { - ...mockGlobalState.sourcerer, - kibanaDataViews: [ - mockGlobalState.sourcerer.defaultDataView, - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '1234', - title: 'auditbeat-*', - patternList: ['auditbeat-*'], - }, - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '12347', - title: 'packetbeat-*', - patternList: ['packetbeat-*'], - }, - ], - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.timeline]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], - loading: false, - patternList, - selectedDataViewId: null, - selectedPatterns: ['myFakebeat-*'], - missingPatterns: ['myFakebeat-*'], - }, - }, - }, - }; - - let wrapper: ReactWrapper; - - beforeEach(() => { - (useSourcererDataView as jest.Mock).mockReturnValue({ - ...sourcererDataView, - activePatterns: ['myFakebeat-*'], - }); - store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - - wrapper = mount( - - - - ); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - test('Show UpdateDefaultDataViewModal CallOut', () => { - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); - - expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( - 'This timeline template uses a legacy data view selector' - ); - - expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-message"]`).first().text()).toEqual( - "We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here." - ); - }); -}); - -describe('Missing index patterns', () => { - const { storage } = createSecuritySolutionStorageMock(); - const state2 = { - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.active]: { - ...mockGlobalState.timeline.timelineById.test, - timelineType: TimelineType.template, - }, - }, - }, - sourcerer: { - ...mockGlobalState.sourcerer, - kibanaDataViews: [ - mockGlobalState.sourcerer.defaultDataView, - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '1234', - title: 'auditbeat-*', - patternList: ['auditbeat-*'], - }, - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '12347', - title: 'packetbeat-*', - patternList: ['packetbeat-*'], - }, - ], - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.timeline]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], - loading: false, - patternList, - selectedDataViewId: 'fake-data-view-id', - selectedPatterns: ['myFakebeat-*'], - missingPatterns: ['myFakebeat-*'], - }, - }, - }, - }; - - let wrapper: ReactWrapper; - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('Show UpdateDefaultDataViewModal CallOut for timeline', () => { - (useSourcererDataView as jest.Mock).mockReturnValue({ - ...sourcererDataView, - activePatterns: ['myFakebeat-*'], - }); - const state3 = cloneDeep(state2); - state3.timeline.timelineById[TimelineId.active].timelineType = TimelineType.default; - store = createStore(state3, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - - wrapper = mount( - - - - ); - - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); - - expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( - 'This timeline is out of date with the Security Data View' - ); - - expect( - wrapper.find(`[data-test-subj="sourcerer-current-patterns-message"]`).first().text() - ).toEqual('The active index patterns in this timeline are: myFakebeat-*'); - - expect( - wrapper.find(`[data-test-subj="sourcerer-missing-patterns-callout"]`).first().text() - ).toEqual('Security Data View is missing the following index patterns: myFakebeat-*'); - - expect( - wrapper.find(`[data-test-subj="sourcerer-missing-patterns-message"]`).first().text() - ).toEqual( - "We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here." - ); - }); - - test('Show UpdateDefaultDataViewModal CallOut for timeline template', () => { - (useSourcererDataView as jest.Mock).mockReturnValue({ - ...sourcererDataView, - activePatterns: ['myFakebeat-*'], - }); - store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - - wrapper = mount( - - - - ); - - wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); - - wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); - - expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( - 'This timeline template is out of date with the Security Data View' - ); - - expect( - wrapper.find(`[data-test-subj="sourcerer-current-patterns-message"]`).first().text() - ).toEqual('The active index patterns in this timeline template are: myFakebeat-*'); - - expect( - wrapper.find(`[data-test-subj="sourcerer-missing-patterns-callout"]`).first().text() - ).toEqual('Security Data View is missing the following index patterns: myFakebeat-*'); - - expect( - wrapper.find(`[data-test-subj="sourcerer-missing-patterns-message"]`).first().text() - ).toEqual( - "We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here." - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx index a0bb1f3f27ff4..5a2f3050c1590 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx @@ -25,7 +25,7 @@ import { useDeepEqualSelector } from '../../hooks/use_selector'; import type { SourcererUrlState } from '../../store/sourcerer/model'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { usePickIndexPatterns } from './use_pick_index_patterns'; -import { FormRow, PopoverContent, StyledButton, StyledFormRow } from './helpers'; +import { FormRow, PopoverContent, StyledButtonEmpty, StyledFormRow } from './helpers'; import { TemporarySourcerer } from './temporary'; import { useSourcererDataView } from '../../containers/sourcerer'; import { useUpdateDataView } from './use_update_data_view'; @@ -338,14 +338,14 @@ export const Sourcerer = React.memo(({ scope: scopeId } )} - {i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE} - + {expandAdvancedOptions && } true); +jest.mock('./use_update_data_view', () => ({ + useUpdateDataView: () => mockUseUpdateDataView, +})); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + + return { + ...original, + toMountPoint: jest.fn(), + }; +}); + +const mockUpdateUrlParam = jest.fn(); +jest.mock('../../utils/global_query_string', () => { + const original = jest.requireActual('../../utils/global_query_string'); + + return { + ...original, + useUpdateUrlParam: () => mockUpdateUrlParam, + }; +}); + +const defaultProps = { + scope: sourcererModel.SourcererScopeName.default, +}; + +const checkOptionsAndSelections = (wrapper: ReactWrapper, patterns: string[]) => ({ + availableOptionCount: + wrapper.find('List').length > 0 ? wrapper.find('List').prop('itemCount') : 0, + optionsSelected: patterns.every((pattern) => + wrapper.find(`[data-test-subj="sourcerer-combo-box"] span[title="${pattern}"]`).first().exists() + ), +}); + +const { id, patternList } = mockGlobalState.sourcerer.defaultDataView; + +const patternListNoSignals = sortWithExcludesAtEnd( + patternList.filter((p) => p !== mockGlobalState.sourcerer.signalIndexName) +); +let store: ReturnType; +const sourcererDataView = { + indicesExist: true, + loading: false, +}; + +describe('No data', () => { + const mockNoIndicesState = { + ...mockGlobalState, + sourcerer: { + ...initialSourcererState, + }, + }; + + const { storage } = createSecuritySolutionStorageMock(); + const pollForSignalIndexMock = jest.fn(); + + beforeEach(() => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + indicesExist: false, + }); + (useSignalHelpers as jest.Mock).mockReturnValue({ + pollForSignalIndex: pollForSignalIndexMock, + signalIndexNeedsInit: false, + }); + store = createStore(mockNoIndicesState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + jest.clearAllMocks(); + }); + + test('Hide sourcerer - default ', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false); + }); + test('Hide sourcerer - detections ', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false); + }); + test('Hide sourcerer - timeline ', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).exists()).toEqual(true); + }); +}); + +describe('Update available', () => { + const { storage } = createSecuritySolutionStorageMock(); + const state2 = { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'auditbeat-*', + patternList: ['auditbeat-*'], + }, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '12347', + title: 'packetbeat-*', + patternList: ['packetbeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + patternList, + selectedDataViewId: null, + selectedPatterns: ['myFakebeat-*'], + missingPatterns: ['myFakebeat-*'], + }, + }, + }, + }; + + const pollForSignalIndexMock = jest.fn(); + beforeEach(() => { + (useSignalHelpers as jest.Mock).mockReturnValue({ + pollForSignalIndex: pollForSignalIndexMock, + signalIndexNeedsInit: false, + }); + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + render( + + + + ); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Show Update available label', () => { + expect(screen.getByTestId('sourcerer-deprecated-badge')).toBeInTheDocument(); + }); + + test('Show correct tooltip', async () => { + fireEvent.mouseOver(screen.getByTestId('timeline-sourcerer-trigger')); + await waitFor(() => { + expect(screen.getByTestId('sourcerer-tooltip').textContent).toBe('myFakebeat-*'); + }); + }); + + test('Show UpdateDefaultDataViewModal', () => { + fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]); + + fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]); + + expect(screen.getByTestId('sourcerer-update-data-view-modal')).toBeVisible(); + }); + + test('Show UpdateDefaultDataViewModal Callout', () => { + fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]); + + fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]); + + expect(screen.queryAllByTestId('sourcerer-deprecated-callout')[0].textContent).toBe( + 'This timeline uses a legacy data view selector' + ); + + expect(screen.queryAllByTestId('sourcerer-current-patterns-message')[0].textContent).toBe( + 'The active index patterns in this timeline are: myFakebeat-*' + ); + + expect(screen.queryAllByTestId('sourcerer-deprecated-message')[0].textContent).toBe( + "We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here." + ); + }); + + test('Show Add index pattern in UpdateDefaultDataViewModal', () => { + fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]); + + fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]); + + expect(screen.queryAllByTestId('sourcerer-update-data-view')[0].textContent).toBe( + 'Add index pattern' + ); + }); + + test('Set all the index patterns from legacy timeline to sourcerer, after clicking on "Add index pattern"', async () => { + fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]); + + fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]); + + fireEvent.click(screen.queryAllByTestId('sourcerer-update-data-view')[0]); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + sourcererActions.setSelectedDataView({ + id: SourcererScopeName.timeline, + selectedDataViewId: 'security-solution', + selectedPatterns: ['myFakebeat-*'], + shouldValidateSelectedPatterns: false, + }) + ); + }); + }); +}); + +describe('Update available for timeline template', () => { + const { storage } = createSecuritySolutionStorageMock(); + const state2 = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + timelineType: TimelineType.template, + }, + }, + }, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'auditbeat-*', + patternList: ['auditbeat-*'], + }, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '12347', + title: 'packetbeat-*', + patternList: ['packetbeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + patternList, + selectedDataViewId: null, + selectedPatterns: ['myFakebeat-*'], + missingPatterns: ['myFakebeat-*'], + }, + }, + }, + }; + + beforeEach(() => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + render( + + + + ); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Show UpdateDefaultDataViewModal CallOut', () => { + fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger')); + fireEvent.click(screen.getByTestId('sourcerer-deprecated-update')); + + expect(screen.getByTestId('sourcerer-deprecated-callout')).toHaveTextContent( + 'This timeline template uses a legacy data view selector' + ); + + expect(screen.getByTestId('sourcerer-deprecated-message')).toHaveTextContent( + "We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here." + ); + }); +}); + +describe('Missing index patterns', () => { + const { storage } = createSecuritySolutionStorageMock(); + const state2 = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + timelineType: TimelineType.template, + }, + }, + }, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'auditbeat-*', + patternList: ['auditbeat-*'], + }, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '12347', + title: 'packetbeat-*', + patternList: ['packetbeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + patternList, + selectedDataViewId: 'fake-data-view-id', + selectedPatterns: ['myFakebeat-*'], + missingPatterns: ['myFakebeat-*'], + }, + }, + }, + }; + + beforeEach(() => { + const pollForSignalIndexMock = jest.fn(); + (useSignalHelpers as jest.Mock).mockReturnValue({ + pollForSignalIndex: pollForSignalIndexMock, + signalIndexNeedsInit: false, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Show UpdateDefaultDataViewModal CallOut for timeline', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + const state3 = cloneDeep(state2); + state3.timeline.timelineById[TimelineId.active].timelineType = TimelineType.default; + store = createStore(state3, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger')); + + fireEvent.click(screen.getByTestId('sourcerer-deprecated-update')); + + expect(screen.getByTestId('sourcerer-deprecated-callout').textContent).toBe( + 'This timeline is out of date with the Security Data View' + ); + expect(screen.getByTestId('sourcerer-current-patterns-message').textContent).toBe( + 'The active index patterns in this timeline are: myFakebeat-*' + ); + expect(screen.queryAllByTestId('sourcerer-missing-patterns-callout')[0].textContent).toBe( + 'Security Data View is missing the following index patterns: myFakebeat-*' + ); + expect(screen.queryAllByTestId('sourcerer-missing-patterns-message')[0].textContent).toBe( + "We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here." + ); + }); + + test('Show UpdateDefaultDataViewModal CallOut for timeline template', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger')); + + fireEvent.click(screen.getByTestId('sourcerer-deprecated-update')); + + await waitFor(() => { + expect(screen.queryAllByTestId('sourcerer-deprecated-callout')[0].textContent).toBe( + 'This timeline template is out of date with the Security Data View' + ); + + expect(screen.queryAllByTestId('sourcerer-current-patterns-message')[0].textContent).toBe( + 'The active index patterns in this timeline template are: myFakebeat-*' + ); + + expect(screen.queryAllByTestId('sourcerer-missing-patterns-callout')[0].textContent).toBe( + 'Security Data View is missing the following index patterns: myFakebeat-*' + ); + + expect(screen.queryAllByTestId('sourcerer-missing-patterns-message')[0].textContent).toBe( + "We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here." + ); + }); + }); +}); + +describe('Sourcerer integration tests', () => { + const state = { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'fakebeat-*,neatbeat-*', + patternList: ['fakebeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.default]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], + loading: false, + selectedDataViewId: id, + selectedPatterns: patternListNoSignals.slice(0, 2), + }, + }, + }, + }; + + const { storage } = createSecuritySolutionStorageMock(); + + beforeEach(() => { + const pollForSignalIndexMock = jest.fn(); + (useSignalHelpers as jest.Mock).mockReturnValue({ + pollForSignalIndex: pollForSignalIndexMock, + signalIndexNeedsInit: false, + }); + + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + jest.clearAllMocks(); + }); + + it('Selects a different index pattern', async () => { + const wrapper = mount( + + + + ); + wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click'); + + wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click'); + expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({ + availableOptionCount: 0, + optionsSelected: true, + }); + wrapper.find(`button[data-test-subj="sourcerer-save"]`).first().simulate('click'); + + expect(mockDispatch).toHaveBeenCalledWith( + sourcererActions.setSelectedDataView({ + id: SourcererScopeName.default, + selectedDataViewId: '1234', + selectedPatterns: ['fakebeat-*'], + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/sourcerer_integration.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/sourcerer_integration.test.tsx new file mode 100644 index 0000000000000..33eba9dac6b95 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/sourcerer_integration.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 type { ReactWrapper } from 'enzyme'; +import { mount } from 'enzyme'; + +import { SourcererScopeName } from '../../store/sourcerer/model'; +import { Sourcerer } from '.'; +import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers'; +import { sourcererActions, sourcererModel } from '../../store/sourcerer'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../mock'; +import { createStore } from '../../store'; +import { sortWithExcludesAtEnd } from '../../../../common/utils/sourcerer'; +import { useSourcererDataView } from '../../containers/sourcerer'; + +const mockDispatch = jest.fn(); + +jest.mock('../../containers/sourcerer'); +jest.mock('../../containers/sourcerer/use_signal_helpers'); +const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true); +jest.mock('./use_update_data_view', () => ({ + useUpdateDataView: () => mockUseUpdateDataView, +})); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + + return { + ...original, + toMountPoint: jest.fn(), + }; +}); + +const mockUpdateUrlParam = jest.fn(); +jest.mock('../../utils/global_query_string', () => { + const original = jest.requireActual('../../utils/global_query_string'); + + return { + ...original, + useUpdateUrlParam: () => mockUpdateUrlParam, + }; +}); + +const defaultProps = { + scope: sourcererModel.SourcererScopeName.default, +}; + +const checkOptionsAndSelections = (wrapper: ReactWrapper, patterns: string[]) => ({ + availableOptionCount: + wrapper.find('List').length > 0 ? wrapper.find('List').prop('itemCount') : 0, + optionsSelected: patterns.every((pattern) => + wrapper.find(`[data-test-subj="sourcerer-combo-box"] span[title="${pattern}"]`).first().exists() + ), +}); + +const { id, patternList } = mockGlobalState.sourcerer.defaultDataView; +const patternListNoSignals = sortWithExcludesAtEnd( + patternList.filter((p) => p !== mockGlobalState.sourcerer.signalIndexName) +); +let store: ReturnType; +const sourcererDataView = { + indicesExist: true, + loading: false, +}; + +describe('Sourcerer integration tests', () => { + const state = { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'fakebeat-*,neatbeat-*', + patternList: ['fakebeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.default]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], + loading: false, + selectedDataViewId: id, + selectedPatterns: patternListNoSignals.slice(0, 2), + }, + }, + }, + }; + + const { storage } = createSecuritySolutionStorageMock(); + + beforeEach(() => { + const pollForSignalIndexMock = jest.fn(); + (useSignalHelpers as jest.Mock).mockReturnValue({ + pollForSignalIndex: pollForSignalIndexMock, + signalIndexNeedsInit: false, + }); + + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + jest.clearAllMocks(); + }); + + it('Selects a different index pattern', async () => { + const wrapper = mount( + + + + ); + wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click'); + + wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click'); + expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({ + availableOptionCount: 0, + optionsSelected: true, + }); + wrapper.find(`button[data-test-subj="sourcerer-save"]`).first().simulate('click'); + + expect(mockDispatch).toHaveBeenCalledWith( + sourcererActions.setSelectedDataView({ + id: SourcererScopeName.default, + selectedDataViewId: '1234', + selectedPatterns: ['fakebeat-*'], + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx index 7653a0830b70e..1c2c73abcc042 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx @@ -107,6 +107,7 @@ export const TemporarySourcererComp = React.memo( const timelineType = useDeepEqualSelector( (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).timelineType ); + return ( <> ( )} {isModified === 'missingPatterns' && ( <> - {missingPatterns.join(', ')}, - }} - /> + + {missingPatterns.join(', ')}, + }} + /> + )} diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/timeline_sourcerer.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/timeline_sourcerer.test.tsx new file mode 100644 index 0000000000000..6f57f5fc2d34f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/timeline_sourcerer.test.tsx @@ -0,0 +1,188 @@ +/* + * 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, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { SourcererScopeName } from '../../store/sourcerer/model'; +import { Sourcerer } from '.'; +import { sourcererModel } from '../../store/sourcerer'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../mock'; +import { createStore } from '../../store'; +import { useSourcererDataView } from '../../containers/sourcerer'; +import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers'; + +const mockDispatch = jest.fn(); + +jest.mock('../../containers/sourcerer'); +jest.mock('../../containers/sourcerer/use_signal_helpers'); +const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true); +jest.mock('./use_update_data_view', () => ({ + useUpdateDataView: () => mockUseUpdateDataView, +})); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + + return { + ...original, + toMountPoint: jest.fn(), + }; +}); + +const mockUpdateUrlParam = jest.fn(); +jest.mock('../../utils/global_query_string', () => { + const original = jest.requireActual('../../utils/global_query_string'); + + return { + ...original, + useUpdateUrlParam: () => mockUpdateUrlParam, + }; +}); + +const { id } = mockGlobalState.sourcerer.defaultDataView; + +let store: ReturnType; +const sourcererDataView = { + indicesExist: true, + loading: false, +}; + +describe('timeline sourcerer', () => { + const { storage } = createSecuritySolutionStorageMock(); + store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + const testProps = { + scope: sourcererModel.SourcererScopeName.timeline, + }; + + beforeEach(async () => { + const pollForSignalIndexMock = jest.fn(); + + (useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView); + + (useSignalHelpers as jest.Mock).mockReturnValue({ + pollForSignalIndex: pollForSignalIndexMock, + signalIndexNeedsInit: false, + }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger')); + + await waitFor(() => { + fireEvent.click(screen.getByTestId(`sourcerer-advanced-options-toggle`)); + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders "alerts only" checkbox, unchecked', async () => { + await waitFor(() => { + expect(screen.getByTestId('sourcerer-alert-only-checkbox').parentElement).toHaveTextContent( + 'Show only detection alerts' + ); + expect(screen.getByTestId('sourcerer-alert-only-checkbox')).not.toBeChecked(); + }); + + fireEvent.click(screen.getByTestId('sourcerer-alert-only-checkbox')); + + await waitFor(() => { + expect(screen.getByTestId('sourcerer-alert-only-checkbox')).toBeChecked(); + }); + }); + + it('data view selector is enabled', async () => { + await waitFor(() => { + expect(screen.getByTestId('sourcerer-select')).toBeEnabled(); + }); + }); + + it('data view selector is default to Security Default Data View', async () => { + await waitFor(() => { + expect(screen.getByTestId('security-option-super')).toHaveTextContent( + 'Security Default Data View' + ); + }); + }); + + it('index pattern selector is enabled', async () => { + await waitFor(() => { + expect(screen.getByTestId('sourcerer-combo-box')).toBeEnabled(); + }); + }); + + it('render reset button', async () => { + await waitFor(() => { + expect(screen.getByTestId('sourcerer-reset')).toBeVisible(); + }); + }); + + it('render save button', async () => { + await waitFor(() => { + expect(screen.getByTestId('sourcerer-save')).toBeVisible(); + }); + }); + + it('Checks box when only alerts index is selected in timeline', async () => { + cleanup(); + const state2 = { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + selectedDataViewId: id, + selectedPatterns: [`${mockGlobalState.sourcerer.signalIndexName}`], + }, + }, + }, + }; + + store = createStore( + state2, + SUB_PLUGINS_REDUCER, + + kibanaObservable, + storage + ); + + render( + + + + ); + + fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]); + + await waitFor(() => { + expect(screen.getByTestId('sourcerer-alert-only-checkbox')).toBeChecked(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/trigger.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/trigger.tsx index e1c1e405bd52b..5dc7ab8522189 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/trigger.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/trigger.tsx @@ -7,9 +7,9 @@ import type { FC } from 'react'; import React, { memo, useMemo } from 'react'; -import { EuiToolTip } from '@elastic/eui'; +import { EuiToolTip, EuiButton } from '@elastic/eui'; import * as i18n from './translations'; -import { getTooltipContent, StyledBadge, StyledButton } from './helpers'; +import { getTooltipContent, StyledBadge, StyledButtonEmpty } from './helpers'; import type { ModifiedTypes } from './use_pick_index_patterns'; interface Props { @@ -68,12 +68,17 @@ export const TriggerComponent: FC = ({ } }, [isModified]); + const Button = useMemo( + () => (isTimelineSourcerer ? EuiButton : StyledButtonEmpty), + [isTimelineSourcerer] + ); + const trigger = useMemo( () => ( - = ({ > {i18n.DATA_VIEW} {!disabled && badge} - + ), - [disabled, badge, isTimelineSourcerer, loading, onClick] + [disabled, badge, isTimelineSourcerer, loading, onClick, Button] ); const tooltipContent = useMemo( diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/utils.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/utils.tsx index 00c6219598729..2e6653a185952 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/utils.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/utils.tsx @@ -43,28 +43,30 @@ export const CurrentPatternsMessage = ({ if (timelineType === TimelineType.template) { return ( + + {activePatterns.join(', ')}, + }} + /> + + ); + } + + return ( + {activePatterns.join(', ')}, }} /> - ); - } - - return ( - {activePatterns.join(', ')}, - }} - /> + ); }; @@ -147,25 +149,27 @@ export const DeprecatedMessage = ({ }) => { if (timelineType === TimelineType.template) { return ( + + {i18n.TOGGLE_TO_NEW_SOURCERER}, + }} + /> + + ); + } + return ( + {i18n.TOGGLE_TO_NEW_SOURCERER}, }} /> - ); - } - return ( - {i18n.TOGGLE_TO_NEW_SOURCERER}, - }} - /> + ); }; @@ -178,24 +182,26 @@ export const MissingPatternsMessage = ({ }) => { if (timelineType === TimelineType.template) { return ( + + {i18n.TOGGLE_TO_NEW_SOURCERER}, + }} + /> + + ); + } + return ( + {i18n.TOGGLE_TO_NEW_SOURCERER}, }} /> - ); - } - return ( - {i18n.TOGGLE_TO_NEW_SOURCERER}, - }} - /> + ); }; diff --git a/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts b/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts index 3fefc26076a12..80cf74a8f55b7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts @@ -233,7 +233,7 @@ export const convertToBuildEsQuery = ({ export const combineQueries = ({ config, - dataProviders, + dataProviders = [], indexPattern, browserFields, filters = [], diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index a118dfc86545c..ea73a893b6ad7 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -372,6 +372,7 @@ export const mockGlobalState: State = { itemsPerPageOptions: [10, 25, 50, 100], savedSearchId: null, isDiscoverSavedSearchLoaded: false, + isDataProviderVisible: true, }, }, insertTimeline: null, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index ef6b4d265a5a7..ce52132282798 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2027,6 +2027,7 @@ export const mockTimelineModel: TimelineModel = { templateTimelineVersion: null, version: '1', savedSearchId: null, + isDataProviderVisible: false, }; export const mockDataTableModel: DataTableModel = { @@ -2208,6 +2209,7 @@ export const defaultTimelineProps: CreateTimelineProps = { version: null, savedSearchId: null, isDiscoverSavedSearchLoaded: false, + isDataProviderVisible: false, }, to: '2018-11-05T19:03:25.937Z', notes: null, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 23b8fe50be532..da975ca4a9564 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -453,9 +453,9 @@ describe('alert actions', () => { version: null, savedSearchId: null, isDiscoverSavedSearchLoaded: false, + isDataProviderVisible: false, }, to: '2018-11-05T19:03:25.937Z', - resolveTimelineConfig: undefined, ruleNote: '# this is some markdown documentation', ruleAuthor: ['elastic'], }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx index a60c24378902f..611480ddd8f29 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx @@ -30,10 +30,32 @@ import { useKibana } from '../../../../common/lib/kibana'; const TextArea = styled(EuiTextArea)` display: block; - border: ${({ theme }) => theme.eui.euiBorderThin}; - border-bottom: 0; + border: 0; box-shadow: none; + border-radius: 0px; min-height: ${({ theme }) => theme.eui.euiFormControlHeight}; + &:focus { + box-shadow: none; + } +`; + +const StyledFormRow = styled(EuiFormRow)` + border: ${({ theme }) => theme.eui.euiBorderThin}; + border-radius: ${({ theme }) => theme.eui.euiBorderRadius}; + + .euiFormRow__labelWrapper { + background: ${({ theme }) => theme.eui.euiColorLightestShade}; + border-top-left-radius: ${({ theme }) => theme.eui.euiBorderRadius}; + border-top-right-radius: ${({ theme }) => theme.eui.euiBorderRadius}; + padding: 8px 10px; + margin-bottom: 0px; + label { + color: ${({ theme }) => theme.eui.euiTextSubduedColor}; + &.euiFormLabel-isInvalid { + color: ${({ theme }) => theme.eui.euiColorDangerText}; + } + } + } `; export interface FieldValueQueryBar { @@ -157,7 +179,7 @@ export const EqlQueryBar: FC = ({ ); return ( - = ({ )} - + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.tsx index c9e74b6a9acf5..7f7a6ab21a385 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.tsx @@ -14,7 +14,6 @@ import { EuiFlexItem, EuiFormRow, EuiLoadingSpinner, - EuiPanel, EuiPopover, EuiPopoverTitle, } from '@elastic/eui'; @@ -44,9 +43,11 @@ export interface Props { type SizeVoidFunc = (newSize: string) => void; -const Container = styled(EuiPanel)` +const Container = styled(EuiFlexGroup)` border-radius: 0; - background: ${({ theme }) => theme.eui.euiPageBackgroundColor}; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + background: ${({ theme }) => theme.eui.euiColorLightestShade}; padding: ${({ theme }) => theme.eui.euiSizeXS} ${({ theme }) => theme.eui.euiSizeS}; `; @@ -161,96 +162,113 @@ export const EqlQueryBarFooter: FC = ({ return ( - - - {errors.length > 0 && ( - - )} - {isLoading && } + + + + + {errors.length > 0 && ( + + )} + {isLoading && } + + - {!onOptionsChange && ( - - - - )} - {onOptionsChange && ( - <> - - - - - - } - isOpen={openEqlSettings} - closePopover={closeEqlSettingsHandler} - anchorPosition="downCenter" - ownFocus={false} - > - {i18n.EQL_SETTINGS_TITLE} -
- {!isSizeOptionDisabled && ( - - + + {!onOptionsChange && ( + + + + )} + + {onOptionsChange && ( + <> + + + + + - - )} - - - - - - - - - -
-
-
- - )} + {i18n.EQL_SETTINGS_TITLE} +
+ {!isSizeOptionDisabled && ( + + + + )} + + + + + + + + + +
+ + + + )} + +
); diff --git a/x-pack/plugins/security_solution/public/explore/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap b/x-pack/plugins/security_solution/public/explore/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap index b9fd26239a829..cebd8a483aafb 100644 --- a/x-pack/plugins/security_solution/public/explore/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/explore/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap @@ -105,7 +105,7 @@ exports[`Authentication Host Table Component rendering it renders the host authe class="euiFlexItem emotion-euiFlexItem-grow-1" >

- .c2 { - display: block; + .c3:active, +.c3:focus { + background: transparent; } -.c1 > span { +.c3 > span { padding: 0; } -.c3 { +.c4 { overflow: hidden; display: inline-block; text-overflow: ellipsis; + white-space: nowrap; } -.c0 { +.c5 { + white-space: nowrap; +} + +.c1 { + overflow-x: auto; +} + +.c2 { overflow: hidden; } +.c0 { + backgroundColor: #1d1e24; + color: #dfe5ef; + padding-inline: 12px; + border-radius: 0px; +} +
- -
-
-
-
- + + +
+
+
+
+ + Unsaved + +
+
+
+ +
+
diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.test.tsx new file mode 100644 index 0000000000000..39dc27c540d56 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.test.tsx @@ -0,0 +1,88 @@ +/* + * 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 } from '@testing-library/react'; + +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; +import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { allCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils'; +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { TimelineActionMenu } from '.'; +import { TimelineId, TimelineTabs } from '../../../../../common/types'; + +const mockUseSourcererDataView: jest.Mock = useSourcererDataView as jest.Mock; +jest.mock('../../../../common/containers/sourcerer'); + +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../../../common/lib/kibana'); +jest.mock('@kbn/i18n-react', () => { + const originalModule = jest.requireActual('@kbn/i18n-react'); + const FormattedRelative = jest.fn().mockImplementation(() => '20 hours ago'); + + return { + ...originalModule, + FormattedRelative, + }; +}); + +const sourcererDefaultValue = { + sourcererDefaultValue: mockBrowserFields, + indexPattern: mockIndexPattern, + loading: false, + selectedPatterns: mockIndexNames, +}; + +describe('Action menu', () => { + beforeEach(() => { + // Mocking these services is required for the header component to render. + mockUseSourcererDataView.mockImplementation(() => sourcererDefaultValue); + useKibanaMock().services.application.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + describe('AddToCaseButton', () => { + it('renders the button when the user has create and read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions()); + + render( + + + + ); + + expect(screen.getByTestId('attach-timeline-case-button')).toBeInTheDocument(); + }); + + it('does not render the button when the user does not have create permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions()); + + render( + + + + ); + + expect(screen.queryByTestId('attach-timeline-case-button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.tsx new file mode 100644 index 0000000000000..850078e134129 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.tsx @@ -0,0 +1,68 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import { useGetUserCasesPermissions } from '../../../../common/lib/kibana'; +import type { TimelineTabs } from '../../../../../common/types'; +import { InspectButton } from '../../../../common/components/inspect'; +import { InputsModelId } from '../../../../common/store/inputs/constants'; +import { AddToCaseButton } from '../add_to_case_button'; +import { NewTimelineAction } from './new_timeline'; +import { SaveTimelineButton } from './save_timeline_button'; +import { OpenTimelineAction } from './open_timeline'; + +interface TimelineActionMenuProps { + mode?: 'compact' | 'normal'; + timelineId: string; + isInspectButtonDisabled: boolean; + activeTab: TimelineTabs; +} + +const TimelineActionMenuComponent = ({ + mode = 'normal', + timelineId, + activeTab, + isInspectButtonDisabled, +}: TimelineActionMenuProps) => { + const userCasesPermissions = useGetUserCasesPermissions(); + return ( + + + + + + + + {userCasesPermissions.create && userCasesPermissions.read ? ( + + + + ) : null} + + + + + + + + ); +}; + +export const TimelineActionMenu = React.memo(TimelineActionMenuComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/new_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/new_timeline.tsx new file mode 100644 index 0000000000000..8fc65250c5b4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/new_timeline.tsx @@ -0,0 +1,62 @@ +/* + * 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui'; +import React, { useMemo, useState, useCallback } from 'react'; +import { NewTimeline } from '../../timeline/properties/helpers'; +import { NewTemplateTimeline } from '../../timeline/properties/new_template_timeline'; +import * as i18n from './translations'; + +interface NewTimelineActionProps { + timelineId: string; +} + +const panelStyle = { + padding: 0, +}; + +export const NewTimelineAction = React.memo(({ timelineId }: NewTimelineActionProps) => { + const [isPopoverOpen, setPopover] = useState(false); + + const closePopover = useCallback(() => { + setPopover(false); + }, []); + + const togglePopover = useCallback(() => setPopover((prev) => !prev), []); + + const newTimelineActionbtn = useMemo(() => { + return ( + + {i18n.NEW_TIMELINE_BTN} + + ); + }, [togglePopover]); + + return ( + + + + + + + + + + + ); +}); + +NewTimelineAction.displayName = 'NewTimelineAction'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/open_timeline.tsx new file mode 100644 index 0000000000000..f5a7a51dc75e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/open_timeline.tsx @@ -0,0 +1,40 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import React, { useState, useCallback } from 'react'; +import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; +import type { ActionTimelineToShow } from '../../open_timeline/types'; +import * as i18n from './translations'; + +const actionTimelineToHide: ActionTimelineToShow[] = ['createFrom']; + +export const OpenTimelineAction = React.memo(() => { + const [showTimelineModal, setShowTimelineModal] = useState(false); + const onCloseTimelineModal = useCallback(() => setShowTimelineModal(false), []); + const onOpenTimelineModal = useCallback(() => { + setShowTimelineModal(true); + }, []); + + return ( + <> + + {i18n.OPEN_TIMELINE_BTN} + + + {showTimelineModal ? ( + + ) : null} + + ); +}); + +OpenTimelineAction.displayName = 'OpenTimelineAction'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.test.tsx index 7c05526594501..a7259e256bd7e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.test.tsx @@ -11,7 +11,7 @@ import type { SaveTimelineButtonProps } from './save_timeline_button'; import { SaveTimelineButton } from './save_timeline_button'; import { TestProviders } from '../../../../common/mock'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; -import { getTimelineStatusByIdSelector } from '../../flyout/header/selectors'; +import { getTimelineStatusByIdSelector } from '../header/selectors'; import { TimelineStatus } from '../../../../../common/api/timeline'; const TEST_ID = { @@ -28,7 +28,7 @@ jest.mock('react-redux', () => { jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/components/user_privileges'); -jest.mock('../../flyout/header/selectors', () => { +jest.mock('../header/selectors', () => { return { getTimelineStatusByIdSelector: jest.fn().mockReturnValue(() => ({ status: 'draft', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx rename to x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.tsx index f7e7c397e08ad..6cc0686acbff4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.tsx @@ -8,7 +8,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, EuiToolTip, EuiTourStep, EuiCode, EuiText, EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { getTimelineStatusByIdSelector } from '../../flyout/header/selectors'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { TimelineStatus } from '../../../../../common/api/timeline'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; @@ -17,6 +16,7 @@ import { useLocalStorage } from '../../../../common/components/local_storage'; import { SaveTimelineModal } from './save_timeline_modal'; import * as timelineTranslations from './translations'; +import { getTimelineStatusByIdSelector } from '../header/selectors'; export interface SaveTimelineButtonProps { timelineId: string; @@ -90,10 +90,11 @@ export const SaveTimelineButton = React.memo(({ timelin fill color="primary" onClick={openEditTimeline} + size="s" iconType="save" isLoading={isSaving} disabled={!canEditTimeline} - data-test-subj="save-timeline-btn" + data-test-subj="save-timeline-action-btn" id={SAVE_BUTTON_ELEMENT_ID} > {timelineTranslations.SAVE} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_modal.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_modal.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.test.tsx index 8ee6bf807913a..33fa8f17880f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.test.tsx @@ -18,7 +18,7 @@ jest.mock('../../../../common/hooks/use_selector', () => ({ useDeepEqualSelector: jest.fn(), })); -jest.mock('../properties/use_create_timeline', () => ({ +jest.mock('../../timeline/properties/use_create_timeline', () => ({ useCreateTimeline: jest.fn(), })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_modal.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_modal.tsx rename to x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.tsx index f38cb4784ec17..0e76facc45040 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_modal.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.tsx @@ -26,13 +26,13 @@ import { TimelineId } from '../../../../../common/types/timeline'; import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; -import { useCreateTimeline } from '../properties/use_create_timeline'; -import * as commonI18n from '../properties/translations'; +import * as commonI18n from '../../timeline/properties/translations'; import * as i18n from './translations'; -import { formSchema } from './schema'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; import { TIMELINE_ACTIONS } from '../../../../common/lib/apm/user_actions'; +import { useCreateTimeline } from '../../timeline/properties/use_create_timeline'; +import { NOTES_PANEL_WIDTH } from '../../timeline/properties/notes_size'; +import { formSchema } from './schema'; const CommonUseField = getUseField({ component: Field }); interface SaveTimelineModalProps { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/schema.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/schema.ts similarity index 100% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/schema.ts rename to x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/schema.ts diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/translations.ts similarity index 72% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts rename to x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/translations.ts index 83017597861a7..dd7f7e6a6e95f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/translations.ts @@ -9,6 +9,55 @@ import { i18n } from '@kbn/i18n'; import type { TimelineTypeLiteral } from '../../../../../common/api/timeline'; import { TimelineType } from '../../../../../common/api/timeline'; +export const NEW_TIMELINE_BTN = i18n.translate( + 'xpack.securitySolution.flyout.timeline.actionMenu.newTimelineBtn', + { + defaultMessage: 'New', + } +); + +export const NEW_TIMELINE = i18n.translate( + 'xpack.securitySolution.flyout.timeline.actionMenu.newTimeline', + { + defaultMessage: 'New Timeline', + } +); + +export const OPEN_TIMELINE_BTN = i18n.translate( + 'xpack.securitySolution.flyout.timeline.actionMenu.openTimelineBtn', + { + defaultMessage: 'Open', + } +); + +export const OPEN_TIMELINE_BTN_LABEL = i18n.translate( + 'xpack.securitySolution.flyout.timeline.actionMenu.openTimelineBtnLabel', + { + defaultMessage: 'Open Existing Timeline', + } +); + +export const SAVE_TIMELINE_BTN = i18n.translate( + 'xpack.securitySolution.flyout.timeline.actionMenu.saveTimelineBtn', + { + defaultMessage: 'Save', + } +); + +export const SAVE_TIMELINE_BTN_LABEL = i18n.translate( + 'xpack.securitySolution.flyout.timeline.actionMenu.saveTimelineBtnLabel', + { + defaultMessage: 'Save currently opened Timeline', + } +); + +export const NEW_TEMPLATE_TIMELINE = i18n.translate( + 'xpack.securitySolution.flyout.timeline.actionMenu.newTimelineTemplate', + { + defaultMessage: 'New Timeline template', + } +); + export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate( 'xpack.securitySolution.timeline.callOut.unauthorized.message.description', { @@ -79,13 +128,6 @@ export const UNSAVED_TIMELINE_WARNING = (timelineType: TimelineTypeLiteral) => defaultMessage: 'You have an unsaved {timeline}. Do you wish to save it?', }); -export const TITLE = i18n.translate( - 'xpack.securitySolution.timeline.saveTimeline.modal.titleTitle', - { - defaultMessage: 'Title', - } -); - export const TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel', { @@ -114,6 +156,10 @@ export const SAVE_TOUR_CLOSE = i18n.translate( } ); +export const TITLE = i18n.translate('xpack.securitySolution.timeline.saveTimeline.modal.title', { + defaultMessage: 'Title', +}); + export const SAVE_TOUR_TITLE = i18n.translate( 'xpack.securitySolution.timeline.flyout.saveTour.title', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx index 74662e7563201..69b71adb9fb6e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx @@ -70,13 +70,13 @@ const AddTimelineButtonComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index c25d48ad03dd9..56b7bafe58ea2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -6,7 +6,7 @@ */ import { pick } from 'lodash/fp'; -import { EuiButton, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; +import { EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; @@ -118,8 +118,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { const button = useMemo( () => ( - = ({ timelineId }) => { disabled={timelineStatus === TimelineStatus.draft || timelineType !== TimelineType.default} > {i18n.ATTACH_TO_CASE} - + ), [handleButtonClick, timelineStatus, timelineType] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx index 0b5286f1fedb3..bc1617d3b9a53 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx @@ -10,6 +10,7 @@ import { FlyoutHeaderPanel } from '../header'; interface FlyoutBottomBarProps { showTimelineHeaderPanel: boolean; + timelineId: string; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx new file mode 100644 index 0000000000000..18bf93d0ab6c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx @@ -0,0 +1,96 @@ +/* + * 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 { + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../../../common/mock'; +import React from 'react'; +import type { ActiveTimelinesProps } from './active_timelines'; +import { ActiveTimelines } from './active_timelines'; +import { TimelineId } from '../../../../../common/types'; +import { TimelineType } from '../../../../../common/api/timeline'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createSecuritySolutionStorageMock } from '@kbn/timelines-plugin/public/mock/mock_local_storage'; +import { createStore } from '../../../../common/store'; + +const { storage } = createSecuritySolutionStorageMock(); + +const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + +const TestComponent = (props: ActiveTimelinesProps) => { + return ( + + + + ); +}; + +describe('ActiveTimelines', () => { + describe('default timeline', () => { + it('should render timeline title as button when minimized', () => { + render( + + ); + + expect(screen.getByLabelText(/Open timeline timeline-test/).nodeName.toLowerCase()).toBe( + 'button' + ); + }); + + it('should render timeline title as text when maximized', () => { + render( + + ); + expect(screen.queryByLabelText(/Open timeline timeline-test/)).toBeFalsy(); + }); + + it('should maximized timeline when clicked on minimized timeline', async () => { + render( + + ); + + fireEvent.click(screen.getByLabelText(/Open timeline timeline-test/)); + + await waitFor(() => { + expect(store.getState().timeline.timelineById.test.show).toBe(true); + }); + }); + }); + + describe('template timeline', () => { + it('should render timeline template title as button when minimized', () => { + render( + + ); + + expect(screen.getByTestId(/timeline-title/)).toHaveTextContent(/Untitled template/); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index e6df4e56386a5..cbc38f4ccecb9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -5,14 +5,13 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiHealth, EuiToolTip } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiText } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { isEmpty } from 'lodash/fp'; import styled from 'styled-components'; -import { FormattedRelative } from '@kbn/i18n-react'; -import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; +import { TimelineType } from '../../../../../common/api/timeline'; import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; import { ACTIVE_TIMELINE_BUTTON_CLASS_NAME, @@ -22,20 +21,18 @@ import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/ import { timelineActions } from '../../../store/timeline'; import * as i18n from './translations'; -const EuiHealthStyled = styled(EuiHealth)` - display: block; -`; - -interface ActiveTimelinesProps { +export interface ActiveTimelinesProps { timelineId: string; - timelineStatus: TimelineStatus; timelineTitle: string; timelineType: TimelineType; isOpen: boolean; - updated?: number; } const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` + &:active, + &:focus { + background: transparent; + } > span { padding: 0; } @@ -45,17 +42,17 @@ const TitleConatiner = styled(EuiFlexItem)` overflow: hidden; display: inline-block; text-overflow: ellipsis; + white-space: nowrap; `; const ActiveTimelinesComponent: React.FC = ({ timelineId, - timelineStatus, timelineType, timelineTitle, - updated, isOpen, }) => { const dispatch = useDispatch(); + const handleToggleOpen = useCallback(() => { dispatch(timelineActions.showTimeline({ id: timelineId, show: !isOpen })); focusActiveTimelineButton(); @@ -67,53 +64,47 @@ const ActiveTimelinesComponent: React.FC = ({ ? UNTITLED_TEMPLATE : UNTITLED_TIMELINE; - const tooltipContent = useMemo(() => { - if (timelineStatus === TimelineStatus.draft) { - return <>{i18n.UNSAVED}; - } + const titleContent = useMemo(() => { return ( - <> - {i18n.SAVED}{' '} - - - ); - }, [timelineStatus, updated]); - - return ( - - - - - - - {title} + + {isOpen ? ( + +

{title}

+
+ ) : ( + <>{title} + )} +
{!isOpen && ( )}
+ ); + }, [isOpen, title]); + + if (isOpen) { + return <>{titleContent}; + } + + return ( + + {titleContent} ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx deleted file mode 100644 index 79ef41a070574..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx +++ /dev/null @@ -1,171 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render, screen } from '@testing-library/react'; - -import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; -import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock'; -import { TimelineId } from '../../../../../common/types/timeline'; -import { useTimelineKpis } from '../../../containers/kpis'; -import { FlyoutHeader } from '.'; -import { useSourcererDataView } from '../../../../common/containers/sourcerer'; -import { mockBrowserFields } from '../../../../common/containers/source/mock'; -import { getEmptyValue } from '../../../../common/components/empty_value'; -import { allCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils'; - -const mockUseSourcererDataView: jest.Mock = useSourcererDataView as jest.Mock; -jest.mock('../../../../common/containers/sourcerer'); - -const mockUseTimelineKpis: jest.Mock = useTimelineKpis as jest.Mock; -jest.mock('../../../containers/kpis', () => ({ - useTimelineKpis: jest.fn(), -})); -const useKibanaMock = useKibana as jest.Mocked; -jest.mock('../../../../common/lib/kibana'); -jest.mock('@kbn/i18n-react', () => { - const originalModule = jest.requireActual('@kbn/i18n-react'); - const FormattedRelative = jest.fn().mockImplementation(() => '20 hours ago'); - - return { - ...originalModule, - FormattedRelative, - }; -}); -const mockUseTimelineKpiResponse = { - processCount: 1, - userCount: 1, - sourceIpCount: 1, - hostCount: 1, - destinationIpCount: 1, -}; - -const mockUseTimelineLargeKpiResponse = { - processCount: 1000, - userCount: 1000000, - sourceIpCount: 1000000000, - hostCount: 999, - destinationIpCount: 1, -}; -const defaultMocks = { - browserFields: mockBrowserFields, - indexPattern: mockIndexPattern, - loading: false, - selectedPatterns: mockIndexNames, -}; -describe('header', () => { - beforeEach(() => { - // Mocking these services is required for the header component to render. - mockUseSourcererDataView.mockImplementation(() => defaultMocks); - useKibanaMock().services.application.capabilities = { - navLinks: {}, - management: {}, - catalogue: {}, - actions: { show: true, crud: true }, - }; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('AddToCaseButton', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); - }); - - it('renders the button when the user has create and read permissions', () => { - (useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions()); - - render( - - - - ); - - expect(screen.getByTestId('attach-timeline-case-button')).toBeInTheDocument(); - }); - - it('does not render the button when the user does not have create permissions', () => { - (useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions()); - - render( - - - - ); - - expect(screen.queryByTestId('attach-timeline-case-button')).not.toBeInTheDocument(); - }); - }); - - describe('Timeline KPIs', () => { - describe('when the data is not loading and the response contains data', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); - }); - it('renders the component, labels and values successfully', () => { - render( - - - - ); - expect(screen.getByTestId('siem-timeline-kpis')).toBeInTheDocument(); - // label - expect(screen.getByText('Processes')).toBeInTheDocument(); - // value - expect(screen.getByTestId('siem-timeline-process-kpi').textContent).toContain('1'); - }); - }); - - describe('when the data is loading', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); - }); - it('renders a loading indicator for values', async () => { - render( - - - - ); - expect(screen.getAllByText('--')).not.toHaveLength(0); - }); - }); - - describe('when the response is null and timeline is blank', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, null]); - }); - it('renders labels and the default empty string', () => { - render( - - - - ); - expect(screen.getByText('Processes')).toBeInTheDocument(); - expect(screen.getAllByText(getEmptyValue())).not.toHaveLength(0); - }); - }); - - describe('when the response contains numbers larger than one thousand', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); - }); - it('formats the numbers correctly', () => { - render( - - - - ); - expect(screen.getByText('1k', { selector: '.euiTitle' })).toBeInTheDocument(); - expect(screen.getByText('1m', { selector: '.euiTitle' })).toBeInTheDocument(); - expect(screen.getByText('1b', { selector: '.euiTitle' })).toBeInTheDocument(); - expect(screen.getByText('999', { selector: '.euiTitle' })).toBeInTheDocument(); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 985d373950cc8..939a450e7064e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -5,70 +5,48 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiToolTip, - EuiButtonIcon, - EuiText, - EuiButtonEmpty, - useEuiTheme, - EuiTextColor, -} from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n-react'; -import type { MouseEventHandler } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { isEmpty, get, pick } from 'lodash/fp'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; -import { InputsModelId } from '../../../../common/store/inputs/constants'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { TimelineTabs, TimelineId } from '../../../../../common/types/timeline'; -import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; -import type { State } from '../../../../common/store'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { timelineDefaults } from '../../../store/timeline/defaults'; -import { AddToFavoritesButton } from '../../timeline/properties/helpers'; -import type { TimerangeInput } from '../../../../../common/search_strategy'; -import { AddToCaseButton } from '../add_to_case_button'; -import { AddTimelineButton } from '../add_timeline_button'; -import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; -import { InspectButton } from '../../../../common/components/inspect'; -import { useTimelineKpis } from '../../../containers/kpis'; +import type { State } from '../../../../common/store'; +import { useKibana } from '../../../../common/lib/kibana'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; -import type { TimelineModel } from '../../../store/timeline/model'; -import { - startSelector, - endSelector, -} from '../../../../common/components/super_date_picker/selectors'; import { focusActiveTimelineButton } from '../../timeline/helpers'; import { combineQueries } from '../../../../common/lib/kuery'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { ActiveTimelines } from './active_timelines'; import * as i18n from './translations'; -import * as commonI18n from '../../timeline/properties/translations'; -import { TimelineKPIs } from './kpis'; - -import { setActiveTabTimeline } from '../../../store/timeline/actions'; -import { useIsOverflow } from '../../../../common/hooks/use_is_overflow'; -import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; -import { TimelineSavePrompt } from '../../timeline/header/timeline_save_prompt'; +import { TimelineActionMenu } from '../action_menu'; +import { AddToFavoritesButton } from '../../timeline/properties/helpers'; +import { TimelineStatusInfo } from './timeline_status_info'; -interface FlyoutHeaderProps { +export interface FlyoutHeaderPanelProps { timelineId: string; } -interface FlyoutHeaderPanelProps { - timelineId: string; -} +const FlyoutHeaderPanelContentFlexGroupContainer = styled(EuiFlexGroup)` + overflow-x: auto; +`; const ActiveTimelinesContainer = styled(EuiFlexItem)` overflow: hidden; `; +const TimelinePanel = euiStyled(EuiPanel)<{ $isOpen?: boolean }>` + backgroundColor: ${(props) => props.theme.eui.euiColorEmptyShade}; + color: ${(props) => props.theme.eui.euiTextColor}; + padding-inline: ${(props) => props.theme.eui.euiSizeM}; + border-radius: ${({ $isOpen, theme }) => ($isOpen ? theme.eui.euiBorderRadius : '0px')}; +`; + const FlyoutHeaderPanelComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const { browserFields, indexPattern } = useSourcererDataView(SourcererScopeName.timeline); @@ -86,6 +64,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline show, filters, kqlMode, + changed = false, } = useDeepEqualSelector((state) => pick( [ @@ -99,6 +78,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline 'show', 'filters', 'kqlMode', + 'changed', ], getTimeline(state, timelineId) ?? timelineDefaults ) @@ -109,14 +89,15 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline ); const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!); + + const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)); const kqlQueryExpression = isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' ? ' ' - : kqlQueryTimeline; - const kqlQueryTest = useMemo( + : kqlQueryTimeline ?? ''; + + const kqlQueryObj = useMemo( () => ({ query: kqlQueryExpression, language: 'kuery' }), [kqlQueryExpression] ); @@ -129,10 +110,10 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline indexPattern, browserFields, filters: filters ? filters : [], - kqlQuery: kqlQueryTest, + kqlQuery: kqlQueryObj, kqlMode, }), - [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQueryTest] + [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQueryObj] ); const handleClose = useCallback(() => { @@ -140,44 +121,57 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline focusActiveTimelineButton(); }, [dispatch, timelineId]); - const { euiTheme } = useEuiTheme(); - return ( - - - - - - + + + + + + + + + + + + + + + + {show && ( - - - {(activeTab === TimelineTabs.query || activeTab === TimelineTabs.eql) && ( - - - - )} + + + = ({ timeline )} - - + + ); }; export const FlyoutHeaderPanel = React.memo(FlyoutHeaderPanelComponent); - -const StyledDiv = styled.div` - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; -`; - -const ReadMoreButton = ({ - description, - onclick, -}: { - description: string; - onclick: MouseEventHandler; -}) => { - const [isOverflow, ref] = useIsOverflow(description); - return ( - <> - {description} - {isOverflow && ( - - {i18n.READ_MORE} - - )} - - ); -}; - -const StyledTimelineHeader = styled(EuiFlexGroup)` - ${({ theme }) => `margin: ${theme.eui.euiSizeXS} ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS};`} - flex: 0; -`; - -const TimelineStatusInfoContainer = styled.span` - ${({ theme }) => `margin-left: ${theme.eui.euiSizeS};`} - white-space: nowrap; -`; - -const KpisContainer = styled.div` - ${({ theme }) => `margin-right: ${theme.eui.euiSizeM};`} -`; - -const RowFlexItem = styled(EuiFlexItem)` - flex-direction: row; - align-items: center; -`; - -const TimelineTitleContainer = styled.h3` - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - word-break: break-word; -`; - -const TimelineNameComponent: React.FC = ({ timelineId }) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { title, timelineType } = useDeepEqualSelector((state) => - pick(['title', 'timelineType'], getTimeline(state, timelineId) ?? timelineDefaults) - ); - const placeholder = useMemo( - () => - timelineType === TimelineType.template - ? commonI18n.UNTITLED_TEMPLATE - : commonI18n.UNTITLED_TIMELINE, - [timelineType] - ); - - const content = useMemo(() => title || placeholder, [title, placeholder]); - - return ( - - - {content} - - - ); -}; - -const TimelineName = React.memo(TimelineNameComponent); - -const TimelineDescriptionComponent: React.FC<{ timelineId: string; description?: string }> = ({ - timelineId, - description, -}) => { - const dispatch = useDispatch(); - - const onReadMore = useCallback(() => { - dispatch( - setActiveTabTimeline({ - id: timelineId, - activeTab: TimelineTabs.notes, - scrollToTop: true, - }) - ); - }, [dispatch, timelineId]); - - const hasDescription = !!description; - return hasDescription ? ( - - - - ) : null; -}; - -const TimelineDescription = React.memo(TimelineDescriptionComponent); - -const TimelineStatusInfoComponent = React.memo<{ - status: TimelineStatus; - updated?: number; - changed?: boolean; -}>(({ status, updated, changed }) => { - const isUnsaved = status === TimelineStatus.draft; - - let statusContent: React.ReactNode = null; - if (isUnsaved) { - statusContent = {i18n.UNSAVED}; - } else if (changed) { - statusContent = {i18n.UNSAVED_CHANGES}; - } else { - statusContent = ( - <> - {i18n.SAVED}{' '} - - - ); - } - return ( - - {statusContent} - - ); -}); -TimelineStatusInfoComponent.displayName = 'TimelineStatusInfoComponent'; - -const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { - const { selectedPatterns, indexPattern, browserFields } = useSourcererDataView( - SourcererScopeName.timeline - ); - const getStartSelector = useMemo(() => startSelector(), []); - const getEndSelector = useMemo(() => endSelector(), []); - const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]); - const timerange: TimerangeInput = useDeepEqualSelector((state) => { - if (isActive) { - return { - from: getStartSelector(state.inputs.timeline), - to: getEndSelector(state.inputs.timeline), - interval: '', - }; - } else { - return { - from: getStartSelector(state.inputs.global), - to: getEndSelector(state.inputs.global), - interval: '', - }; - } - }); - const { uiSettings } = useKibana().services; - const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const timeline: TimelineModel = useSelector( - (state: State) => getTimeline(state, timelineId) ?? timelineDefaults - ); - const { dataProviders, filters, timelineType, kqlMode, activeTab } = timeline; - const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!); - - const kqlQueryExpression = - isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' - ? ' ' - : kqlQueryTimeline; - const kqlQuery = useMemo( - () => ({ query: kqlQueryExpression, language: 'kuery' }), - [kqlQueryExpression] - ); - - const combinedQueries = useMemo( - () => - combineQueries({ - config: esQueryConfig, - dataProviders, - indexPattern, - browserFields, - filters: filters ? filters : [], - kqlQuery, - kqlMode, - }), - [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery] - ); - - const isBlankTimeline: boolean = useMemo( - () => - (isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query)) || - combinedQueries?.filterQuery === undefined, - [dataProviders, filters, kqlQuery, combinedQueries] - ); - - const [loading, kpis] = useTimelineKpis({ - defaultIndex: selectedPatterns, - timerange, - isBlankTimeline, - filterQuery: combinedQueries?.filterQuery ?? '', - }); - - const userCasesPermissions = useGetUserCasesPermissions(); - return ( - - - - - - - - - - - - - - - - - - - {activeTab === TimelineTabs.query ? ( - - ) : null} - - - - - - - - - {userCasesPermissions.create && userCasesPermissions.read && ( - - - - )} - - - - - - - ); -}; - -FlyoutHeaderComponent.displayName = 'FlyoutHeaderComponent'; - -export const FlyoutHeader = React.memo(FlyoutHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx deleted file mode 100644 index 4cb622ec801b4..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx +++ /dev/null @@ -1,110 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; -import styled from 'styled-components'; - -import { EuiStat, EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui'; -import numeral from '@elastic/numeral'; -import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; -import { useUiSetting$ } from '../../../../common/lib/kibana'; -import type { TimelineKpiStrategyResponse } from '../../../../../common/search_strategy'; -import { getEmptyValue } from '../../../../common/components/empty_value'; -import * as i18n from './translations'; - -const NoWrapEuiStat = styled(EuiStat)` - & .euiStat__description { - white-space: nowrap; - } -`; - -export const TimelineKPIs = React.memo( - ({ kpis, isLoading }: { kpis: TimelineKpiStrategyResponse | null; isLoading: boolean }) => { - const kpiFormat = '0,0.[000]a'; - const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const formattedKpis = useMemo(() => { - return { - process: kpis === null ? getEmptyValue() : numeral(kpis.processCount).format(kpiFormat), - user: kpis === null ? getEmptyValue() : numeral(kpis.userCount).format(kpiFormat), - host: kpis === null ? getEmptyValue() : numeral(kpis.hostCount).format(kpiFormat), - sourceIp: kpis === null ? getEmptyValue() : numeral(kpis.sourceIpCount).format(kpiFormat), - destinationIp: - kpis === null ? getEmptyValue() : numeral(kpis.destinationIpCount).format(kpiFormat), - }; - }, [kpis]); - const formattedKpiToolTips = useMemo(() => { - return { - process: numeral(kpis?.processCount).format(defaultNumberFormat), - user: numeral(kpis?.userCount).format(defaultNumberFormat), - host: numeral(kpis?.hostCount).format(defaultNumberFormat), - sourceIp: numeral(kpis?.sourceIpCount).format(defaultNumberFormat), - destinationIp: numeral(kpis?.destinationIpCount).format(defaultNumberFormat), - }; - }, [kpis, defaultNumberFormat]); - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } -); - -TimelineKPIs.displayName = 'TimelineKPIs'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/timeline_status_info.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/timeline_status_info.test.tsx new file mode 100644 index 0000000000000..f7fda862f793f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/timeline_status_info.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { screen, render } from '@testing-library/react'; +import type { TimelineStatusInfoProps } from './timeline_status_info'; +import { TimelineStatusInfo } from './timeline_status_info'; +import { TimelineStatus } from '../../../../../common/api/timeline'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +const TestComponent = (props: TimelineStatusInfoProps) => { + return ( + + + + ); +}; + +describe('TestComponent', () => { + it('should render the status correctly when timeline is unsaved', () => { + render(); + expect(screen.getByText('Unsaved')).toBeVisible(); + }); + + it('should render the status correctly when timeline has unsaved changes', () => { + render(); + expect(screen.getByText('Has unsaved changes')).toBeVisible(); + }); + + it('should render the status correctly when timeline is saved', () => { + const updatedTime = Date.now(); + render(); + expect(screen.getByText('Saved')).toBeVisible(); + }); + + it('should render the status correctly when timeline is saved some time ago', () => { + const updatedTime = Date.now() - 10000; + render(); + expect(screen.getByTestId('timeline-status')).toHaveTextContent(/Saved10 seconds ago/); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/timeline_status_info.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/timeline_status_info.tsx new file mode 100644 index 0000000000000..ed164ddab47fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/timeline_status_info.tsx @@ -0,0 +1,54 @@ +/* + * 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 { EuiTextColor, EuiText } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n-react'; + +import styled from 'styled-components'; +import { TimelineStatus } from '../../../../../common/api/timeline'; +import * as i18n from './translations'; + +const NoWrapText = styled(EuiText)` + white-space: nowrap; +`; + +export interface TimelineStatusInfoProps { + status: TimelineStatus; + updated?: number; + changed?: boolean; +} + +export const TimelineStatusInfo = React.memo( + ({ status, updated, changed }) => { + const isUnsaved = status === TimelineStatus.draft; + + let statusContent: React.ReactNode = null; + if (isUnsaved || !updated) { + statusContent = {i18n.UNSAVED}; + } else if (changed) { + statusContent = {i18n.UNSAVED_CHANGES}; + } else { + statusContent = ( + <> + {i18n.SAVED} + + + ); + } + return ( + + {statusContent} + + ); + } +); +TimelineStatusInfo.displayName = 'TimelineStatusInfo'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index 2a52d9407ad58..56036f899e61f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -37,35 +37,6 @@ export const INSPECT_TIMELINE_TITLE = i18n.translate( } ); -export const PROCESS_KPI_TITLE = i18n.translate( - 'xpack.securitySolution.timeline.kpis.processKpiTitle', - { - defaultMessage: 'Processes', - } -); - -export const HOST_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.hostKpiTitle', { - defaultMessage: 'Hosts', -}); - -export const SOURCE_IP_KPI_TITLE = i18n.translate( - 'xpack.securitySolution.timeline.kpis.sourceIpKpiTitle', - { - defaultMessage: 'Source IPs', - } -); - -export const DESTINATION_IP_KPI_TITLE = i18n.translate( - 'xpack.securitySolution.timeline.kpis.destinationKpiTitle', - { - defaultMessage: 'Destination IPs', - } -); - -export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.userKpiTitle', { - defaultMessage: 'Users', -}); - export const READ_MORE = i18n.translate('xpack.securitySolution.timeline.properties.readMore', { defaultMessage: 'Read More', }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/pane.styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/pane.styles.tsx index 6824756f8bb0c..95bea7d742ca1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/pane.styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/pane.styles.tsx @@ -68,9 +68,6 @@ export const usePaneStyles = () => { .timeline-template-badge { border-radius: ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium} 0 0; // top corners only } - .timeline-body { - padding: 0 ${euiTheme.size.s}; - } } `; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx index 31df17fa40046..f770d3a46c46d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx @@ -33,7 +33,7 @@ const AddNotesContainer = styled.div` AddNotesContainer.displayName = 'AddNotesContainer'; const ButtonsContainer = styled(EuiFlexGroup)` - margin-top: 5px; + margin-top: ${({ theme }) => theme.eui.euiSizeS}; `; ButtonsContainer.displayName = 'ButtonsContainer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 6fcc467c7626b..f0e4e31f1e9be 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -122,16 +122,24 @@ Array [ />
, -
+ class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row" + > +
+
, ] @@ -164,6 +172,10 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should padding: 0 12px 12px; } +.c2 .side-panel-flyout-footer { + background-color: transparent; +} +
+ class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row" + > +
+
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx index ac5d5ee32797c..15d6b2040234a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx @@ -142,7 +142,10 @@ export const FlyoutFooterComponent = React.memo( return ( <> - + {detailsEcsData && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 729b20e68cb15..a2a45e46a07b2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -6,8 +6,9 @@ */ import { useAssistantOverlay } from '@kbn/elastic-assistant'; -import { EuiSpacer, EuiFlyoutBody } from '@elastic/eui'; +import { EuiSpacer, EuiFlyoutBody, EuiPanel } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import type { EntityType } from '@kbn/timelines-plugin/common'; @@ -41,6 +42,12 @@ import { PROMPT_CONTEXTS, } from '../../../../assistant/content/prompt_contexts'; +const FlyoutFooterContainerPanel = styled(EuiPanel)` + .side-panel-flyout-footer { + background-color: transparent; + } +`; + interface EventDetailsPanelProps { browserFields: BrowserFields; entityType?: EntityType; @@ -254,17 +261,19 @@ const EventDetailsPanelComponent: React.FC = ({ <> {header} {body} - + + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx index 59c9aa8e24c06..c712c1b5c8df5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx @@ -6,10 +6,12 @@ */ import { rgba } from 'polished'; -import React, { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { v4 as uuidv4 } from 'uuid'; import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; +import { EuiToolTip, EuiSuperSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; @@ -23,13 +25,16 @@ import { timelineSelectors } from '../../../store/timeline'; import { timelineDefaults } from '../../../store/timeline/defaults'; import * as i18n from './translations'; +import { options } from '../search_or_filter/helpers'; +import type { KqlMode } from '../../../store/timeline/model'; +import { updateKqlMode } from '../../../store/timeline/actions'; interface Props { timelineId: string; } const DropTargetDataProvidersContainer = styled.div` - padding: 2px 0 4px 0; + position: relative; .${IS_DRAGGING_CLASS_NAME} & .drop-target-data-providers { background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.1)}; @@ -49,12 +54,11 @@ const DropTargetDataProviders = styled.div` display: flex; flex-direction: column; justify-content: flex-start; - padding-bottom: 2px; position: relative; border: 0.2rem dashed ${({ theme }) => theme.eui.euiColorMediumShade}; border-radius: 5px; - padding: ${({ theme }) => theme.eui.euiSizeXS} 0; - margin: 2px 0 2px 0; + padding: ${({ theme }) => theme.eui.euiSizeS} 0; + margin: 0px 0 0px 0; max-height: 33vh; min-height: 100px; overflow: auto; @@ -84,7 +88,24 @@ const getDroppableId = (id: string): string => * the user to drop anything with a facet count into * the data pro section. */ + +const timelineSelectModeItemsClassName = 'timelineSelectModeItemsClassName'; + +const searchOrFilterPopoverClassName = 'searchOrFilterPopover'; +const searchOrFilterPopoverWidth = 350; + +const popoverProps = { + className: searchOrFilterPopoverClassName, + panelClassName: searchOrFilterPopoverClassName, + panelMinWidth: searchOrFilterPopoverWidth, +}; + +const CustomTooltipDiv = styled.div` + position: relative; +`; + export const DataProviders = React.memo(({ timelineId }) => { + const dispatch = useDispatch(); const { browserFields } = useSourcererDataView(SourcererScopeName.timeline); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); @@ -96,28 +117,65 @@ export const DataProviders = React.memo(({ timelineId }) => { ); const droppableId = useMemo(() => getDroppableId(timelineId), [timelineId]); + const kqlMode = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).kqlMode + ); + + const handleChange = useCallback( + (mode: KqlMode) => { + dispatch(updateKqlMode({ id: timelineId, kqlMode: mode })); + }, + [timelineId, dispatch] + ); + return ( - - + - {dataProviders != null && dataProviders.length ? ( - - ) : ( - - - - )} - - + + + + + + + + + + + {dataProviders != null && dataProviders.length ? ( + + ) : ( + + + + )} + + + + + ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index 9de471e578363..5d581a505742f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -66,8 +66,10 @@ const getItemStyle = ( const DroppableContainer = styled.div` min-height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px; height: auto !important; + display: none; .${IS_DRAGGING_CLASS_NAME} &:hover { + display: flex; background-color: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important; } `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts index 18ca62a71c6d1..8733e42d24966 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts @@ -169,3 +169,10 @@ export const GROUP_AREA_ARIA_LABEL = (group: number) => values: { group }, defaultMessage: 'You are in group {group}', }); + +export const FILTER_OR_SEARCH_WITH_KQL = i18n.translate( + 'xpack.securitySolution.timeline.searchOrFilter.filterOrSearchWithKql', + { + defaultMessage: 'Filter or Search with KQL', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/index.tsx index c3d3db91430ee..4335e0b806a1b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/index.tsx @@ -39,8 +39,10 @@ const TimelineDatePickerLockComponent = () => { props.theme.eui.euiSizeS}; width: 100%; `; -TimelineHeaderContainer.displayName = 'TimelineHeaderContainer'; +EqlTabHeaderContainer.displayName = 'EqlTabHeaderContainer'; const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` align-items: stretch; @@ -73,8 +73,7 @@ const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` padding: 0; &.euiFlyoutHeader { - ${({ theme }) => - `padding: 0 ${theme.eui.euiSizeM} ${theme.eui.euiSizeS} ${theme.eui.euiSizeS};`} + ${({ theme }) => `padding: ${theme.eui.euiSizeS} 0 0 0;`} } `; @@ -110,6 +109,7 @@ const FullWidthFlexGroup = styled(EuiFlexGroup)` `; const ScrollableFlexItem = styled(EuiFlexItem)` + ${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`} overflow: hidden; `; @@ -260,9 +260,11 @@ export const EqlTabContentComponent: React.FC = ({ hasBorder={false} > {timelineFullScreen && setTimelineFullScreen != null && ( = ({ setFullScreen={setTimelineFullScreen} /> )} - - - - - - {activeTab === TimelineTabs.eql && ( )} + + + + + + - - - + + + - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx deleted file mode 100644 index 56936c1840ba2..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ /dev/null @@ -1,59 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiCallOut } from '@elastic/eui'; -import React from 'react'; -import type { FilterManager } from '@kbn/data-plugin/public'; - -import { DataProviders } from '../data_providers'; -import { StatefulSearchOrFilter } from '../search_or_filter'; - -import * as i18n from './translations'; -import type { TimelineStatusLiteralWithNull } from '../../../../../common/api/timeline'; -import { TimelineStatus } from '../../../../../common/api/timeline'; - -interface Props { - filterManager: FilterManager; - show: boolean; - showCallOutUnauthorizedMsg: boolean; - status: TimelineStatusLiteralWithNull; - timelineId: string; -} - -const TimelineHeaderComponent: React.FC = ({ - filterManager, - show, - showCallOutUnauthorizedMsg, - status, - timelineId, -}) => ( - <> - {showCallOutUnauthorizedMsg && ( - - )} - {status === TimelineStatus.immutable && ( - - )} - {show && } - - - -); - -export const TimelineHeader = React.memo(TimelineHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/timeline_save_prompt.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/timeline_save_prompt.tsx deleted file mode 100644 index fdf3e453c8f65..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/timeline_save_prompt.tsx +++ /dev/null @@ -1,59 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { useUserPrivileges } from '../../../../common/components/user_privileges'; - -import { TimelineId } from '../../../../../common/types/timeline'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { timelineActions } from '../../../store/timeline'; -import { getTimelineSaveModalByIdSelector } from './selectors'; -import { SaveTimelineModal } from './save_timeline_modal'; -import { TimelineStatus } from '../../../../../common/api/timeline'; - -interface TimelineSavePromptProps { - timelineId: string; -} - -/** - * Displays the edit timeline modal with a warning that unsaved changes might get lost. - * The modal is rendered based on a flag that is set in Redux, in other words, this component - * only renders the modal when the flag is triggered from outside this component. - */ -export const TimelineSavePrompt = React.memo(({ timelineId }) => { - const dispatch = useDispatch(); - const getTimelineSaveModal = useMemo(() => getTimelineSaveModalByIdSelector(), []); - const { showSaveModal: forceShow, status } = useDeepEqualSelector((state) => - getTimelineSaveModal(state, timelineId) - ); - const isUnsaved = status === TimelineStatus.draft; - - const closeSaveTimeline = useCallback(() => { - dispatch( - timelineActions.toggleModalSaveTimeline({ - id: TimelineId.active, - showModalSaveTimeline: false, - }) - ); - }, [dispatch]); - - const { - kibanaSecuritySolutionsPrivileges: { crud: hasKibanaCrud }, - } = useUserPrivileges(); - - return forceShow && hasKibanaCrud ? ( - - ) : null; -}); - -TimelineSavePrompt.displayName = 'TimelineSavePrompt'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 7ebd3093d8cfc..a78747e8cb32f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -17,7 +17,7 @@ import { timelineDefaults } from '../../store/timeline/defaults'; import { defaultHeaders } from './body/column_headers/default_headers'; import type { CellValueElementProps } from './cell_rendering'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; +import { FlyoutHeaderPanel } from '../flyout/header'; import type { TimelineId, RowRenderer } from '../../../../common/types/timeline'; import { TimelineType } from '../../../../common/api/timeline'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; @@ -192,19 +192,18 @@ const StatefulTimelineComponent: React.FC = ({ ref={containerElement} > - {timelineType === TimelineType.template && ( - - {i18n.TIMELINE_TEMPLATE} - - )}
+ {timelineType === TimelineType.template && ( + + {i18n.TIMELINE_TEMPLATE} + + )} {resolveConflictComponent} - ({ + useTimelineKpis: jest.fn(), +})); + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('@kbn/i18n-react', () => { + const originalModule = jest.requireActual('@kbn/i18n-react'); + const FormattedRelative = jest.fn().mockImplementation(() => '20 hours ago'); + + return { + ...originalModule, + FormattedRelative, + }; +}); + +const mockUseTimelineKpis: jest.Mock = useTimelineKpis as jest.Mock; + +const mockUseTimelineKpiResponse = { + processCount: 1, + userCount: 1, + sourceIpCount: 1, + hostCount: 1, + destinationIpCount: 1, +}; + +const mockUseTimelineLargeKpiResponse = { + processCount: 1000, + userCount: 1000000, + sourceIpCount: 1000000000, + hostCount: 999, + destinationIpCount: 1, +}; + +describe('Timeline KPIs', () => { + describe('when the data is not loading and the response contains data', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); + }); + it('renders the component, labels and values successfully', () => { + render( + + + + ); + expect(screen.getByTestId('siem-timeline-kpis')).toBeInTheDocument(); + // label + expect(screen.getByText('Processes :')).toBeInTheDocument(); + // value + expect(screen.getByTestId('siem-timeline-process-kpi').textContent).toContain('1'); + }); + }); + + describe('when the response is null and timeline is blank', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, null]); + }); + it('renders labels and the default empty string', () => { + render( + + + + ); + expect(screen.getByText('Processes :')).toBeInTheDocument(); + expect(screen.getAllByText(getEmptyValue())).not.toHaveLength(0); + }); + }); + + describe('when the response contains numbers larger than one thousand', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + }); + it('formats the numbers correctly', () => { + render( + + + + ); + expect(screen.getByTitle('1k')).toBeInTheDocument(); + expect(screen.getByTitle('1m')).toBeInTheDocument(); + expect(screen.getByTitle('1b')).toBeInTheDocument(); + expect(screen.getByTitle('999')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/index.tsx new file mode 100644 index 0000000000000..a0ebf4d5408f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/index.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { TimelineKpisContainer as TimelineKpi } from './kpi_container'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/kpi_container.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/kpi_container.tsx new file mode 100644 index 0000000000000..31eb9ad5e5e53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/kpi_container.tsx @@ -0,0 +1,113 @@ +/* + * 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 { isEmpty, pick } from 'lodash/fp'; +import { useSelector } from 'react-redux'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; +import type { TimerangeInput } from '@kbn/timelines-plugin/common'; +import { EuiPanel } from '@elastic/eui'; +import { TimelineId } from '../../../../../common/types'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import type { State } from '../../../../common/store'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { TimelineKPIs } from './kpis'; +import { useTimelineKpis } from '../../../containers/kpis'; +import { useKibana } from '../../../../common/lib/kibana'; +import { timelineSelectors } from '../../../store/timeline'; +import { timelineDefaults } from '../../../store/timeline/defaults'; +import { combineQueries } from '../../../../common/lib/kuery'; +import { + endSelector, + startSelector, +} from '../../../../common/components/super_date_picker/selectors'; + +interface KpiExpandedProps { + timelineId: string; +} + +export const TimelineKpisContainer = ({ timelineId }: KpiExpandedProps) => { + const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView( + SourcererScopeName.timeline + ); + + const { uiSettings } = useKibana().services; + const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { dataProviders, filters, kqlMode } = useDeepEqualSelector((state) => + pick( + ['dataProviders', 'filters', 'kqlMode'], + getTimeline(state, timelineId) ?? timelineDefaults + ) + ); + + const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []); + + const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)); + + const kqlQueryExpression = kqlQueryTimeline ?? ' '; + + const kqlQuery = useMemo( + () => ({ query: kqlQueryExpression, language: 'kuery' }), + [kqlQueryExpression] + ); + + const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]); + const getStartSelector = useMemo(() => startSelector(), []); + const getEndSelector = useMemo(() => endSelector(), []); + + const timerange: TimerangeInput = useDeepEqualSelector((state) => { + if (isActive) { + return { + from: getStartSelector(state.inputs.timeline), + to: getEndSelector(state.inputs.timeline), + interval: '', + }; + } else { + return { + from: getStartSelector(state.inputs.global), + to: getEndSelector(state.inputs.global), + interval: '', + }; + } + }); + + const combinedQueries = useMemo( + () => + combineQueries({ + config: esQueryConfig, + dataProviders, + indexPattern, + browserFields, + filters: filters ? filters : [], + kqlQuery, + kqlMode, + }), + [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery] + ); + + const isBlankTimeline: boolean = useMemo( + () => + (isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query)) || + combinedQueries?.filterQuery === undefined, + [dataProviders, filters, kqlQuery, combinedQueries] + ); + + const [, kpis] = useTimelineKpis({ + defaultIndex: selectedPatterns, + timerange, + isBlankTimeline, + filterQuery: combinedQueries?.filterQuery ?? '', + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/kpis.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/kpis.tsx new file mode 100644 index 0000000000000..9fde146b4ed4d --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/kpis.tsx @@ -0,0 +1,114 @@ +/* + * 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 styled from 'styled-components'; + +import { EuiFlexItem, EuiFlexGroup, EuiToolTip, EuiBadge } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; +import { useUiSetting$ } from '../../../../common/lib/kibana'; +import type { TimelineKpiStrategyResponse } from '../../../../../common/search_strategy'; +import { getEmptyValue } from '../../../../common/components/empty_value'; +import * as i18n from './translations'; + +export const StatsContainer = styled.span` + font-size: ${euiThemeVars.euiFontSizeXS}; + font-weight: ${euiThemeVars.euiFontWeightSemiBold}; + padding-right: 16px; + .smallDot { + width: 3px !important; + display: inline-block; + } + .euiBadge__text { + text-align: center; + width: 100%; + } +`; + +export const TimelineKPIs = React.memo(({ kpis }: { kpis: TimelineKpiStrategyResponse | null }) => { + const kpiFormat = '0,0.[000]a'; + const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const formattedKpis = useMemo(() => { + return { + process: kpis === null ? getEmptyValue() : numeral(kpis.processCount).format(kpiFormat), + user: kpis === null ? getEmptyValue() : numeral(kpis.userCount).format(kpiFormat), + host: kpis === null ? getEmptyValue() : numeral(kpis.hostCount).format(kpiFormat), + sourceIp: kpis === null ? getEmptyValue() : numeral(kpis.sourceIpCount).format(kpiFormat), + destinationIp: + kpis === null ? getEmptyValue() : numeral(kpis.destinationIpCount).format(kpiFormat), + }; + }, [kpis]); + + const formattedKpiToolTips = useMemo(() => { + return { + process: numeral(kpis?.processCount).format(defaultNumberFormat), + user: numeral(kpis?.userCount).format(defaultNumberFormat), + host: numeral(kpis?.hostCount).format(defaultNumberFormat), + sourceIp: numeral(kpis?.sourceIpCount).format(defaultNumberFormat), + destinationIp: numeral(kpis?.destinationIpCount).format(defaultNumberFormat), + }; + }, [kpis, defaultNumberFormat]); + + return ( + + + + {`${i18n.PROCESS_KPI_TITLE} : `} + + + {formattedKpis.process} + + + + + + + {`${i18n.USER_KPI_TITLE} : `} + + + {formattedKpis.user} + + + + + + + {`${i18n.HOST_KPI_TITLE} : `} + + + {formattedKpis.host} + + + + + + + {`${i18n.SOURCE_IP_KPI_TITLE} : `} + + + {formattedKpis.sourceIp} + + + + + + + {`${i18n.DESTINATION_IP_KPI_TITLE} : `} + + + {formattedKpis.destinationIp} + + + + + + ); +}); + +TimelineKPIs.displayName = 'TimelineKPIs'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/translations.ts new file mode 100644 index 0000000000000..177516ea4a689 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/kpi/translations.ts @@ -0,0 +1,37 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const PROCESS_KPI_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.kpis.processKpiTitle', + { + defaultMessage: 'Processes', + } +); + +export const HOST_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.hostKpiTitle', { + defaultMessage: 'Hosts', +}); + +export const SOURCE_IP_KPI_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.kpis.sourceIpKpiTitle', + { + defaultMessage: 'Source IPs', + } +); + +export const DESTINATION_IP_KPI_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.kpis.destinationKpiTitle', + { + defaultMessage: 'Destination IPs', + } +); + +export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.userKpiTitle', { + defaultMessage: 'Users', +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index d43f92fc96e69..963713a2317a6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -21,6 +21,7 @@ import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import type { EuiTheme } from '@kbn/react-kibana-context-styled'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineActions } from '../../../store/timeline'; @@ -51,6 +52,8 @@ const FullWidthFlexGroup = styled(EuiFlexGroup)` const ScrollableFlexItem = styled(EuiFlexItem)` overflow-x: hidden; overflow-y: auto; + padding-inline: ${({ theme }) => (theme as EuiTheme).eui.euiSizeM}; + padding-block: ${({ theme }) => (theme as EuiTheme).eui.euiSizeS}; `; const VerticalRule = styled.div` @@ -202,7 +205,6 @@ const NotesTabContentComponent: React.FC = ({ timelineId } <> {createdBy && ( <> -

{CREATED_BY}

@@ -218,13 +220,12 @@ const NotesTabContentComponent: React.FC = ({ timelineId } ); return ( - + - +

{NOTES}

- { const mockGetButton = jest.fn().mockReturnValue('<>'); const props: NewTimelineProps = { - closeGearMenu: jest.fn(), + onClick: jest.fn(), timelineId: 'mockTimelineId', title: 'mockTitle', }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 1ac4947b5adac..0330e3ed74d57 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -27,9 +27,13 @@ NotesCountBadge.displayName = 'NotesCountBadge'; interface AddToFavoritesButtonProps { timelineId: string; + compact?: boolean; } -const AddToFavoritesButtonComponent: React.FC = ({ timelineId }) => { +const AddToFavoritesButtonComponent: React.FC = ({ + timelineId, + compact, +}) => { const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); @@ -48,7 +52,19 @@ const AddToFavoritesButtonComponent: React.FC = ({ ti [dispatch, timelineId, isFavorite] ); - return ( + const label = isFavorite ? i18n.REMOVE_FROM_FAVORITES : i18n.ADD_TO_FAVORITES; + + return compact ? ( + + ) : ( = ({ ti onClick={handleClick} data-test-subj={`timeline-favorite-${isFavorite ? 'filled' : 'empty'}-star`} disabled={disableFavoriteButton} + aria-label={label} + title={label} > - {isFavorite ? i18n.REMOVE_FROM_FAVORITES : i18n.ADD_TO_FAVORITES} + {label} ); }; @@ -66,18 +84,18 @@ AddToFavoritesButtonComponent.displayName = 'AddToFavoritesButtonComponent'; export const AddToFavoritesButton = React.memo(AddToFavoritesButtonComponent); export interface NewTimelineProps { - closeGearMenu?: () => void; + onClick?: () => void; outline?: boolean; timelineId: string; title?: string; } export const NewTimeline = React.memo( - ({ closeGearMenu, outline = false, timelineId, title = i18n.NEW_TIMELINE }) => { + ({ onClick, outline = false, timelineId, title = i18n.NEW_TIMELINE }) => { const { getButton } = useCreateTimelineButton({ timelineId, timelineType: TimelineType.default, - closeGearMenu, + onClick, }); const button = getButton({ outline, title }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx index ac69b86ec5803..10ad06c17d5bd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx @@ -55,7 +55,7 @@ describe('NewTemplateTimeline', () => { wrapper = mount( - + ); }); @@ -92,7 +92,7 @@ describe('NewTemplateTimeline', () => { wrapper = mount( - + ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx index 4cdef0f843dfe..3d79fdbf031bd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx @@ -13,14 +13,14 @@ import { TimelineType } from '../../../../../common/api/timeline'; import { useCreateTimelineButton } from './use_create_timeline'; interface OwnProps { - closeGearMenu?: () => void; + onClick?: () => void; outline?: boolean; title?: string; timelineId?: string; } export const NewTemplateTimelineComponent: React.FC = ({ - closeGearMenu, + onClick, outline, title, timelineId = TimelineId.active, @@ -28,7 +28,7 @@ export const NewTemplateTimelineComponent: React.FC = ({ const { getButton } = useCreateTimelineButton({ timelineId, timelineType: TimelineType.template, - closeGearMenu, + onClick, }); const button = getButton({ outline, title }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index 5fc94a2bf99c7..0693901bf2114 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -27,7 +27,7 @@ import { useDiscoverInTimelineContext } from '../../../../common/components/disc interface Props { timelineId?: string; timelineType: TimelineTypeLiteral; - closeGearMenu?: () => void; + onClick?: () => void; timeRange?: TimeRange; } @@ -35,7 +35,7 @@ interface Props { * Creates a new empty timeline at the given id. * Can be used to create new timelines or to reset timeline state. */ -export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: Props) => { +export const useCreateTimeline = ({ timelineId, timelineType, onClick }: Props) => { const dispatch = useDispatch(); const defaultDataViewSelector = useMemo(() => sourcererSelectors.defaultDataViewSelector(), []); const { id: dataViewId, patternList: selectedPatterns } = @@ -109,12 +109,12 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P const handleCreateNewTimeline = useCallback( (options?: CreateNewTimelineOptions) => { createTimeline({ id: timelineId, show: true, timelineType, timeRange: options?.timeRange }); - if (typeof closeGearMenu === 'function') { - closeGearMenu(); + if (typeof onClick === 'function') { + onClick(); } resetDiscoverAppState(); }, - [createTimeline, timelineId, timelineType, closeGearMenu, resetDiscoverAppState] + [createTimeline, timelineId, timelineType, onClick, resetDiscoverAppState] ); return handleCreateNewTimeline; @@ -124,11 +124,11 @@ interface CreateNewTimelineOptions { timeRange?: TimeRange; } -export const useCreateTimelineButton = ({ timelineId, timelineType, closeGearMenu }: Props) => { +export const useCreateTimelineButton = ({ timelineId, timelineType, onClick }: Props) => { const handleCreateNewTimeline = useCreateTimeline({ timelineId, timelineType, - closeGearMenu, + onClick, }); const getButton = useCallback( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx index 4abc4919721b6..e1e732b32e479 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx @@ -14,6 +14,7 @@ import deepEqual from 'fast-deep-equal'; import type { Filter, Query } from '@kbn/es-query'; import { FilterStateStore } from '@kbn/es-query'; import type { FilterManager, SavedQuery, SavedQueryTimeFilter } from '@kbn/data-plugin/public'; +import styled from '@emotion/styled'; import { InputsModelId } from '../../../../common/store/inputs/constants'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; @@ -47,6 +48,42 @@ export interface QueryBarTimelineComponentProps { updateReduxTime: DispatchUpdateReduxTime; } +const SearchBarContainer = styled.div` + /* + * + * hide search bar default filters as they are disturbing the layout as shown below + * + * Filters are displayed with QueryBar so below is how is the layout with default filters. + * + * + * -------------------------------- + * -----------------| |------------ + * | DataViewPicker | QueryBar | Date | + * ------------------------------------------------------------- + * | Filters | + * -------------------------------- + * + * The tree under this component makes sure that default filters are not rendered and we can separately display + * them outside query component so that layout is as below: + * + * ----------------------------------------------------------- + * | DataViewPicker | QueryBar | Date | + * ----------------------------------------------------------- + * | Filters | + * ----------------------------------------------------------- + * + * */ + .uniSearchBar .filter-items-group { + display: none; + } + + .euiDataGrid__restrictBody & { + .kbnQueryBar { + display: flex; + } + } +`; + export const TIMELINE_FILTER_DROP_AREA = 'timeline-filter-drop-area'; const getNonDropAreaFilters = (filters: Filter[] = []) => @@ -265,22 +302,24 @@ export const QueryBarTimeline = memo( ); return ( - + + + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/index.test.tsx similarity index 83% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/index.test.tsx index 8ce39f9b535d5..44d2ee6cd6a54 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/index.test.tsx @@ -5,23 +5,23 @@ * 2.0. */ -import { shallow } from 'enzyme'; import React from 'react'; import { coreMock } from '@kbn/core/public/mocks'; -import { mockIndexPattern } from '../../../../common/mock'; -import { TestProviders } from '../../../../common/mock/test_providers'; +import { mockIndexPattern } from '../../../../../common/mock'; +import { TestProviders } from '../../../../../common/mock/test_providers'; import { FilterManager } from '@kbn/data-plugin/public'; -import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; -import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { mockDataProviders } from '../../data_providers/mock/mock_data_providers'; +import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; -import { TimelineHeader } from '.'; -import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; +import { QueryTabHeader } from '.'; +import { TimelineStatus, TimelineType } from '../../../../../../common/api/timeline'; import { waitFor } from '@testing-library/react'; +import { TimelineId } from '../../../../../../common/types'; const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../../common/lib/kibana'); describe('Header', () => { const indexPattern = mockIndexPattern; @@ -44,21 +44,16 @@ describe('Header', () => { show: true, showCallOutUnauthorizedMsg: false, status: TimelineStatus.active, - timelineId: 'foo', + timelineId: TimelineId.test, timelineType: TimelineType.default, }; describe('rendering', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - test('it renders the data providers when show is true', async () => { const testProps = { ...props, show: true }; const wrapper = await getWrapper( - + ); @@ -74,7 +69,7 @@ describe('Header', () => { const wrapper = await getWrapper( - + ); @@ -90,7 +85,7 @@ describe('Header', () => { const wrapper = await getWrapper( - + ); @@ -108,7 +103,7 @@ describe('Header', () => { const wrapper = await getWrapper( - + ); @@ -129,7 +124,7 @@ describe('Header', () => { const wrapper = await getWrapper( - + ); @@ -146,7 +141,7 @@ describe('Header', () => { const wrapper = await getWrapper( - + ); @@ -165,7 +160,7 @@ describe('Header', () => { const wrapper = await getWrapper( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/index.tsx new file mode 100644 index 0000000000000..8d70c3e4d44c3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/index.tsx @@ -0,0 +1,115 @@ +/* + * 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 { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import type { FilterManager } from '@kbn/data-plugin/public'; + +import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; +import styled from '@emotion/styled'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import type { TimelineStatusLiteralWithNull } from '../../../../../../common/api/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../../common/api/timeline'; +import { timelineSelectors } from '../../../../store/timeline'; +import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { timelineDefaults } from '../../../../store/timeline/defaults'; +import * as i18n from './translations'; +import { StatefulSearchOrFilter } from '../../search_or_filter'; +import { DataProviders } from '../../data_providers'; + +interface Props { + filterManager: FilterManager; + show: boolean; + showCallOutUnauthorizedMsg: boolean; + status: TimelineStatusLiteralWithNull; + timelineId: string; +} + +const DataProvidersContainer = styled.div<{ $shouldShowQueryBuilder: boolean }>` + position: relative; + width: 100%; + transition: 0.5s ease-in-out; + overflow: hidden; + + ${(props) => + props.$shouldShowQueryBuilder + ? `display: block; max-height: 300px; visibility: visible; margin-block-start: 0px;` + : `display: block; max-height: 0px; visibility: hidden; margin-block-start:-${euiThemeVars.euiSizeS};`} + + .${IS_DRAGGING_CLASS_NAME} & { + display: block; + max-height: 300px; + visibility: visible; + margin-block-start: 0px; + } +`; + +const QueryTabHeaderComponent: React.FC = ({ + filterManager, + show, + showCallOutUnauthorizedMsg, + status, + timelineId, +}) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + + const getIsDataProviderVisible = useMemo( + () => timelineSelectors.dataProviderVisibilitySelector(), + [] + ); + + const timelineType = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).timelineType + ); + + const isDataProviderVisible = useDeepEqualSelector( + (state) => getIsDataProviderVisible(state, timelineId) ?? timelineDefaults.isDataProviderVisible + ); + + const shouldShowQueryBuilder = isDataProviderVisible || timelineType === TimelineType.template; + + return ( + + + + + {showCallOutUnauthorizedMsg && ( + + + + )} + {status === TimelineStatus.immutable && ( + + + + )} + {show ? ( + + + + ) : null} + + ); +}; + +export const QueryTabHeader = React.memo(QueryTabHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/selectors.ts similarity index 88% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts rename to x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/selectors.ts index 65deef3399863..02668c18b308c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/selectors.ts @@ -7,7 +7,7 @@ import { createSelector } from 'reselect'; -import { timelineSelectors } from '../../../store/timeline'; +import { timelineSelectors } from '../../../../store/timeline'; export const getTimelineSaveModalByIdSelector = () => createSelector(timelineSelectors.selectTimeline, (timeline) => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/translations.ts new file mode 100644 index 0000000000000..f90c46d69d230 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/header/translations.ts @@ -0,0 +1,24 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate( + 'xpack.securitySolution.timeline.callOut.unauthorized.message.description', + { + defaultMessage: + 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.', + } +); + +export const CALL_OUT_IMMUTABLE = i18n.translate( + 'xpack.securitySolution.timeline.callOut.immutable.message.description', + { + defaultMessage: + 'This prebuilt timeline template cannot be modified. To make changes, please duplicate this template and make modifications to the duplicate template.', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index a5d941ca6456e..f20283f2c3c94 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -35,7 +35,7 @@ import { useKibana } from '../../../../common/lib/kibana'; import { defaultHeaders } from '../body/column_headers/default_headers'; import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; -import { TimelineHeader } from '../header'; +import { QueryTabHeader } from './header'; import { calculateTotalPages } from '../helpers'; import { combineQueries } from '../../../../common/lib/kuery'; import { TimelineRefetch } from '../refetch_timeline'; @@ -46,7 +46,6 @@ import type { } from '../../../../../common/types/timeline'; import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; -import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; import type { inputsModel, State } from '../../../../common/store'; import { inputsSelectors } from '../../../../common/store'; @@ -55,22 +54,19 @@ import { timelineDefaults } from '../../../store/timeline/defaults'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; import type { TimelineModel } from '../../../store/timeline/model'; -import { TimelineDatePickerLock } from '../date_picker_lock'; import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { activeTimeline } from '../../../containers/active_timeline_context'; import { DetailsPanel } from '../../side_panel'; import { ExitFullScreen } from '../../../../common/components/exit_full_screen'; import { getDefaultControlColumn } from '../body/control_columns'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { Sourcerer } from '../../../../common/components/sourcerer'; import { useLicense } from '../../../../common/hooks/use_license'; import { HeaderActions } from '../../../../common/components/header_actions/header_actions'; -const TimelineHeaderContainer = styled.div` - margin-top: 6px; +const QueryTabHeaderContainer = styled.div` width: 100%; `; -TimelineHeaderContainer.displayName = 'TimelineHeaderContainer'; +QueryTabHeaderContainer.displayName = 'TimelineHeaderContainer'; const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` align-items: stretch; @@ -79,8 +75,7 @@ const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` flex-direction: column; &.euiFlyoutHeader { - ${({ theme }) => - `padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS};`} + ${({ theme }) => `padding: ${theme.eui.euiSizeS} 0 0 0;`} } `; @@ -320,10 +315,6 @@ export const QueryTabContentComponent: React.FC = ({ ); }, [loadingSourcerer, timelineId, isQueryLoading, dispatch]); - const isDatePickerDisabled = useMemo(() => { - return (combinedQueries && combinedQueries.kqlError != null) || false; - }, [combinedQueries]); - const leadingControlColumns = useMemo( () => getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({ @@ -352,45 +343,35 @@ export const QueryTabContentComponent: React.FC = ({ data-test-subj={`${activeTab}-tab-flyout-header`} hasBorder={false} > - + {timelineFullScreen && setTimelineFullScreen != null && ( - + + + + + )} - - + + + + - - - - - {activeTab === TimelineTabs.query && ( - - )} - + {/* TODO: This is a temporary solution to hide the KPIs until lens components play nicely with timelines */} + {/* https://github.com/elastic/kibana/issues/17156 */} + {/* */} + {/* */} + {/* */} - - - - + obj != null && typeof obj === 'object' && Object.hasOwn(obj, 'getName'); + const StatefulSearchOrFilterComponent = React.memo( ({ dataProviders, @@ -48,7 +64,59 @@ const StatefulSearchOrFilterComponent = React.memo( toStr, updateKqlMode, updateReduxTime, + timelineType, }) => { + const dispatch = useDispatch(); + + const { addError } = useAppToasts(); + + const [dataView, setDataView] = useState(); + const { + services: { data }, + } = useKibana(); + + const { indexPattern } = useSourcererDataView(SourcererScopeName.timeline); + + const getIsDataProviderVisible = useMemo( + () => timelineSelectors.dataProviderVisibilitySelector(), + [] + ); + + const isDataProviderVisible = useDeepEqualSelector((state) => + getIsDataProviderVisible(state, timelineId) + ); + + useEffect(() => { + let dv: DataView; + if (isDataView(indexPattern)) { + setDataView(indexPattern); + } else if (!filterQuery) { + const createDataView = async () => { + try { + dv = await data.dataViews.create({ title: indexPattern.title }); + setDataView(dv); + } catch (error) { + addError(error, { title: i18n.ERROR_PROCESSING_INDEX_PATTERNS }); + } + }; + createDataView(); + } + return () => { + if (dv?.id) { + data.dataViews.clearInstanceCache(dv?.id); + } + }; + }, [data.dataViews, indexPattern, filterQuery, addError]); + + const arrDataView = useMemo(() => (dataView != null ? [dataView] : []), [dataView]); + + const onFiltersUpdated = useCallback( + (newFilters: Filter[]) => { + filterManager.setFilters(newFilters); + }, + [filterManager] + ); + const setFiltersInTimeline = useCallback( (newFilters: Filter[]) => setFilters({ @@ -67,26 +135,83 @@ const StatefulSearchOrFilterComponent = React.memo( [timelineId, setSavedQueryId] ); + const toggleDataProviderVisibility = useCallback(() => { + dispatch( + setDataProviderVisibility({ id: timelineId, isDataProviderVisible: !isDataProviderVisible }) + ); + }, [isDataProviderVisible, timelineId, dispatch]); + + useEffect(() => { + /* + * If there is a change in data providers + * - data provider has some data and it was hidden, + * * it must be made visible + * + * - data provider has no data and it was visible, + * * it must be hidden + * + * */ + if (dataProviders?.length > 0) { + dispatch(setDataProviderVisibility({ id: timelineId, isDataProviderVisible: true })); + } else if (dataProviders?.length === 0) { + dispatch(setDataProviderVisibility({ id: timelineId, isDataProviderVisible: false })); + } + }, [dataProviders, dispatch, timelineId]); + return ( - + + + + + + + + + {filters && filters.length > 0 ? ( + + + + + + ) : null} + ); }, (prevProps, nextProps) => { @@ -104,7 +229,8 @@ const StatefulSearchOrFilterComponent = React.memo( deepEqual(prevProps.filterQuery, nextProps.filterQuery) && deepEqual(prevProps.kqlMode, nextProps.kqlMode) && deepEqual(prevProps.savedQueryId, nextProps.savedQueryId) && - deepEqual(prevProps.timelineId, nextProps.timelineId) + deepEqual(prevProps.timelineId, nextProps.timelineId) && + prevProps.timelineType === nextProps.timelineType ); } ); @@ -135,8 +261,10 @@ const makeMapStateToProps = () => { to: input.timerange.to, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion toStr: input.timerange.toStr!, + timelineType: timeline.timelineType, }; }; + return mapStateToProps; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx index 5ddfc4cd49623..d99c38057297f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx @@ -5,40 +5,29 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import React, { useCallback } from 'react'; -import styled, { createGlobalStyle } from 'styled-components'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; import type { Filter } from '@kbn/es-query'; import type { FilterManager } from '@kbn/data-plugin/public'; +import { TimelineType } from '../../../../../common/api/timeline'; +import { InputsModelId } from '../../../../common/store/inputs/constants'; import type { KqlMode } from '../../../store/timeline/model'; import type { DispatchUpdateReduxTime } from '../../../../common/components/super_date_picker'; +import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import type { KueryFilterQuery } from '../../../../../common/types/timeline'; import type { DataProvider } from '../data_providers/data_provider'; import { QueryBarTimeline } from '../query_bar'; -import { EuiSuperSelect } from './super_select'; -import { options } from './helpers'; -import * as i18n from './translations'; - -const timelineSelectModeItemsClassName = 'timelineSelectModeItemsClassName'; -const searchOrFilterPopoverClassName = 'searchOrFilterPopover'; -const searchOrFilterPopoverWidth = '352px'; - -// SIDE EFFECT: the following creates a global class selector -const SearchOrFilterGlobalStyle = createGlobalStyle` - .${timelineSelectModeItemsClassName} { - width: 350px !important; - } - - .${searchOrFilterPopoverClassName}.euiPopover__panel { - width: ${searchOrFilterPopoverWidth} !important; - - .euiSuperSelect__listbox { - width: ${searchOrFilterPopoverWidth} !important; - } - } -`; +import { TimelineDatePickerLock } from '../date_picker_lock'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { Sourcerer } from '../../../../common/components/sourcerer'; +import { + DATA_PROVIDER_HIDDEN_EMPTY, + DATA_PROVIDER_HIDDEN_POPULATED, + DATA_PROVIDER_VISIBLE, +} from './translations'; interface Props { dataProviders: DataProvider[]; @@ -58,11 +47,14 @@ interface Props { to: string; toStr: string; updateReduxTime: DispatchUpdateReduxTime; + isDataProviderVisible: boolean; + toggleDataProviderVisibility: () => void; + timelineType: TimelineType; } const SearchOrFilterContainer = styled.div` - ${({ theme }) => `margin-top: ${theme.eui.euiSizeXS};`} - user-select: none; // This should not be here, it makes the entire page inaccessible + overflow-x: auto; + overflow-y: hidden; `; SearchOrFilterContainer.displayName = 'SearchOrFilterContainer'; @@ -90,33 +82,41 @@ export const SearchOrFilter = React.memo( setSavedQueryId, to, toStr, - updateKqlMode, updateReduxTime, + isDataProviderVisible, + toggleDataProviderVisibility, + timelineType, }) => { - const handleChange = useCallback( - (mode: KqlMode) => updateKqlMode({ id: timelineId, kqlMode: mode }), - [timelineId, updateKqlMode] + const isDataProviderEmpty = useMemo(() => dataProviders?.length === 0, [dataProviders]); + + const dataProviderIconTooltipContent = useMemo(() => { + if (isDataProviderVisible) { + return DATA_PROVIDER_VISIBLE; + } + if (isDataProviderEmpty) { + return DATA_PROVIDER_HIDDEN_EMPTY; + } + return DATA_PROVIDER_HIDDEN_POPULATED; + }, [isDataProviderEmpty, isDataProviderVisible]); + + const buttonColor = useMemo( + () => (isDataProviderEmpty || isDataProviderVisible ? 'primary' : 'warning'), + [isDataProviderEmpty, isDataProviderVisible] ); return ( <> - - - - - - - + + + + + ( updateReduxTime={updateReduxTime} /> + { + /* + DataProvider toggle is not needed in template timeline because + it is always visible + */ + timelineType === TimelineType.default ? ( + + + + + + ) : null + } + + + + + + + - ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts index 560a82a329684..f3577e350dc94 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts @@ -64,9 +64,30 @@ export const SEARCH_KQL_SELECTED_TEXT = i18n.translate( } ); -export const FILTER_OR_SEARCH_WITH_KQL = i18n.translate( - 'xpack.securitySolution.timeline.searchOrFilter.filterOrSearchWithKql', +export const DATA_PROVIDER_HIDDEN_POPULATED = i18n.translate( + 'xpack.securitySolution.timeline.searchOrFilter.dataProviderToggle.hiddenAndPopulated', { - defaultMessage: 'Filter or Search with KQL', + defaultMessage: 'Query Builder is hidden. Click here to see the existing Queries', + } +); + +export const DATA_PROVIDER_VISIBLE = i18n.translate( + 'xpack.securitySolution.timeline.searchOrFilter.dataProviderToggle.visible', + { + defaultMessage: 'Click here to hide Query builder', + } +); + +export const DATA_PROVIDER_HIDDEN_EMPTY = i18n.translate( + 'xpack.securitySolution.timeline.searchOrFilter.dataProviderToggle.hiddenAndEmpty', + { + defaultMessage: 'Click here to show the empty Query builder', + } +); + +export const ERROR_PROCESSING_INDEX_PATTERNS = i18n.translate( + 'xpack.securitySolution.timeline.searchOrFilter.errorProcessingDataView', + { + defaultMessage: 'Error processing Index Patterns', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index b62e36e20b938..3f65e542ed30c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -276,6 +276,10 @@ const StyledEuiTab = styled(EuiTab)` } `; +const StyledEuiTabs = styled(EuiTabs)` + padding-inline: ${(props) => props.theme.eui.euiSizeM}; +`; + const TabsContentComponent: React.FC = ({ renderCellValue, rowRenderers, @@ -389,7 +393,7 @@ const TabsContentComponent: React.FC = ({ return ( <> {!timelineFullScreen && ( - + = ({ {i18n.SECURITY_ASSISTANT} )} - + )} ('SET_IS_DISCOVER_SAVED_SEARCH_LOADED'); +export const setDataProviderVisibility = actionCreator<{ + id: string; + isDataProviderVisible: boolean; +}>('SET_DATA_PROVIDER_VISIBLITY'); + export const setChanged = actionCreator<{ id: string; changed: boolean }>('SET_CHANGED'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index 449a2aa2b13f4..e1c01f226ca78 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -80,6 +80,7 @@ export const timelineDefaults: SubsetTimelineModel & filters: [], savedSearchId: null, isDiscoverSavedSearchLoaded: false, + isDataProviderVisible: false, }; export const getTimelineManageDefaults = (id: string) => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts index 21551dacebc66..13941635e5d34 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts @@ -176,6 +176,7 @@ describe('Epic Timeline', () => { id: '11169110-fc22-11e9-8ca9-072f15ce2685', savedQueryId: 'my endgame timeline query', savedSearchId: null, + isDataProviderVisible: true, }; expect( diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.test.ts similarity index 99% rename from x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts rename to x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.test.ts index 980573f0c73c3..f0d553ce75246 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.test.ts @@ -138,6 +138,7 @@ const basicTimeline: TimelineModel = { title: '', version: null, savedSearchId: null, + isDataProviderVisible: true, }; const timelineByIdMock: TimelineById = { foo: { ...basicTimeline }, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index 81a19dde1cea9..a71d20e1b7688 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -136,6 +136,7 @@ export interface TimelineModel { /* discover saved search Id */ savedSearchId: string | null; isDiscoverSavedSearchLoaded?: boolean; + isDataProviderVisible: boolean; /** used to mark the timeline as unsaved in the UI */ changed?: boolean; } @@ -193,6 +194,7 @@ export type SubsetTimelineModel = Readonly< | 'filterManager' | 'savedSearchId' | 'isDiscoverSavedSearchLoaded' + | 'isDataProviderVisible' | 'changed' > >; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 7da0d41fe8955..4bf2b3a2b41a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -59,6 +59,7 @@ import { clearEventsLoading, updateSavedSearchId, setIsDiscoverSavedSearchLoaded, + setDataProviderVisibility, setChanged, } from './actions'; @@ -530,6 +531,18 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) + .case(setDataProviderVisibility, (state, { id, isDataProviderVisible }) => { + return { + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + isDataProviderVisible, + }, + }, + }; + }) .case(setChanged, (state, { id, changed }) => ({ ...state, timelineById: { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts index a62af5f4f5897..d4078373aaff1 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts @@ -54,3 +54,6 @@ export const getKqlFilterKuerySelector = () => ? timeline.kqlQuery.filterQuery.kuery : null ); + +export const dataProviderVisibilitySelector = () => + createSelector(selectTimeline, (timeline) => timeline.isDataProviderVisible); diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 4b762590b2274..2c3ce5d91607e 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -15,7 +15,11 @@ "public/**/*.json", "../../../typings/**/*" ], - "exclude": ["target/**/*", "**/cypress/**", "public/management/cypress.config.ts"], + "exclude": [ + "target/**/*", + "**/cypress/**", + "public/management/cypress.config.ts" + ], "kbn_references": [ "@kbn/core", { @@ -174,6 +178,7 @@ "@kbn/openapi-generator", "@kbn/es", "@kbn/react-kibana-mount", + "@kbn/react-kibana-context-styled", "@kbn/unified-doc-viewer-plugin", "@kbn/shared-ux-error-boundary", "@kbn/zod-helpers", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 3c397f2534c52..2357400d6fa43 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -36677,7 +36677,7 @@ "xpack.securitySolution.timeline.saveTimeline.modal.header": "Enregistrer la chronologie", "xpack.securitySolution.timeline.saveTimeline.modal.optionalLabel": "Facultatif", "xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel": "Titre", - "xpack.securitySolution.timeline.saveTimeline.modal.titleTitle": "Titre", + "xpack.securitySolution.timeline.saveTimeline.modal.title": "Titre", "xpack.securitySolution.timeline.saveTimelineTemplate.modal.discard.title": "Abandonner le modèle de chronologie", "xpack.securitySolution.timeline.saveTimelineTemplate.modal.header": "Enregistrer le modèle de chronologie", "xpack.securitySolution.timeline.searchOrFilter.filterDescription": "Les événements des fournisseurs de données ci-dessus sont filtrés par le KQL adjacent", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e22bf55f87565..ce5994c16f8a4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -36675,7 +36675,7 @@ "xpack.securitySolution.timeline.saveTimeline.modal.header": "タイムラインを保存", "xpack.securitySolution.timeline.saveTimeline.modal.optionalLabel": "オプション", "xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel": "タイトル", - "xpack.securitySolution.timeline.saveTimeline.modal.titleTitle": "タイトル", + "xpack.securitySolution.timeline.saveTimeline.modal.title": "タイトル", "xpack.securitySolution.timeline.saveTimelineTemplate.modal.discard.title": "タイムラインテンプレートを破棄", "xpack.securitySolution.timeline.saveTimelineTemplate.modal.header": "タイムラインテンプレートを保存", "xpack.securitySolution.timeline.searchOrFilter.filterDescription": "上のデータプロバイダーからのイベントは、隣接の KQL でフィルターされます", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6b549a4964f2a..b9fccad8e482e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -36671,7 +36671,7 @@ "xpack.securitySolution.timeline.saveTimeline.modal.header": "保存时间线", "xpack.securitySolution.timeline.saveTimeline.modal.optionalLabel": "可选", "xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel": "标题", - "xpack.securitySolution.timeline.saveTimeline.modal.titleTitle": "标题", + "xpack.securitySolution.timeline.saveTimeline.modal.title": "标题", "xpack.securitySolution.timeline.saveTimelineTemplate.modal.discard.title": "丢弃时间线模板", "xpack.securitySolution.timeline.saveTimelineTemplate.modal.header": "保存时间线模板", "xpack.securitySolution.timeline.searchOrFilter.filterDescription": "上述数据提供程序的事件按相邻 KQL 进行筛选", diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts index ef24d84ee7624..45996eb53b4b5 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts @@ -34,7 +34,7 @@ import { CASES_METRIC, UNEXPECTED_METRICS, } from '../../../screens/case_details'; -import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../../../screens/timeline'; +import { TIMELINE_QUERY, TIMELINE_TITLE } from '../../../screens/timeline'; import { OVERVIEW_CASE_DESCRIPTION, OVERVIEW_CASE_NAME } from '../../../screens/overview'; @@ -123,7 +123,6 @@ describe('Cases', { tags: ['@ess', '@serverless'] }, () => { openCaseTimeline(); cy.get(TIMELINE_TITLE).contains(this.mycase.timeline.title); - cy.get(TIMELINE_DESCRIPTION).contains(this.mycase.timeline.description); cy.get(TIMELINE_QUERY).should('have.text', this.mycase.timeline.query); visitWithTimeRange(OVERVIEW_URL); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/investigate_in_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/investigate_in_timeline.cy.ts index 0570557d33f21..8095921c6df86 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/investigate_in_timeline.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/investigate_in_timeline.cy.ts @@ -7,8 +7,12 @@ import { disableExpandableFlyout } from '../../../tasks/api_calls/kibana_advanced_settings'; import { getNewRule } from '../../../objects/rule'; -import { PROVIDER_BADGE, QUERY_TAB_BUTTON, TIMELINE_TITLE } from '../../../screens/timeline'; -import { FILTER_BADGE } from '../../../screens/alerts'; +import { + PROVIDER_BADGE, + QUERY_TAB_BUTTON, + TIMELINE_FILTER_BADGE, + TIMELINE_TITLE, +} from '../../../screens/timeline'; import { expandFirstAlert, investigateFirstAlertInTimeline } from '../../../tasks/alerts'; import { createRule } from '../../../tasks/api_calls/rules'; @@ -80,7 +84,7 @@ describe('Investigate in timeline', { tags: ['@ess', '@serverless'] }, () => { cy.get(QUERY_TAB_BUTTON).should('contain.text', alertCount); // The correct filter is applied to the timeline query - cy.get(FILTER_BADGE).should( + cy.get(TIMELINE_FILTER_BADGE).should( 'have.text', ' {"bool":{"must":[{"term":{"process.args":"-zsh"}},{"term":{"process.args":"unique"}}]}}' ); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/creation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/creation.cy.ts index bb1e0f372e33c..9054684c53e82 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/creation.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/creation.cy.ts @@ -111,7 +111,6 @@ describe('Timeline Templates', { tags: ['@ess', '@serverless'] }, () => { addNameToTimelineAndSave('Test'); cy.wait('@timeline', { timeout: 100000 }); cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible'); - cy.get(TIMELINE_DESCRIPTION).should('have.text', getTimeline().description); cy.get(TIMELINE_QUERY).should('have.text', getTimeline().query); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts index e324dede796f3..8205ef767356b 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts @@ -12,15 +12,14 @@ import { LOCKED_ICON, NOTES_TEXT, PIN_EVENT, - TIMELINE_DESCRIPTION, TIMELINE_FILTER, TIMELINE_FLYOUT_WRAPPER, TIMELINE_QUERY, TIMELINE_PANEL, TIMELINE_STATUS, TIMELINE_TAB_CONTENT_GRAPHS_NOTES, - TIMELINE_SAVE_MODAL_OPEN_BUTTON, - SAVE_TIMELINE_BTN_TOOLTIP, + SAVE_TIMELINE_ACTION_BTN, + SAVE_TIMELINE_TOOLTIP, } from '../../../screens/timeline'; import { createTimelineTemplate } from '../../../tasks/api_calls/timelines'; @@ -62,9 +61,7 @@ describe('Create a timeline from a template', { tags: ['@ess', '@serverless'] }, selectCustomTemplates(); expandEventAction(); clickingOnCreateTimelineFormTemplateBtn(); - cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible'); - cy.get(TIMELINE_DESCRIPTION).should('have.text', getTimeline().description); cy.get(TIMELINE_QUERY).should('have.text', getTimeline().query); closeTimeline(); }); @@ -75,8 +72,7 @@ describe('Timelines', (): void => { deleteTimelines(); }); - // FLAKY: https://github.com/elastic/kibana/issues/169866 - describe.skip('Toggle create timeline from plus icon', () => { + describe('Toggle create timeline from "New" btn', () => { context('Privileges: CRUD', { tags: '@ess' }, () => { beforeEach(() => { login(); @@ -84,6 +80,7 @@ describe('Timelines', (): void => { }); it('toggle create timeline ', () => { + openTimelineUsingToggle(); createNewTimeline(); addNameAndDescriptionToTimeline(getTimeline()); cy.get(TIMELINE_PANEL).should('be.visible'); @@ -97,12 +94,13 @@ describe('Timelines', (): void => { }); it('should not be able to create/update timeline ', () => { + openTimelineUsingToggle(); createNewTimeline(); cy.get(TIMELINE_PANEL).should('be.visible'); - cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).should('be.disabled'); - cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).first().realHover(); - cy.get(SAVE_TIMELINE_BTN_TOOLTIP).should('be.visible'); - cy.get(SAVE_TIMELINE_BTN_TOOLTIP).should( + cy.get(SAVE_TIMELINE_ACTION_BTN).should('be.disabled'); + cy.get(SAVE_TIMELINE_ACTION_BTN).first().realHover(); + cy.get(SAVE_TIMELINE_TOOLTIP).should('be.visible'); + cy.get(SAVE_TIMELINE_TOOLTIP).should( 'have.text', 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.' ); @@ -153,6 +151,7 @@ describe('Timelines', (): void => { before(() => { login(); visitWithTimeRange(OVERVIEW_URL); + openTimelineUsingToggle(); createNewTimeline(); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/flyout_button.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/flyout_button.cy.ts index 3ba7e607bbfb1..1dd9c5cdcf1f0 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/flyout_button.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/flyout_button.cy.ts @@ -6,20 +6,15 @@ */ import { TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON } from '../../../screens/security_main'; -import { CREATE_NEW_TIMELINE, TIMELINE_FLYOUT_HEADER } from '../../../screens/timeline'; +import { TIMELINE_FLYOUT_HEADER } from '../../../screens/timeline'; import { waitForAllHostsToBeLoaded } from '../../../tasks/hosts/all_hosts'; import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { closeTimelineUsingCloseButton, - closeTimelineUsingToggle, openTimelineUsingToggle, } from '../../../tasks/security_main'; -import { - closeCreateTimelineOptionsPopover, - openCreateTimelineOptionsPopover, -} from '../../../tasks/timeline'; import { hostsUrl } from '../../../urls/navigation'; @@ -33,7 +28,7 @@ describe('timeline flyout button', () => { it('toggles open the timeline', { tags: ['@ess', '@serverless'] }, () => { openTimelineUsingToggle(); cy.get(TIMELINE_FLYOUT_HEADER).should('have.css', 'visibility', 'visible'); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); }); it( @@ -41,7 +36,7 @@ describe('timeline flyout button', () => { { tags: ['@ess', '@serverless'] }, () => { openTimelineUsingToggle(); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).should('have.focus'); } @@ -69,18 +64,6 @@ describe('timeline flyout button', () => { } ); - it( - 'the `(+)` button popover menu owns focus when open', - { tags: ['@ess', '@serverless'] }, - () => { - openCreateTimelineOptionsPopover(); - cy.get(CREATE_NEW_TIMELINE).focus(); - cy.get(CREATE_NEW_TIMELINE).should('have.focus'); - closeCreateTimelineOptionsPopover(); - cy.get(CREATE_NEW_TIMELINE).should('not.exist'); - } - ); - it( 'should render the global search dropdown when the input is focused', { tags: ['@ess'] }, diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts index 372a0a3178975..e1dc678631124 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts @@ -7,11 +7,7 @@ import { getTimeline } from '../../../objects/timeline'; -import { - TIMELINE_DESCRIPTION, - TIMELINE_TITLE, - OPEN_TIMELINE_MODAL, -} from '../../../screens/timeline'; +import { TIMELINE_TITLE, OPEN_TIMELINE_MODAL } from '../../../screens/timeline'; import { TIMELINES_DESCRIPTION, TIMELINES_PINNED_EVENT_COUNT, @@ -69,7 +65,6 @@ describe('Open timeline', { tags: ['@serverless', '@ess'] }, () => { cy.get(TIMELINES_NOTES_COUNT).last().should('have.text', '1'); cy.get(TIMELINES_FAVORITE).last().should('exist'); cy.get(TIMELINE_TITLE).should('have.text', getTimeline().title); - cy.get(TIMELINE_DESCRIPTION).should('have.text', getTimeline().description); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/search_or_filter.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/search_or_filter.cy.ts index 00b3726bfec21..86fa3a7df97d5 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/search_or_filter.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/search_or_filter.cy.ts @@ -22,6 +22,7 @@ import { changeTimelineQueryLanguage, executeTimelineKQL, executeTimelineSearch, + showDataProviderQueryBuilder, } from '../../../tasks/timeline'; import { waitForTimelinesPanelToBeLoaded } from '../../../tasks/timelines'; @@ -60,6 +61,7 @@ describe('Timeline search and filters', { tags: ['@ess', '@serverless'] }, () => openTimelineUsingToggle(); cy.intercept('PATCH', '/api/timeline').as('update'); cy.get(LOADING_INDICATOR).should('not.exist'); + showDataProviderQueryBuilder(); cy.get(TIMELINE_SEARCH_OR_FILTER).click(); cy.get(TIMELINE_SEARCH_OR_FILTER).should('exist'); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unsaved_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unsaved_timeline.cy.ts index 780beec43d838..277fed63d1e85 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unsaved_timeline.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unsaved_timeline.cy.ts @@ -20,7 +20,10 @@ import { } from '../../../tasks/kibana_navigation'; import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; -import { closeTimelineUsingToggle } from '../../../tasks/security_main'; +import { + closeTimelineUsingCloseButton, + openTimelineUsingToggle, +} from '../../../tasks/security_main'; import { navigateToHostsUsingBreadcrumb, navigateToExploreUsingBreadcrumb, @@ -52,6 +55,7 @@ describe('Save Timeline Prompts', { tags: ['@ess'] }, () => { beforeEach(() => { login(); visitWithTimeRange(hostsUrl('allHosts')); + openTimelineUsingToggle(); createNewTimeline(); }); @@ -63,7 +67,7 @@ describe('Save Timeline Prompts', { tags: ['@ess'] }, () => { it('Changed & unsaved timeline should prompt when user navigates away from security solution', () => { populateTimeline(); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); openKibanaNavigation(); navigateFromKibanaCollapsibleTo(OBSERVABILITY_ALERTS_PAGE); cy.get(APP_LEAVE_CONFIRM_MODAL).should('be.visible'); @@ -72,7 +76,7 @@ describe('Save Timeline Prompts', { tags: ['@ess'] }, () => { it('Changed & unsaved timeline should NOT prompt when user navigates away within security solution where timelines are enabled', () => { populateTimeline(); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); // navigate to any other page in security solution openKibanaNavigation(); cy.get(CASES_PAGE).click(); @@ -90,7 +94,7 @@ describe('Save Timeline Prompts', { tags: ['@ess'] }, () => { it('Changed & saved timeline should NOT prompt when user navigates away out of security solution', () => { populateTimeline(); addNameToTimelineAndSave('Test'); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); openKibanaNavigation(); navigateFromKibanaCollapsibleTo(OBSERVABILITY_ALERTS_PAGE); cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist'); @@ -99,7 +103,7 @@ describe('Save Timeline Prompts', { tags: ['@ess'] }, () => { it('Changed & saved timeline should NOT prompt when user navigates within security solution where timelines are disabled', () => { populateTimeline(); addNameToTimelineAndSave('Test'); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); openKibanaNavigation(); cy.get(MANAGE_PAGE).click(); cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist'); @@ -107,7 +111,7 @@ describe('Save Timeline Prompts', { tags: ['@ess'] }, () => { it('When user navigates to the page where timeline is present, Timeline save modal should not exists.', () => { populateTimeline(); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); openKibanaNavigation(); cy.get(MANAGE_PAGE).click(); cy.get(APP_LEAVE_CONFIRM_MODAL).should('be.visible'); @@ -123,7 +127,7 @@ describe('Save Timeline Prompts', { tags: ['@ess'] }, () => { it('Changed and unsaved timeline should NOT prompt when user navigates from the page where timeline is disabled', () => { populateTimeline(); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); openKibanaNavigation(); cy.get(MANAGE_PAGE).click(); cy.get(APP_LEAVE_CONFIRM_MODAL).should('be.visible'); @@ -157,11 +161,12 @@ describe('Save Timeline Prompts', { tags: ['@serverless'] }, () => { beforeEach(() => { login(); visitWithTimeRange(hostsUrl('allHosts')); + openTimelineUsingToggle(); createNewTimeline(); }); it('unchanged & unsaved timeline should NOT prompt when it is closed and navigate to any page', () => { - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); navigateToAlertsPageInServerless(); // security page with timelines enabled cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist'); @@ -173,7 +178,7 @@ describe('Save Timeline Prompts', { tags: ['@serverless'] }, () => { it('Changed & unsaved timeline should prompt when it is closed and navigate to Security page without timeline', () => { populateTimeline(); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); navigateToAlertsPageInServerless(); // security page with timelines enabled cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist'); @@ -184,7 +189,7 @@ describe('Save Timeline Prompts', { tags: ['@serverless'] }, () => { it('Changed & unsaved timeline should prompt when it is closed and navigate to external page', () => { populateTimeline(); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); navigateToDiscoverPageInServerless(); cy.get(APP_LEAVE_CONFIRM_MODAL).should('be.visible'); @@ -194,7 +199,7 @@ describe('Save Timeline Prompts', { tags: ['@serverless'] }, () => { it('Changed & saved timeline should NOT prompt when it is closed', () => { populateTimeline(); addNameToTimelineAndSave('Test'); - closeTimelineUsingToggle(); + closeTimelineUsingCloseButton(); navigateToAlertsPageInServerless(); // security page with timelines enabled cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist'); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts index 755c31d116a63..e84d38c78fd8b 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts @@ -29,7 +29,7 @@ export const CORRELATION_EVENT_TABLE_CELL = export const CLOSE_TIMELINE_BTN = '[data-test-subj="close-timeline"]'; -export const COMBO_BOX = '.euiComboBoxOption__content'; +export const COMBO_BOX = 'button.euiFilterSelectItem[role="option"]'; export const COMBO_BOX_INPUT = '[data-test-subj="comboBoxInput"]'; @@ -222,7 +222,7 @@ export const TIMELINE_FLYOUT_BODY = '[data-test-subj="query-tab-flyout-body"]'; export const TIMELINE_HEADER = '[data-test-subj="timeline-hide-show-container"]'; -export const TIMELINE_INSPECT_BUTTON = `${TIMELINE_FLYOUT} [data-test-subj="inspect-icon-button"]`; +export const TIMELINE_INSPECT_BUTTON = `${TIMELINE_FLYOUT} [data-test-subj="inspect-empty-button"]`; export const TIMELINE_PANEL = `[data-test-subj="timeline-flyout-header-panel"]`; @@ -354,3 +354,14 @@ export const OPEN_TIMELINE_MODAL_SEARCH_BAR = `${OPEN_TIMELINE_MODAL} ${getData export const OPEN_TIMELINE_MODAL_TIMELINE_NAMES = `${OPEN_TIMELINE_MODAL} ${getDataTestSubjectSelectorStartWith( 'timeline-title-' )}`; + +export const TIMELINE_FILTER_BADGE = `[data-test-subj^='timeline-filters-container'] [data-test-subj^="filter-badge"]`; + +export const NEW_TIMELINE_ACTION = getDataTestSubjectSelector('new-timeline-action'); + +export const SAVE_TIMELINE_ACTION = getDataTestSubjectSelector('save-timeline-action'); +export const SAVE_TIMELINE_ACTION_BTN = getDataTestSubjectSelector('save-timeline-action-btn'); + +export const SAVE_TIMELINE_TOOLTIP = getDataTestSubjectSelector('save-timeline-btn-tooltip'); + +export const TOGGLE_DATA_PROVIDER_BTN = getDataTestSubjectSelector('toggle-data-provider'); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/notes.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/notes.ts index d1addc0407900..21f718dc355d1 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/notes.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/notes.ts @@ -17,5 +17,9 @@ export const addNoteToTimeline = ( version: null, note: { note, timelineId }, }, - headers: { 'kbn-xsrf': 'cypress-creds', 'x-elastic-internal-origin': 'security-solution' }, + headers: { + 'kbn-xsrf': 'cypress-creds', + 'x-elastic-internal-origin': 'security-solution', + 'elastic-api-version': '2023-10-31', + }, }); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/timelines.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/timelines.ts index a4edcc54752de..620a105a2b98e 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/timelines.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/timelines.ts @@ -101,12 +101,23 @@ export const createTimelineTemplate = (timeline: CompleteTimeline) => savedQueryId: null, }, }, + headers: { + 'kbn-xsrf': 'cypress-creds', + 'x-elastic-internal-origin': 'security-solution', + 'elastic-api-version': '2023-10-31', + }, }); export const loadPrepackagedTimelineTemplates = () => rootRequest({ method: 'POST', url: 'api/timeline/_prepackaged', + headers: { + 'kbn-xsrf': 'cypress-creds', + 'x-elastic-internal-origin': 'security-solution', + + 'elastic-api-version': '2023-10-31', + }, }); export const favoriteTimeline = ({ diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts b/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts index 9b8af6c5ceef6..15c171676c958 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts @@ -5,21 +5,13 @@ * 2.0. */ -import { - CLOSE_TIMELINE_BUTTON, - TIMELINE_TOGGLE_BUTTON, - TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON, -} from '../screens/security_main'; +import { CLOSE_TIMELINE_BUTTON, TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON } from '../screens/security_main'; import { TIMELINE_EXIT_FULL_SCREEN_BUTTON, TIMELINE_FULL_SCREEN_BUTTON } from '../screens/timeline'; export const openTimelineUsingToggle = () => { cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).click(); }; -export const closeTimelineUsingToggle = () => { - cy.get(TIMELINE_TOGGLE_BUTTON).filter(':visible').click(); -}; - export const closeTimelineUsingCloseButton = () => { cy.get(CLOSE_TIMELINE_BUTTON).filter(':visible').click(); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts index 2579e3794a073..09cdb6158073f 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts @@ -54,7 +54,6 @@ import { CREATE_NEW_TIMELINE_TEMPLATE, OPEN_TIMELINE_TEMPLATE_ICON, TIMELINE_SAVE_MODAL, - TIMELINE_SAVE_MODAL_OPEN_BUTTON, TIMELINE_EDIT_MODAL_SAVE_BUTTON, TIMELINE_PROGRESS_BAR, QUERY_TAB_BUTTON, @@ -89,6 +88,10 @@ import { OPEN_TIMELINE_MODAL_TIMELINE_NAMES, OPEN_TIMELINE_MODAL_SEARCH_BAR, OPEN_TIMELINE_MODAL, + NEW_TIMELINE_ACTION, + SAVE_TIMELINE_ACTION, + TOGGLE_DATA_PROVIDER_BTN, + SAVE_TIMELINE_ACTION_BTN, } from '../screens/timeline'; import { REFRESH_BUTTON, TIMELINE } from '../screens/timelines'; import { drag, drop } from './common'; @@ -102,7 +105,7 @@ export const addDescriptionToTimeline = ( modalAlreadyOpen: boolean = false ) => { if (!modalAlreadyOpen) { - cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).first().click(); + cy.get(SAVE_TIMELINE_ACTION_BTN).first().click(); } cy.get(TIMELINE_DESCRIPTION_INPUT).should('not.be.disabled').type(description); cy.get(TIMELINE_DESCRIPTION_INPUT).invoke('val').should('equal', description); @@ -111,7 +114,7 @@ export const addDescriptionToTimeline = ( }; export const addNameToTimelineAndSave = (name: string) => { - cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).first().click(); + cy.get(SAVE_TIMELINE_ACTION_BTN).first().click(); cy.get(TIMELINE_TITLE_INPUT).should('not.be.disabled').clear(); cy.get(TIMELINE_TITLE_INPUT).type(`${name}{enter}`); cy.get(TIMELINE_TITLE_INPUT).should('have.attr', 'value', name); @@ -124,7 +127,7 @@ export const addNameAndDescriptionToTimeline = ( modalAlreadyOpen: boolean = false ) => { if (!modalAlreadyOpen) { - cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).first().click(); + cy.get(SAVE_TIMELINE_ACTION).click(); } cy.get(TIMELINE_TITLE_INPUT).type(`${timeline.title}{enter}`); cy.get(TIMELINE_TITLE_INPUT).should('have.attr', 'value', timeline.title); @@ -197,7 +200,7 @@ export const addFilter = (filter: TimelineFilter): Cypress.Chainable { }; export const createNewTimeline = () => { - cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click(); - cy.get(TIMELINE_SETTINGS_ICON).should('be.visible'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(1000); + cy.get(NEW_TIMELINE_ACTION).should('be.visible').trigger('click'); cy.get(CREATE_NEW_TIMELINE).eq(0).should('be.visible').click({ force: true }); }; export const openCreateTimelineOptionsPopover = () => { - cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').should('be.visible').click(); + cy.get(NEW_TIMELINE_ACTION).filter(':visible').should('be.visible').click(); }; export const closeCreateTimelineOptionsPopover = () => { @@ -351,7 +351,7 @@ export const expandFirstTimelineEventDetails = () => { * before you're using this task. Otherwise it will fail to save. */ export const saveTimeline = () => { - cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).first().click(); + cy.get(SAVE_TIMELINE_ACTION_BTN).first().click(); cy.get(TIMELINE_SAVE_MODAL).within(() => { cy.get(TIMELINE_PROGRESS_BAR).should('not.exist'); @@ -387,7 +387,6 @@ export const openTimelineInspectButton = () => { }; export const openTimelineFromSettings = () => { - openCreateTimelineOptionsPopover(); cy.get(OPEN_TIMELINE_ICON).should('be.visible'); cy.get(OPEN_TIMELINE_ICON).click(); }; @@ -521,3 +520,9 @@ export const openTimelineFromOpenTimelineModal = (timelineName: string) => { cy.get(OPEN_TIMELINE_MODAL).should('contain.text', timelineName); cy.get(OPEN_TIMELINE_MODAL_TIMELINE_NAMES).first().click(); }; + +export const showDataProviderQueryBuilder = () => { + cy.get(TOGGLE_DATA_PROVIDER_BTN).should('have.attr', 'aria-pressed', 'false'); + cy.get(TOGGLE_DATA_PROVIDER_BTN).trigger('click'); + cy.get(TOGGLE_DATA_PROVIDER_BTN).should('have.attr', 'aria-pressed', 'true'); +}; diff --git a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts index b6c41a813b07e..e7f35e31702cc 100644 --- a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts +++ b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts @@ -15,11 +15,9 @@ const TIMELINE_MODAL_PAGE_TEST_SUBJ = 'timeline'; const TIMELINE_TAB_QUERY_TEST_SUBJ = 'timeline-tab-content-query'; const TIMELINE_CSS_SELECTOR = Object.freeze({ - /** The Plus icon to add a new timeline located in the bottom timeline sticky bar */ - buttonBarAddButton: `${testSubjSelector( + bottomBarTimelineTitle: `${testSubjSelector( TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ - )} ${testSubjSelector('settings-plus-in-circle')}`, - + )} ${testSubjSelector('timeline-title')}`, /** The refresh button on the timeline view (top of view, next to the date selector) */ refreshButton: `${testSubjSelector(TIMELINE_TAB_QUERY_TEST_SUBJ)} ${testSubjSelector( 'superDatePickerApplyTimeButton' @@ -45,16 +43,15 @@ export class TimelinePageObject extends FtrService { await this.testSubjects.existOrFail(TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ); } - async showOpenTimelinePopupFromBottomBar(): Promise { + async openTimelineFromBottomBar() { await this.ensureTimelineAccessible(); await this.testSubjects.findService.clickByCssSelector( - TIMELINE_CSS_SELECTOR.buttonBarAddButton + TIMELINE_CSS_SELECTOR.bottomBarTimelineTitle ); - await this.testSubjects.existOrFail('timeline-addPopupPanel'); } async openTimelineById(id: string): Promise { - await this.showOpenTimelinePopupFromBottomBar(); + await this.openTimelineFromBottomBar(); await this.testSubjects.click('open-timeline-button'); await this.testSubjects.findService.clickByCssSelector( `${testSubjSelector('open-timeline-modal')} ${testSubjSelector(`timeline-title-${id}`)}`