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';