Add CreateFieldButton component to browser fields (#117627)

Add user permission check to CreateFieldButton

Refetch data after creating field

Add global styles to make Overlay z-index higher than timeline z-index

Fix create runtime field loading state

Update alert table columns after adding a new runtime field

Updated documentation of 'overlays.openFlyout' public API

Add cypress test

Add CreateField button unit test
This commit is contained in:
Pablo Machado 2021-11-05 18:25:17 +01:00 committed by GitHub
parent 5a1ac5c440
commit 6c2f9a4dfc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 445 additions and 25 deletions

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) &gt; [maskProps](./kibana-plugin-core-public.overlayflyoutopenoptions.maskprops.md)
## OverlayFlyoutOpenOptions.maskProps property
<b>Signature:</b>
```typescript
maskProps?: EuiOverlayMaskProps;
```

View file

@ -20,6 +20,7 @@ export interface OverlayFlyoutOpenOptions
| [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | <code>string</code> | |
| [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | <code>string</code> | |
| [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | <code>boolean</code> | |
| [maskProps](./kibana-plugin-core-public.overlayflyoutopenoptions.maskprops.md) | <code>EuiOverlayMaskProps</code> | |
| [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | <code>boolean &#124; number &#124; string</code> | |
| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | <code>(flyout: OverlayRef) =&gt; void</code> | 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) | <code>boolean</code> | |

View file

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

View file

@ -15,6 +15,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)

View file

@ -150,6 +150,9 @@ export const getFieldEditorOpener =
flyout.close();
}
},
maskProps: {
className: 'indexPatternFieldEditorMaskOverlay',
},
}
);

View file

@ -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'
);
});
});

View file

@ -38,7 +38,8 @@
"lens",
"lists",
"home",
"telemetry"
"telemetry",
"indexPatternFieldEditor"
],
"server": true,
"ui": true,

View file

