diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.maskprops.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.maskprops.md new file mode 100644 index 0000000000000..3cb3e0b4902a9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.maskprops.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [maskProps](./kibana-plugin-core-public.overlayflyoutopenoptions.maskprops.md) + +## OverlayFlyoutOpenOptions.maskProps property + +Signature: + +```typescript +maskProps?: EuiOverlayMaskProps; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md index dcecdeb840869..611b2206bccdc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md @@ -20,6 +20,7 @@ export interface OverlayFlyoutOpenOptions | [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | string | | | [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string | | | [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | boolean | | +| [maskProps](./kibana-plugin-core-public.overlayflyoutopenoptions.maskprops.md) | EuiOverlayMaskProps | | | [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | boolean | number | string | | | [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | (flyout: OverlayRef) => void | EuiFlyout onClose handler. If provided the consumer is responsible for calling flyout.close() to close the flyout; | | [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | boolean | | diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 6e986cc8ecb48..79047738da4dd 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -8,7 +8,7 @@ /* eslint-disable max-classes-per-file */ -import { EuiFlyout, EuiFlyoutSize } from '@elastic/eui'; +import { EuiFlyout, EuiFlyoutSize, EuiOverlayMaskProps } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; @@ -86,6 +86,7 @@ export interface OverlayFlyoutOpenOptions { size?: EuiFlyoutSize; maxWidth?: boolean | number | string; hideCloseButton?: boolean; + maskProps?: EuiOverlayMaskProps; /** * EuiFlyout onClose handler. * If provided the consumer is responsible for calling flyout.close() to close the flyout; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 353e5aa4607e4..10a5909aba6ce 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -17,6 +17,7 @@ import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; +import { EuiOverlayMaskProps } from '@elastic/eui'; import { History } from 'history'; import { Href } from 'history'; import { IconType } from '@elastic/eui'; @@ -1048,6 +1049,8 @@ export interface OverlayFlyoutOpenOptions { // (undocumented) hideCloseButton?: boolean; // (undocumented) + maskProps?: EuiOverlayMaskProps; + // (undocumented) maxWidth?: boolean | number | string; onClose?: (flyout: OverlayRef) => void; // (undocumented) diff --git a/src/plugins/index_pattern_field_editor/public/open_editor.tsx b/src/plugins/index_pattern_field_editor/public/open_editor.tsx index 19b5d1fde8315..0109b8d95db52 100644 --- a/src/plugins/index_pattern_field_editor/public/open_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_editor.tsx @@ -150,6 +150,9 @@ export const getFieldEditorOpener = flyout.close(); } }, + maskProps: { + className: 'indexPatternFieldEditorMaskOverlay', + }, } ); diff --git a/x-pack/plugins/security_solution/cypress/integration/data_sources/create_runtime_field.spec.ts b/x-pack/plugins/security_solution/cypress/integration/data_sources/create_runtime_field.spec.ts new file mode 100644 index 0000000000000..1f2ca36c5a3d7 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/data_sources/create_runtime_field.spec.ts @@ -0,0 +1,64 @@ +/* + * 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 { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { openTimelineUsingToggle } from '../../tasks/security_main'; +import { openTimelineFieldsBrowser, populateTimeline } from '../../tasks/timeline'; + +import { HOSTS_URL, ALERTS_URL } from '../../urls/navigation'; + +import { waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; + +import { getNewRule } from '../../objects/rule'; +import { refreshPage } from '../../tasks/security_header'; +import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; +import { openEventsViewerFieldsBrowser } from '../../tasks/hosts/events'; + +describe('Create DataView runtime field', () => { + before(() => { + cleanKibana(); + }); + + it('adds field to alert table', () => { + const fieldName = 'field.name.alert.page'; + loginAndWaitForPage(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(getNewRule()); + refreshPage(); + waitForAlertsToPopulate(500); + openEventsViewerFieldsBrowser(); + + cy.get('[data-test-subj="create-field"]').click(); + cy.get('.indexPatternFieldEditorMaskOverlay').find('[data-test-subj="input"]').type(fieldName); + cy.get('[data-test-subj="fieldSaveButton"]').click(); + + cy.get( + `[data-test-subj="events-viewer-panel"] [data-test-subj="dataGridHeaderCell-${fieldName}"]` + ).should('exist'); + }); + + it('adds field to timeline', () => { + const fieldName = 'field.name.timeline'; + + loginAndWaitForPage(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + openTimelineFieldsBrowser(); + + cy.get('[data-test-subj="create-field"]').click(); + cy.get('.indexPatternFieldEditorMaskOverlay').find('[data-test-subj="input"]').type(fieldName); + cy.get('[data-test-subj="fieldSaveButton"]').click(); + + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${fieldName}"]`).should( + 'exist' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 80613ae12f51b..c2dfc7c923303 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -38,7 +38,8 @@ "lens", "lists", "home", - "telemetry" + "telemetry", + "indexPatternFieldEditor" ], "server": true, "ui": true, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index f1d53f3511c5b..365169e07d032 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -9,7 +9,6 @@ import React, { useCallback, useMemo, useEffect } from 'react'; import { connect, ConnectedProps, useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; - import { isEmpty } from 'lodash/fp'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; @@ -32,6 +31,7 @@ import { defaultControlColumn } from '../../../timelines/components/timeline/bod import { EventsViewer } from './events_viewer'; import * as i18n from './translations'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; +import { useCreateFieldButton } from '../../../timelines/components/create_field_button'; const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; const leadingControlColumns: ControlColumnProps[] = [ @@ -174,6 +174,8 @@ const StatefulEventsViewerComponent: React.FC = ({ }, [id, timelineQuery, globalQuery]); const bulkActions = useMemo(() => ({ onAlertStatusActionSuccess }), [onAlertStatusActionSuccess]); + const createFieldComponent = useCreateFieldButton(scopeId, id); + return ( <> @@ -217,6 +219,7 @@ const StatefulEventsViewerComponent: React.FC = ({ trailingControlColumns, type: 'embedded', unit, + createFieldComponent, }) ) : ( { useDispatch: () => mockDispatch, }; }); -jest.mock('../../lib/kibana'); // , () => ({ +jest.mock('../../lib/kibana'); describe('source/index.tsx', () => { describe('getBrowserFields', () => { @@ -39,11 +39,11 @@ describe('source/index.tsx', () => { expect(fields).toEqual({}); }); - test('it returns the same input with the same title', () => { - getBrowserFields('title 1', []); - // Since it is memoized it will return the same output which is empty object given 'title 1' a second time - const fields = getBrowserFields('title 1', mocksSource.indexFields as IndexField[]); - expect(fields).toEqual({}); + test('it returns the same input given the same title and same fields length', () => { + const oldFields = getBrowserFields('title 1', mocksSource.indexFields as IndexField[]); + const newFields = getBrowserFields('title 1', mocksSource.indexFields as IndexField[]); + // Since it is memoized it will return the same object instance + expect(newFields).toBe(oldFields); }); test('it transforms input into output as expected', () => { @@ -282,5 +282,44 @@ describe('source/index.tsx', () => { ); }); }); + + it('doesnt set source when `autoCall` is false', async () => { + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook( + () => useIndexFields(SourcererScopeName.default, false), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + rerender(); + expect(mockDispatch).not.toBeCalled(); + }); + }); + + it('sets source when `autoCall` is false and when indexFieldsSearch is called', async () => { + await act(async () => { + const { rerender, waitForNextUpdate, result } = renderHook< + string, + ReturnType + >(() => useIndexFields(SourcererScopeName.default, false), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); + rerender(); + + result.current.indexFieldsSearch(sourcererState.defaultDataView.id); + + expect(mockDispatch).toHaveBeenCalledTimes(2); + expect(mockDispatch.mock.calls[0][0]).toHaveProperty( + 'type', + 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING' + ); + expect(mockDispatch.mock.calls[1][0]).toHaveProperty( + 'type', + 'x-pack/security_solution/local/sourcerer/SET_SOURCE' + ); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index da733a90aafb1..1b5392b6d19a1 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -84,8 +84,7 @@ export const getBrowserFields = memoizeOne( return accumulator; }, {}); }, - // Update the value only if _title has changed - (newArgs, lastArgs) => newArgs[0] === lastArgs[0] + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length ); export const getDocValueFields = memoizeOne( @@ -103,8 +102,7 @@ export const getDocValueFields = memoizeOne( return accumulator; }, []) : [], - // Update the value only if _title has changed - (newArgs, lastArgs) => newArgs[0] === lastArgs[0] + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length ); export const indicesExistOrDataTemporarilyUnavailable = ( @@ -215,9 +213,12 @@ export const useFetchIndex = ( /** * Sourcerer specific index fields hook/request * sets redux state, returns nothing + * + * @param autoCall - When false it doesn't call `indexFieldsSearch` automacally. */ export const useIndexFields = ( - sourcererScopeName: SourcererScopeName + sourcererScopeName: SourcererScopeName, + autoCall = true ): { indexFieldsSearch: (selectedDataViewId: string, newSignalsIndex?: string) => void } => { const { data } = useKibana().services; const abortCtrl = useRef(new AbortController()); @@ -357,18 +358,19 @@ export const useIndexFields = ( useEffect(() => { if ( - (dataViewId != null && + autoCall && + ((dataViewId != null && // remove this when https://github.com/elastic/kibana/pull/114907 is merged sourcererScopeName !== refSourcererScopeName.current) || - (dataViewId !== refDataViewId.current && selectedPatterns.length > 0) || - (selectedPatterns.length > 0 && refSelectedPatterns.current.length === 0) + (dataViewId !== refDataViewId.current && selectedPatterns.length > 0) || + (selectedPatterns.length > 0 && refSelectedPatterns.current.length === 0)) ) { indexFieldsSearch(dataViewId); } refSourcererScopeName.current = sourcererScopeName; refSelectedPatterns.current = selectedPatterns; refDataViewId.current = dataViewId; - }, [dataViewId, indexFieldsSearch, selectedPatterns, sourcererScopeName]); + }, [dataViewId, indexFieldsSearch, selectedPatterns, sourcererScopeName, autoCall]); useEffect(() => { return () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx new file mode 100644 index 0000000000000..f6d868524539c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 { render, fireEvent, act, screen } from '@testing-library/react'; +import React from 'react'; +import { CreateFieldButton } from './index'; +import { + indexPatternFieldEditorPluginMock, + Start, +} from '../../../../../../../src/plugins/index_pattern_field_editor/public/mocks'; + +import { TestProviders } from '../../../common/mock'; +import { useKibana } from '../../../common/lib/kibana'; +import { DataView } from '../../../../../../../src/plugins/data/common'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { TimelineId } from '../../../../common'; + +const useKibanaMock = useKibana as jest.Mocked; + +let mockIndexPatternFieldEditor: Start; +jest.mock('../../../common/lib/kibana'); + +const runAllPromises = () => new Promise(setImmediate); + +describe('CreateFieldButton', () => { + beforeEach(() => { + mockIndexPatternFieldEditor = indexPatternFieldEditorPluginMock.createStartContract(); + useKibanaMock().services.indexPatternFieldEditor = mockIndexPatternFieldEditor; + useKibanaMock().services.data.dataViews.get = () => new Promise(() => undefined); + }); + + it('displays the button when user has permissions', () => { + mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true; + + render( + undefined} + sourcererScope={SourcererScopeName.timeline} + timelineId={TimelineId.detectionsPage} + />, + { + wrapper: TestProviders, + } + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it("doesn't display the button when user doesn't have permissions", () => { + mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => false; + render( + undefined} + sourcererScope={SourcererScopeName.timeline} + timelineId={TimelineId.detectionsPage} + />, + { + wrapper: TestProviders, + } + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it("calls 'onClick' param when the button is clicked", async () => { + mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true; + useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView); + + const onClickParam = jest.fn(); + await act(async () => { + render( + , + { + wrapper: TestProviders, + } + ); + await runAllPromises(); + }); + + fireEvent.click(screen.getByRole('button')); + expect(onClickParam).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx new file mode 100644 index 0000000000000..4036d3d9ae5fd --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx @@ -0,0 +1,138 @@ +/* + * 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, useEffect, useMemo, useState } from 'react'; +import { EuiButton } from '@elastic/eui'; +import styled from 'styled-components'; + +import { useDispatch } from 'react-redux'; +import { IndexPattern, IndexPatternField } from '../../../../../../../src/plugins/data/public'; +import { useKibana } from '../../../common/lib/kibana'; + +import * as i18n from './translations'; +import { CreateFieldComponentType, TimelineId } from '../../../../../timelines/common'; +import { tGridActions } from '../../../../../timelines/public'; +import { useIndexFields } from '../../../common/containers/source'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { sourcererSelectors } from '../../../common/store'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { SelectedDataView } from '../../../common/store/sourcerer/selectors'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; +import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; + +interface CreateFieldButtonProps { + selectedDataViewId: string; + onClick: () => void; + timelineId: TimelineId; + sourcererScope: SourcererScopeName; +} +const StyledButton = styled(EuiButton)` + margin-left: ${({ theme }) => theme.eui.paddingSizes.m}; +`; + +export const CreateFieldButton = React.memo( + ({ selectedDataViewId, onClick: onClickParam, sourcererScope, timelineId }) => { + const [dataView, setDataView] = useState(null); + const dispatch = useDispatch(); + + const { indexFieldsSearch } = useIndexFields(sourcererScope, false); + const { + indexPatternFieldEditor, + data: { dataViews }, + } = useKibana().services; + + useEffect(() => { + dataViews.get(selectedDataViewId).then((dataViewResponse) => { + setDataView(dataViewResponse); + }); + }, [selectedDataViewId, dataViews]); + + const onClick = useCallback(() => { + if (dataView) { + indexPatternFieldEditor?.openEditor({ + ctx: { indexPattern: dataView }, + onSave: (field: IndexPatternField) => { + // Fetch the updated list of fields + indexFieldsSearch(selectedDataViewId); + + // Add the new field to the event table + dispatch( + tGridActions.upsertColumn({ + column: { + columnHeaderType: defaultColumnHeaderType, + id: field.name, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + id: timelineId, + index: 0, + }) + ); + }, + }); + } + onClickParam(); + }, [ + indexPatternFieldEditor, + dataView, + onClickParam, + indexFieldsSearch, + selectedDataViewId, + dispatch, + timelineId, + ]); + + if (!indexPatternFieldEditor?.userPermissions.editIndexPattern()) { + return null; + } + + return ( + <> + + {i18n.CREATE_FIELD} + + + ); + } +); + +CreateFieldButton.displayName = 'CreateFieldButton'; + +/** + * + * Returns a memoised 'CreateFieldButton' with only an 'onClick' property. + */ +export const useCreateFieldButton = ( + sourcererScope: SourcererScopeName, + timelineId: TimelineId +) => { + const getSelectedDataView = useMemo(() => sourcererSelectors.getSelectedDataViewSelector(), []); + const { dataViewId } = useDeepEqualSelector((state) => + getSelectedDataView(state, sourcererScope) + ); + + const createFieldComponent = useMemo(() => { + // It receives onClick props from field browser in order to close the modal. + const CreateFieldButtonComponent: CreateFieldComponentType = ({ onClick }) => ( + + ); + + return CreateFieldButtonComponent; + }, [dataViewId, sourcererScope, timelineId]); + + return createFieldComponent; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/translations.ts new file mode 100644 index 0000000000000..cc655b10849a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/translations.ts @@ -0,0 +1,15 @@ +/* + * 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 CREATE_FIELD = i18n.translate( + 'xpack.securitySolution.fieldBrowser.createFieldButton', + { + defaultMessage: 'Create field', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index 2afb2af01406d..dd50f00b23eae 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -7,7 +7,7 @@ import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui'; import React, { useCallback } from 'react'; -import styled from 'styled-components'; +import styled, { createGlobalStyle } from 'styled-components'; import { useDispatch } from 'react-redux'; import { StatefulTimeline } from '../../timeline'; @@ -29,6 +29,17 @@ const StyledEuiFlyout = styled(EuiFlyout)` z-index: ${({ theme }) => theme.eui.euiZLevel4}; `; +// SIDE EFFECT: the following creates a global class selector +const IndexPatternFieldEditorOverlayGlobalStyle = createGlobalStyle<{ + theme: { eui: { euiZLevel5: number } }; +}>` + .indexPatternFieldEditorMaskOverlay { + ${({ theme }) => ` + z-index: ${theme.eui.euiZLevel5}; + `} + } +`; + const FlyoutPaneComponent: React.FC = ({ timelineId, visible = true, @@ -51,6 +62,7 @@ const FlyoutPaneComponent: React.FC = ({ ownFocus={false} style={{ visibility: visible ? 'visible' : 'hidden' }} > + = ({ sort, tabType, timelineId, + createFieldComponent, }) => { const { timelines: timelinesUi } = useKibana().services; const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); @@ -183,6 +184,7 @@ const HeaderActionsComponent: React.FC = ({ browserFields, columnHeaders, timelineId, + createFieldComponent, })} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index 25aefd513f806..80a9022105d2c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -33,6 +33,9 @@ import { import { Sort } from '../sort'; import { ColumnHeader } from './column_header'; +import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; +import { useCreateFieldButton } from '../../../create_field_button'; + interface Props { actionsColumnWidth: number; browserFields: BrowserFields; @@ -169,6 +172,11 @@ export const ColumnHeadersComponent = ({ [trailingControlColumns] ); + const createFieldComponent = useCreateFieldButton( + SourcererScopeName.timeline, + timelineId as TimelineId + ); + const LeadingHeaderActions = useMemo(() => { return leadingHeaderCells.map( (Header: React.ComponentType | React.ComponentType | undefined, index) => { @@ -194,6 +202,7 @@ export const ColumnHeadersComponent = ({ sort={sort} tabType={tabType} timelineId={timelineId} + createFieldComponent={createFieldComponent} /> )} @@ -206,6 +215,7 @@ export const ColumnHeadersComponent = ({ actionsColumnWidth, browserFields, columnHeaders, + createFieldComponent, isEventViewer, isSelectAllChecked, onSelectAll, @@ -241,6 +251,7 @@ export const ColumnHeadersComponent = ({ sort={sort} tabType={tabType} timelineId={timelineId} + createFieldComponent={createFieldComponent} /> )} @@ -253,6 +264,7 @@ export const ColumnHeadersComponent = ({ actionsColumnWidth, browserFields, columnHeaders, + createFieldComponent, isEventViewer, isSelectAllChecked, onSelectAll, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 9509ae0eb7838..586109f7f68a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -27,7 +27,6 @@ import { defaultRowRenderers } from './renderers'; jest.mock('../../../../common/lib/kibana/hooks'); jest.mock('../../../../common/hooks/use_app_toasts'); - jest.mock('../../../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../../../common/lib/kibana'); return { diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 61813d1a122b4..f7dcf79f35414 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -41,6 +41,7 @@ import { Management } from './management'; import { Ueba } from './ueba'; import { LicensingPluginStart, LicensingPluginSetup } from '../../licensing/public'; import { DashboardStart } from '../../../../src/plugins/dashboard/public'; +import { IndexPatternFieldEditorStart } from '../../../../src/plugins/index_pattern_field_editor/public'; export interface SetupPlugins { home?: HomePublicPluginSetup; @@ -67,6 +68,7 @@ export interface StartPlugins { uiActions: UiActionsStart; ml?: MlPluginStart; spaces?: SpacesPluginStart; + indexPatternFieldEditor: IndexPatternFieldEditorStart; } export type StartServices = CoreStart & diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index e85f2eaa12d72..dd4a84be2eb69 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -7,7 +7,7 @@ import { ComponentType, JSXElementConstructor } from 'react'; import { EuiDataGridControlColumn, EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { OnRowSelected, SortColumnTimeline, TimelineTabs } from '..'; +import { CreateFieldComponentType, OnRowSelected, SortColumnTimeline, TimelineTabs } from '..'; import { BrowserFields } from '../../../search_strategy/index_fields'; import { ColumnHeaderOptions } from '../columns'; import { TimelineNonEcsData } from '../../../search_strategy'; @@ -67,6 +67,7 @@ export interface HeaderActionProps { width: number; browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; + createFieldComponent?: CreateFieldComponentType; isEventViewer?: boolean; isSelectAllChecked: boolean; onSelectAll: ({ isSelected }: { isSelected: boolean }) => void; diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index c57f247493ffc..e4ce670e87c9f 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -467,6 +467,10 @@ export enum TimelineTabs { eql = 'eql', } +export type CreateFieldComponentType = React.FC<{ + onClick: () => void; +}>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any type EmptyObject = Partial>; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 9e43c16fd5e6f..5ea31b16b45ae 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -46,6 +46,7 @@ import { TimelineTabs, SetEventsLoading, SetEventsDeleted, + CreateFieldComponentType, } from '../../../../common/types/timeline'; import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; @@ -86,6 +87,7 @@ interface OwnProps { additionalControls?: React.ReactNode; browserFields: BrowserFields; bulkActions?: BulkActionsProp; + createFieldComponent?: CreateFieldComponentType; data: TimelineItem[]; defaultCellActions?: TGridCellAction[]; filters?: Filter[]; @@ -153,6 +155,7 @@ const transformControlColumns = ({ actionColumnsWidth, columnHeaders, controlColumns, + createFieldComponent, data, isEventViewer = false, loadingEventIds, @@ -175,6 +178,7 @@ const transformControlColumns = ({ actionColumnsWidth: number; columnHeaders: ColumnHeaderOptions[]; controlColumns: ControlColumnProps[]; + createFieldComponent?: CreateFieldComponentType; data: TimelineItem[]; isEventViewer?: boolean; loadingEventIds: string[]; @@ -220,6 +224,7 @@ const transformControlColumns = ({ sort={sort} tabType={tabType} timelineId={timelineId} + createFieldComponent={createFieldComponent} /> )} @@ -301,6 +306,7 @@ export const BodyComponent = React.memo( bulkActions = true, clearSelected, columnHeaders, + createFieldComponent, data, defaultCellActions, filterQuery, @@ -484,6 +490,7 @@ export const BodyComponent = React.memo( @@ -520,6 +527,7 @@ export const BodyComponent = React.memo( additionalControls, browserFields, columnHeaders, + createFieldComponent, ] ); @@ -609,6 +617,7 @@ export const BodyComponent = React.memo( transformControlColumns({ columnHeaders, controlColumns, + createFieldComponent, data, isEventViewer, actionColumnsWidth: hasAdditionalActions(id as TimelineId) @@ -641,6 +650,7 @@ export const BodyComponent = React.memo( leadingControlColumns, trailingControlColumns, columnHeaders, + createFieldComponent, data, isEventViewer, id, diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index ff7ca41389b41..50596b5e2fb7d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -22,6 +22,7 @@ import type { CoreStart } from '../../../../../../../src/core/public'; import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; import { BulkActionsProp, + CreateFieldComponentType, TGridCellAction, TimelineId, TimelineTabs, @@ -103,6 +104,7 @@ export interface TGridIntegratedProps { browserFields: BrowserFields; bulkActions?: BulkActionsProp; columns: ColumnHeaderOptions[]; + createFieldComponent?: CreateFieldComponentType; data?: DataPublicPluginStart; dataProviders: DataProvider[]; defaultCellActions?: TGridCellAction[]; @@ -157,6 +159,7 @@ const TGridIntegratedComponent: React.FC = ({ globalFullScreen, graphEventId, graphOverlay = null, + createFieldComponent, hasAlertsCrud, id, indexNames, @@ -354,6 +357,7 @@ const TGridIntegratedComponent: React.FC = ({ activePage={pageInfo.activePage} browserFields={browserFields} bulkActions={bulkActions} + createFieldComponent={createFieldComponent} data={nonDeletedEvents} defaultCellActions={defaultCellActions} filterQuery={filterQuery} diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx index e19499628e8c1..f819a93ae57c0 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx @@ -271,4 +271,29 @@ describe('FieldsBrowser', () => { expect(onSearchInputChange).toBeCalledWith(inputText); }); + + test('it renders the CreateField button when createFieldComponent is provided', () => { + const MyTestComponent = () =>
{'test'}
; + + const wrapper = mount( + + ()} + selectedCategoryId={''} + timelineId={timelineId} + createFieldComponent={MyTestComponent} + /> + + ); + + expect(wrapper.find(MyTestComponent).exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx index a645235b620d8..b7f72e66b1a87 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx @@ -21,11 +21,16 @@ import React, { useEffect, useCallback, useRef, useMemo } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import type { BrowserFields, ColumnHeaderOptions } from '../../../../../common'; +import type { + BrowserFields, + ColumnHeaderOptions, + CreateFieldComponentType, +} from '../../../../../common'; import { isEscape, isTab, stopPropagationAndPreventDefault } from '../../../../../common'; import { CategoriesPane } from './categories_pane'; import { FieldsPane } from './fields_pane'; import { Search } from './search'; + import { CATEGORY_PANE_WIDTH, CLOSE_BUTTON_CLASS_NAME, @@ -53,6 +58,9 @@ type Props = Pick & * The current timeline column headers */ columnHeaders: ColumnHeaderOptions[]; + + createFieldComponent?: CreateFieldComponentType; + /** * A map of categoryId -> metadata about the fields in that category, * filtered such that the name of every field in the category includes @@ -99,6 +107,7 @@ type Props = Pick & const FieldsBrowserComponent: React.FC = ({ columnHeaders, filteredBrowserFields, + createFieldComponent: CreateField, isSearching, onCategorySelected, onSearchInputChange, @@ -187,14 +196,22 @@ const FieldsBrowserComponent: React.FC = ({ - + + + + + + {CreateField && } + + + diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx index 0b67f53cca76e..abe882d9a8b59 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx @@ -34,6 +34,7 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ timelineId, columnHeaders, browserFields, + createFieldComponent, width, }) => { const customizeColumnsButtonRef = useRef(null); @@ -140,6 +141,7 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ {show && ( metadata about the fields in that category */ browserFields: BrowserFields; + + createFieldComponent?: CreateFieldComponentType; /** When true, this Fields Browser is being used as an "events viewer" */ isEventViewer?: boolean; /** The width of the field browser */ diff --git a/x-pack/plugins/timelines/public/index.ts b/x-pack/plugins/timelines/public/index.ts index 70f7185e9c486..65088f9816b16 100644 --- a/x-pack/plugins/timelines/public/index.ts +++ b/x-pack/plugins/timelines/public/index.ts @@ -67,3 +67,5 @@ export function plugin() { export const StatefulEventContext = createContext(null); export { TimelineContext } from './components/t_grid/shared'; + +export { CreateFieldComponentType } from '../common';