@ -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[] = [
@ -175,6 +175,8 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
}, [id, timelineQuery, globalQuery]);
const bulkActions = useMemo(() => ({ onAlertStatusActionSuccess }), [onAlertStatusActionSuccess]);
const createFieldComponent = useCreateFieldButton(scopeId, id);
return (
<>
<FullScreenContainer $isFullScreen={globalFullScreen}>
@ -218,6 +220,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
trailingControlColumns,
type: 'embedded',
unit,
createFieldComponent,
})
) : (
<EventsViewer

View file

@ -31,7 +31,7 @@ jest.mock('react-redux', () => {
useDispatch: () => mockDispatch,
};
});
jest.mock('../../lib/kibana'); // , () => ({
jest.mock('../../lib/kibana');
describe('source/index.tsx', () => {
describe('getBrowserFields', () => {
@ -40,11 +40,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', () => {

View file

@ -78,8 +78,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(
@ -97,8 +96,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 = (

View file

@ -0,0 +1,90 @@
/*
* 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 { TimelineId } from '../../../../common';
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
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(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={() => undefined}
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(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={() => undefined}
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(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={onClickParam}
timelineId={TimelineId.detectionsPage}
/>,
{
wrapper: TestProviders,
}
);
await runAllPromises();
});
fireEvent.click(screen.getByRole('button'));
expect(onClickParam).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,135 @@
/*
* 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 { useDataView } from '../../../common/containers/source/use_data_view';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { sourcererSelectors } from '../../../common/store';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
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;
}
const StyledButton = styled(EuiButton)`
margin-left: ${({ theme }) => theme.eui.paddingSizes.m};
`;
export const CreateFieldButton = React.memo<CreateFieldButtonProps>(
({ selectedDataViewId, onClick: onClickParam, timelineId }) => {
const [dataView, setDataView] = useState<IndexPattern | null>(null);
const dispatch = useDispatch();
const { indexFieldsSearch } = useDataView();
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 (
<>
<StyledButton
iconType={dataView ? 'plusInCircle' : 'none'}
aria-label={i18n.CREATE_FIELD}
data-test-subj="create-field"
onClick={onClick}
isLoading={!dataView}
>
{i18n.CREATE_FIELD}
</StyledButton>
</>
);
}
);
CreateFieldButton.displayName = 'CreateFieldButton';
/**
*
* Returns a memoised 'CreateFieldButton' with only an 'onClick' property.
*/
export const useCreateFieldButton = (
sourcererScope: SourcererScopeName,
timelineId: TimelineId
) => {
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const { selectedDataViewId } = useDeepEqualSelector((state) =>
scopeIdSelector(state, sourcererScope)
);
const createFieldComponent = useMemo(() => {
// It receives onClick props from field browser in order to close the modal.
const CreateFieldButtonComponent: CreateFieldComponentType = ({ onClick }) => (
<CreateFieldButton
selectedDataViewId={selectedDataViewId}
onClick={onClick}
timelineId={timelineId}
/>
);
return CreateFieldButtonComponent;
}, [selectedDataViewId, timelineId]);
return createFieldComponent;
};

View file

@ -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',
}
);

View file

@ -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)<EuiFlyoutProps>`
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<FlyoutPaneComponentProps> = ({
timelineId,
visible = true,
@ -51,6 +62,7 @@ const FlyoutPaneComponent: React.FC<FlyoutPaneComponentProps> = ({
ownFocus={false}
style={{ visibility: visible ? 'visible' : 'hidden' }}
>
<IndexPatternFieldEditorOverlayGlobalStyle />
<StatefulTimeline
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}

View file

@ -86,6 +86,7 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = ({
sort,
tabType,
timelineId,
createFieldComponent,
}) => {
const { timelines: timelinesUi } = useKibana().services;
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
@ -183,6 +184,7 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = ({
browserFields,
columnHeaders,
timelineId,
createFieldComponent,
})}
</FieldBrowserContainer>
</EventsTh>

View file

@ -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<HeaderActionProps> | React.ComponentType | undefined, index) => {
@ -194,6 +202,7 @@ export const ColumnHeadersComponent = ({
sort={sort}
tabType={tabType}
timelineId={timelineId}
createFieldComponent={createFieldComponent}
/>
)}
</EventsThGroupActions>
@ -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}
/>
)}
</EventsThGroupActions>
@ -253,6 +264,7 @@ export const ColumnHeadersComponent = ({
actionsColumnWidth,
browserFields,
columnHeaders,
createFieldComponent,
isEventViewer,
isSelectAllChecked,
onSelectAll,

View file

@ -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 {

View file

@ -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 &

View file

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

View file

@ -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<Record<any, never>>;

View file

@ -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[];
@ -160,6 +162,7 @@ const transformControlColumns = ({
actionColumnsWidth,
columnHeaders,
controlColumns,
createFieldComponent,
data,
isEventViewer = false,
loadingEventIds,
@ -182,6 +185,7 @@ const transformControlColumns = ({
actionColumnsWidth: number;
columnHeaders: ColumnHeaderOptions[];
controlColumns: ControlColumnProps[];
createFieldComponent?: CreateFieldComponentType;
data: TimelineItem[];
isEventViewer?: boolean;
loadingEventIds: string[];
@ -227,6 +231,7 @@ const transformControlColumns = ({
sort={sort}
tabType={tabType}
timelineId={timelineId}
createFieldComponent={createFieldComponent}
/>
)}
</>
@ -308,6 +313,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
bulkActions = true,
clearSelected,
columnHeaders,
createFieldComponent,
data,
defaultCellActions,
filterQuery,
@ -491,6 +497,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
<StatefulFieldsBrowser
data-test-subj="field-browser"
browserFields={browserFields}
createFieldComponent={createFieldComponent}
timelineId={id}
columnHeaders={columnHeaders}
/>
@ -527,6 +534,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
additionalControls,
browserFields,
columnHeaders,
createFieldComponent,
]
);
@ -616,6 +624,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
transformControlColumns({
columnHeaders,
controlColumns,
createFieldComponent,
data,
isEventViewer,
actionColumnsWidth: hasAdditionalActions(id as TimelineId)
@ -648,6 +657,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
leadingControlColumns,
trailingControlColumns,
columnHeaders,
createFieldComponent,
data,
isEventViewer,
id,

View file

@ -23,6 +23,7 @@ import type { CoreStart } from '../../../../../../../src/core/public';
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import {
BulkActionsProp,
CreateFieldComponentType,
TGridCellAction,
TimelineId,
TimelineTabs,
@ -98,6 +99,7 @@ export interface TGridIntegratedProps {
browserFields: BrowserFields;
bulkActions?: BulkActionsProp;
columns: ColumnHeaderOptions[];
createFieldComponent?: CreateFieldComponentType;
data?: DataPublicPluginStart;
dataProviders: DataProvider[];
defaultCellActions?: TGridCellAction[];
@ -152,6 +154,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
globalFullScreen,
graphEventId,
graphOverlay = null,
createFieldComponent,
hasAlertsCrud,
id,
indexNames,
@ -349,6 +352,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
activePage={pageInfo.activePage}
browserFields={browserFields}
bulkActions={bulkActions}
createFieldComponent={createFieldComponent}
data={nonDeletedEvents}
defaultCellActions={defaultCellActions}
filterQuery={filterQuery}

View file

@ -271,4 +271,29 @@ describe('FieldsBrowser', () => {
expect(onSearchInputChange).toBeCalledWith(inputText);
});
test('it renders the CreateField button when createFieldComponent is provided', () => {
const MyTestComponent = () => <div>{'test'}</div>;
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
createFieldComponent={MyTestComponent}
/>
</TestProviders>
);
expect(wrapper.find(MyTestComponent).exists()).toBeTruthy();
});
});

View file

@ -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<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width'> &
* 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<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width'> &
const FieldsBrowserComponent: React.FC<Props> = ({
columnHeaders,
filteredBrowserFields,
createFieldComponent: CreateField,
isSearching,
onCategorySelected,
onSearchInputChange,
@ -187,14 +196,22 @@ const FieldsBrowserComponent: React.FC<Props> = ({
</EuiModalHeader>
<EuiModalBody>
<Search
data-test-subj="header"
filteredBrowserFields={filteredBrowserFields}
isSearching={isSearching}
onSearchInputChange={onInputChange}
searchInput={searchInput}
timelineId={timelineId}
/>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<Search
data-test-subj="header"
filteredBrowserFields={filteredBrowserFields}
isSearching={isSearching}
onSearchInputChange={onInputChange}
searchInput={searchInput}
timelineId={timelineId}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{CreateField && <CreateField onClick={onHide} />}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<PanesFlexGroup alignItems="flexStart" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>

View file

@ -34,6 +34,7 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
timelineId,
columnHeaders,
browserFields,
createFieldComponent,
width,
}) => {
const customizeColumnsButtonRef = useRef<HTMLButtonElement | null>(null);
@ -140,6 +141,7 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
{show && (
<FieldsBrowser
browserFields={browserFieldsWithDefaultCategory}
createFieldComponent={createFieldComponent}
columnHeaders={columnHeaders}
filteredBrowserFields={
filteredBrowserFields != null ? filteredBrowserFields : browserFieldsWithDefaultCategory

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CreateFieldComponentType } from '../../../../../common';
import type { BrowserFields } from '../../../../../common/search_strategy/index_fields';
import type { ColumnHeaderOptions } from '../../../../../common/types/timeline/columns';
@ -17,6 +18,8 @@ export interface FieldBrowserProps {
columnHeaders: ColumnHeaderOptions[];
/** A map of categoryId -> 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 */

View file

@ -67,3 +67,5 @@ export function plugin() {
export const StatefulEventContext = createContext<StatefulEventContextType | null>(null);
export { TimelineContext } from './components/t_grid/shared';
export type { CreateFieldComponentType } from '../common';