[SecuritySolution] Add runtime field edit/delete actions in the Field Browser (#127037)

* implement fieldBrowser runtime field edit/remove actions

* fix user edit permission check

* fix lint error

* test improvements and fixes

* test fix

* fix rules sourcerer loading unmounting alerts

* column widths updated

* comment removed

* test fix

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2022-03-15 11:37:29 +01:00 committed by GitHub
parent 53420d8658
commit 53ba0305f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1151 additions and 491 deletions

View file

@ -24,6 +24,7 @@ import { useTimelineEvents } from '../../../timelines/containers';
import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions';
import { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser';
jest.mock('../../../common/lib/kibana');
@ -33,9 +34,9 @@ jest.mock('../../../timelines/containers', () => ({
jest.mock('../../components/url_state/normalize_time_range.ts');
const mockUseCreateFieldButton = jest.fn().mockReturnValue(<></>);
jest.mock('../../../timelines/components/fields_browser/create_field_button', () => ({
useCreateFieldButton: (...params: unknown[]) => mockUseCreateFieldButton(...params),
const mockUseFieldBrowserOptions = jest.fn();
jest.mock('../../../timelines/components/fields_browser', () => ({
useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props),
}));
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
@ -95,9 +96,9 @@ describe('StatefulEventsViewer', () => {
test('it closes field editor when unmounted', async () => {
const mockCloseEditor = jest.fn();
mockUseCreateFieldButton.mockImplementation((_, __, fieldEditorActionsRef) => {
fieldEditorActionsRef.current = { closeEditor: mockCloseEditor };
return <></>;
mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => {
editorActionsRef.current = { closeEditor: mockCloseEditor };
return {};
});
const wrapper = mount(

View file

@ -31,7 +31,7 @@ import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana';
import { GraphOverlay } from '../../../timelines/components/graph_overlay';
import {
useFieldBrowserOptions,
CreateFieldEditorActions,
FieldEditorActions,
} from '../../../timelines/components/fields_browser';
const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = [];
@ -125,7 +125,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
const tGridEventRenderedViewEnabled = useIsExperimentalFeatureEnabled(
'tGridEventRenderedViewEnabled'
);
const editorActionsRef = useRef<CreateFieldEditorActions>(null);
const editorActionsRef = useRef<FieldEditorActions>(null);
useEffect(() => {
if (createTimeline != null) {

View file

@ -7,19 +7,10 @@
import { IndexField } from '../../../../common/search_strategy/index_fields';
import { getBrowserFields } from '.';
import { useDataView } from './use_data_view';
import { IndexFieldSearch, useDataView } from './use_data_view';
import { mockBrowserFields, mocksSource } from './mock';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { createStore, State } from '../../store';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
} from '../../mock';
import { mockGlobalState, TestProviders } from '../../mock';
import { act, renderHook } from '@testing-library/react-hooks';
import { Provider } from 'react-redux';
import React from 'react';
import { useKibana } from '../../lib/kibana';
const mockDispatch = jest.fn();
@ -52,44 +43,15 @@ describe('source/index.tsx', () => {
expect(fields).toEqual(mockBrowserFields);
});
});
describe('useDataView hook', () => {
const sourcererState = mockGlobalState.sourcerer;
const state: State = {
...mockGlobalState,
sourcerer: {
...sourcererState,
kibanaDataViews: [
...sourcererState.kibanaDataViews,
{
...sourcererState.defaultDataView,
id: 'something-random',
title: 'something,random',
patternList: ['something', 'random'],
},
],
sourcererScopes: {
...sourcererState.sourcererScopes,
[SourcererScopeName.default]: {
...sourcererState.sourcererScopes[SourcererScopeName.default],
},
[SourcererScopeName.detections]: {
...sourcererState.sourcererScopes[SourcererScopeName.detections],
},
[SourcererScopeName.timeline]: {
...sourcererState.sourcererScopes[SourcererScopeName.timeline],
},
},
},
};
const mockSearchResponse = {
...mocksSource,
indicesExist: ['auditbeat-*', sourcererState.signalIndexName],
indicesExist: ['auditbeat-*', mockGlobalState.sourcerer.signalIndexName],
isRestore: false,
rawResponse: {},
runtimeMappings: {},
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
beforeEach(() => {
jest.clearAllMocks();
@ -116,15 +78,14 @@ describe('source/index.tsx', () => {
});
it('sets field data for data view', async () => {
await act(async () => {
const { rerender, waitForNextUpdate, result } = renderHook<
const { waitForNextUpdate, result } = renderHook<
string,
{ indexFieldsSearch: (id: string) => Promise<void> }
{ indexFieldsSearch: IndexFieldSearch }
>(() => useDataView(), {
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
wrapper: TestProviders,
});
await waitForNextUpdate();
rerender();
await result.current.indexFieldsSearch('neato');
await result.current.indexFieldsSearch({ dataViewId: 'neato' });
});
expect(mockDispatch.mock.calls[0][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING',
@ -134,7 +95,76 @@ describe('source/index.tsx', () => {
expect(sourceType).toEqual('x-pack/security_solution/local/sourcerer/SET_DATA_VIEW');
expect(payload.id).toEqual('neato');
expect(Object.keys(payload.browserFields)).toHaveLength(12);
expect(Object.keys(payload.indexFields)).toHaveLength(mocksSource.indexFields.length);
expect(payload.docValueFields).toEqual([{ field: '@timestamp' }]);
});
it('should reuse the result for dataView info when cleanCache not passed', async () => {
let indexFieldsSearch: IndexFieldSearch;
await act(async () => {
const { waitForNextUpdate, result } = renderHook<
string,
{ indexFieldsSearch: IndexFieldSearch }
>(() => useDataView(), {
wrapper: TestProviders,
});
await waitForNextUpdate();
indexFieldsSearch = result.current.indexFieldsSearch;
});
await indexFieldsSearch!({ dataViewId: 'neato' });
const {
payload: { browserFields, indexFields, docValueFields },
} = mockDispatch.mock.calls[1][0];
mockDispatch.mockClear();
await indexFieldsSearch!({ dataViewId: 'neato' });
const {
payload: {
browserFields: newBrowserFields,
indexFields: newIndexFields,
docValueFields: newDocValueFields,
},
} = mockDispatch.mock.calls[1][0];
expect(browserFields).toBe(newBrowserFields);
expect(indexFields).toBe(newIndexFields);
expect(docValueFields).toBe(newDocValueFields);
});
it('should not reuse the result for dataView info when cleanCache passed', async () => {
let indexFieldsSearch: IndexFieldSearch;
await act(async () => {
const { waitForNextUpdate, result } = renderHook<
string,
{ indexFieldsSearch: IndexFieldSearch }
>(() => useDataView(), {
wrapper: TestProviders,
});
await waitForNextUpdate();
indexFieldsSearch = result.current.indexFieldsSearch;
});
await indexFieldsSearch!({ dataViewId: 'neato' });
const {
payload: { browserFields, indexFields, docValueFields },
} = mockDispatch.mock.calls[1][0];
mockDispatch.mockClear();
await indexFieldsSearch!({ dataViewId: 'neato', cleanCache: true });
const {
payload: {
browserFields: newBrowserFields,
indexFields: newIndexFields,
docValueFields: newDocValueFields,
},
} = mockDispatch.mock.calls[1][0];
expect(browserFields).not.toBe(newBrowserFields);
expect(indexFields).not.toBe(newIndexFields);
expect(docValueFields).not.toBe(newDocValueFields);
});
});
});

View file

@ -9,12 +9,14 @@ import { useCallback, useEffect, useRef } from 'react';
import { Subscription } from 'rxjs';
import { useDispatch } from 'react-redux';
import memoizeOne from 'memoize-one';
import { pick } from 'lodash/fp';
import { omit, pick } from 'lodash/fp';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { sourcererActions } from '../../store/sourcerer';
import {
BrowserField,
DELETED_SECURITY_SOLUTION_DATA_VIEW,
DocValueFields,
IndexField,
IndexFieldsStrategyRequest,
IndexFieldsStrategyResponse,
@ -25,26 +27,71 @@ import {
isErrorResponse,
} from '../../../../../../../src/plugins/data/common';
import * as i18n from './translations';
import { getBrowserFields, getDocValueFields } from './';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { getSourcererDataview } from '../sourcerer/api';
const getEsFields = memoizeOne(
(fields: IndexField[]): FieldSpec[] =>
fields && fields.length > 0
? fields.map((field) =>
export type IndexFieldSearch = (param: {
dataViewId: string;
scopeId?: SourcererScopeName;
needToBeInit?: boolean;
cleanCache?: boolean;
}) => Promise<void>;
type DangerCastForBrowserFieldsMutation = Record<
string,
Omit<BrowserField, 'fields'> & { fields: Record<string, BrowserField> }
>;
interface DataViewInfo {
browserFields: DangerCastForBrowserFieldsMutation;
docValueFields: DocValueFields[];
indexFields: FieldSpec[];
}
/**
* HOT Code path where the fields can be 16087 in length or larger. This is
* VERY mutatious on purpose to improve the performance of the transform.
*/
const getDataViewStateFromIndexFields = memoizeOne(
(_title: string, fields: IndexField[]): DataViewInfo => {
// Adds two dangerous casts to allow for mutations within this function
type DangerCastForMutation = Record<string, {}>;
return fields.reduce<DataViewInfo>(
(acc, field) => {
// mutate browserFields
if (acc.browserFields[field.category] == null) {
(acc.browserFields as DangerCastForMutation)[field.category] = {};
}
if (acc.browserFields[field.category].fields == null) {
acc.browserFields[field.category].fields = {};
}
acc.browserFields[field.category].fields[field.name] = field as unknown as BrowserField;
// mutate indexFields
acc.indexFields.push(
pick(['name', 'searchable', 'type', 'aggregatable', 'esTypes', 'subType'], field)
)
: [],
(newArgs, lastArgs) => newArgs[0].length === lastArgs[0].length
);
// mutate docValueFields
if (field.readFromDocValues && acc.docValueFields.length < 100) {
acc.docValueFields.push({
field: field.name,
});
}
return acc;
},
{
browserFields: {},
docValueFields: [],
indexFields: [],
}
);
},
(newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length
);
export const useDataView = (): {
indexFieldsSearch: (
selectedDataViewId: string,
scopeId?: SourcererScopeName,
needToBeInit?: boolean
) => Promise<void>;
indexFieldsSearch: IndexFieldSearch;
} => {
const { data } = useKibana().services;
const abortCtrl = useRef<Record<string, AbortController>>({});
@ -58,22 +105,29 @@ export const useDataView = (): {
},
[dispatch]
);
const indexFieldsSearch = useCallback(
(
selectedDataViewId: string,
scopeId: SourcererScopeName = SourcererScopeName.default,
needToBeInit: boolean = false
) => {
const indexFieldsSearch = useCallback<IndexFieldSearch>(
({
dataViewId,
scopeId = SourcererScopeName.default,
needToBeInit = false,
cleanCache = false,
}) => {
const unsubscribe = () => {
searchSubscription$.current[dataViewId]?.unsubscribe();
searchSubscription$.current = omit(dataViewId, searchSubscription$.current);
abortCtrl.current = omit(dataViewId, abortCtrl.current);
};
const asyncSearch = async () => {
abortCtrl.current = {
...abortCtrl.current,
[selectedDataViewId]: new AbortController(),
[dataViewId]: new AbortController(),
};
setLoading({ id: selectedDataViewId, loading: true });
setLoading({ id: dataViewId, loading: true });
if (needToBeInit) {
const dataViewToUpdate = await getSourcererDataview(
selectedDataViewId,
abortCtrl.current[selectedDataViewId].signal
dataViewId,
abortCtrl.current[dataViewId].signal
);
dispatch(
sourcererActions.updateSourcererDataViews({
@ -86,11 +140,11 @@ export const useDataView = (): {
const subscription = data.search
.search<IndexFieldsStrategyRequest<'dataView'>, IndexFieldsStrategyResponse>(
{
dataViewId: selectedDataViewId,
dataViewId,
onlyCheckIfIndicesExist: false,
},
{
abortSignal: abortCtrl.current[selectedDataViewId].signal,
abortSignal: abortCtrl.current[dataViewId].signal,
strategy: 'indexFields',
}
)
@ -102,27 +156,33 @@ export const useDataView = (): {
dispatch(
sourcererActions.setSelectedDataView({
id: scopeId,
selectedDataViewId,
selectedDataViewId: dataViewId,
selectedPatterns: response.indicesExist,
})
);
}
if (cleanCache) {
getDataViewStateFromIndexFields.clear();
}
const dataViewInfo = getDataViewStateFromIndexFields(
patternString,
response.indexFields
);
dispatch(
sourcererActions.setDataView({
browserFields: getBrowserFields(patternString, response.indexFields),
docValueFields: getDocValueFields(patternString, response.indexFields),
id: selectedDataViewId,
indexFields: getEsFields(response.indexFields),
...dataViewInfo,
id: dataViewId,
loading: false,
runtimeMappings: response.runtimeMappings,
})
);
searchSubscription$.current[selectedDataViewId]?.unsubscribe();
} else if (isErrorResponse(response)) {
setLoading({ id: selectedDataViewId, loading: false });
setLoading({ id: dataViewId, loading: false });
addWarning(i18n.ERROR_BEAT_FIELDS);
searchSubscription$.current[selectedDataViewId]?.unsubscribe();
}
unsubscribe();
resolve();
},
error: (msg) => {
@ -130,25 +190,25 @@ export const useDataView = (): {
// reload app if security solution data view is deleted
return location.reload();
}
setLoading({ id: selectedDataViewId, loading: false });
setLoading({ id: dataViewId, loading: false });
addError(msg, {
title: i18n.FAIL_BEAT_FIELDS,
});
searchSubscription$.current[selectedDataViewId]?.unsubscribe();
unsubscribe();
resolve();
},
});
searchSubscription$.current = {
...searchSubscription$.current,
[selectedDataViewId]: subscription,
[dataViewId]: subscription,
};
});
};
if (searchSubscription$.current[selectedDataViewId]) {
searchSubscription$.current[selectedDataViewId].unsubscribe();
if (searchSubscription$.current[dataViewId]) {
searchSubscription$.current[dataViewId].unsubscribe();
}
if (abortCtrl.current[selectedDataViewId]) {
abortCtrl.current[selectedDataViewId].abort();
if (abortCtrl.current[dataViewId]) {
abortCtrl.current[dataViewId].abort();
}
return asyncSearch();
},

View file

@ -100,15 +100,17 @@ export const useInitSourcerer = (
activeDataViewIds.forEach((id) => {
if (id != null && id.length > 0 && !searchedIds.current.includes(id)) {
searchedIds.current = [...searchedIds.current, id];
indexFieldsSearch(
id,
id === scopeDataViewId ? SourcererScopeName.default : SourcererScopeName.timeline,
id === scopeDataViewId
? selectedPatterns.length === 0 && missingPatterns.length === 0
: timelineDataViewId === id
? timelineMissingPatterns.length === 0 && timelineSelectedPatterns.length === 0
: false
);
indexFieldsSearch({
dataViewId: id,
scopeId:
id === scopeDataViewId ? SourcererScopeName.default : SourcererScopeName.timeline,
needToBeInit:
id === scopeDataViewId
? selectedPatterns.length === 0 && missingPatterns.length === 0
: timelineDataViewId === id
? timelineMissingPatterns.length === 0 && timelineSelectedPatterns.length === 0
: false,
});
}
});
}, [
@ -188,7 +190,7 @@ export const useInitSourcerer = (
if (response.defaultDataView.patternList.includes(newSignalsIndex)) {
// first time signals is defined and validated in the sourcerer
// redo indexFieldsSearch
indexFieldsSearch(response.defaultDataView.id);
indexFieldsSearch({ dataViewId: response.defaultDataView.id });
}
dispatch(sourcererActions.setSourcererDataViews(response));
dispatch(sourcererActions.setSourcererScopeLoading({ loading: false }));

View file

@ -64,7 +64,7 @@ export const useSignalHelpers = (): {
) {
// first time signals is defined and validated in the sourcerer
// redo indexFieldsSearch
indexFieldsSearch(response.defaultDataView.id);
indexFieldsSearch({ dataViewId: response.defaultDataView.id });
dispatch(sourcererActions.setSourcererDataViews(response));
}
} catch (err) {

View file

@ -211,7 +211,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
loading: isLoadingIndexPattern,
} = useSourcererDataView(SourcererScopeName.detections);
const loading = userInfoLoading || listsConfigLoading || isLoadingIndexPattern;
const loading = userInfoLoading || listsConfigLoading;
const { detailName: ruleId } = useParams<{ detailName: string }>();
const {
rule: maybeRule,
@ -320,7 +320,10 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
}
}, [hasIndexRead]);
const showUpdating = useMemo(() => isAlertsLoading || loading, [isAlertsLoading, loading]);
const showUpdating = useMemo(
() => isLoadingIndexPattern || isAlertsLoading || loading,
[isLoadingIndexPattern, isAlertsLoading, loading]
);
const title = useMemo(
() => (

View file

@ -5,141 +5,66 @@
* 2.0.
*/
import { render, fireEvent, act, screen } from '@testing-library/react';
import React, { MutableRefObject } from 'react';
import { CreateFieldButton, CreateFieldEditorActions } from './index';
import {
indexPatternFieldEditorPluginMock,
Start,
} from '../../../../../../../../src/plugins/data_view_field_editor/public/mocks';
import { render } from '@testing-library/react';
import React from 'react';
import { useCreateFieldButton, UseCreateFieldButton, UseCreateFieldButtonProps } from './index';
import { TestProviders } from '../../../../common/mock';
import { useKibana } from '../../../../common/lib/kibana';
import type { DataView } from '../../../../../../../../src/plugins/data/common';
import { TimelineId } from '../../../../../common/types';
import { renderHook } from '@testing-library/react-hooks';
let mockIndexPatternFieldEditor: Start;
jest.mock('../../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const mockOpenFieldEditor = jest.fn();
const mockOnHide = jest.fn();
const runAllPromises = () => new Promise(setImmediate);
const renderUseCreateFieldButton = (props: Partial<UseCreateFieldButtonProps> = {}) =>
renderHook<UseCreateFieldButtonProps, ReturnType<UseCreateFieldButton>>(
() =>
useCreateFieldButton({
hasFieldEditPermission: true,
loading: false,
openFieldEditor: mockOpenFieldEditor,
...props,
}),
{
wrapper: TestProviders,
}
);
describe('CreateFieldButton', () => {
describe('useCreateFieldButton', () => {
beforeEach(() => {
mockIndexPatternFieldEditor = indexPatternFieldEditorPluginMock.createStartContract();
mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true;
useKibanaMock().services.dataViewFieldEditor = mockIndexPatternFieldEditor;
useKibanaMock().services.data.dataViews.get = () => new Promise(() => undefined);
useKibanaMock().services.application.capabilities = {
...useKibanaMock().services.application.capabilities,
indexPatterns: { save: true },
};
});
// refactor below tests once resolved: https://github.com/elastic/kibana/issues/122462
it('displays the button when user has read permissions and write permissions', () => {
render(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={() => undefined}
timelineId={TimelineId.detectionsPage}
/>,
{
wrapper: TestProviders,
}
);
expect(screen.getByRole('button')).toBeInTheDocument();
jest.clearAllMocks();
});
it("doesn't display the button when user doesn't have read permissions", () => {
mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => false;
render(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={() => undefined}
timelineId={TimelineId.detectionsPage}
/>,
{
wrapper: TestProviders,
}
);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
it('should return the button component function when user has edit permissions', async () => {
const { result } = renderUseCreateFieldButton();
expect(result.current).not.toBeUndefined();
});
it("doesn't display the button when user doesn't have write permissions", () => {
useKibanaMock().services.application.capabilities = {
...useKibanaMock().services.application.capabilities,
indexPatterns: { save: false },
};
render(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={() => undefined}
timelineId={TimelineId.detectionsPage}
/>,
{
wrapper: TestProviders,
}
);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
it('should return the undefined when user do not has edit permissions', async () => {
const { result } = renderUseCreateFieldButton({ hasFieldEditPermission: false });
expect(result.current).toBeUndefined();
});
it("calls 'onClick' param when the button is clicked", async () => {
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
it('should return a button wrapped component', async () => {
const { result } = renderUseCreateFieldButton();
const onClickParam = jest.fn();
await act(async () => {
render(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={onClickParam}
timelineId={TimelineId.detectionsPage}
/>,
{
wrapper: TestProviders,
}
);
await runAllPromises();
const CreateFieldButton = result.current!;
const { getByRole } = render(<CreateFieldButton onHide={mockOnHide} />, {
wrapper: TestProviders,
});
fireEvent.click(screen.getByRole('button'));
expect(onClickParam).toHaveBeenCalled();
expect(getByRole('button')).toBeInTheDocument();
});
it("stores 'closeEditor' in the actions ref when editor is open", async () => {
const mockCloseEditor = jest.fn();
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
useKibanaMock().services.dataViewFieldEditor.openEditor = () => mockCloseEditor;
it('should call openFieldEditor and hide the modal when button clicked', async () => {
const { result } = renderUseCreateFieldButton();
const editorActionsRef: MutableRefObject<CreateFieldEditorActions> = React.createRef();
await act(async () => {
render(
<CreateFieldButton
selectedDataViewId={'dataViewId'}
onClick={() => undefined}
timelineId={TimelineId.detectionsPage}
editorActionsRef={editorActionsRef}
/>,
{
wrapper: TestProviders,
}
);
await runAllPromises();
const CreateFieldButton = result.current!;
const { getByRole } = render(<CreateFieldButton onHide={mockOnHide} />, {
wrapper: TestProviders,
});
expect(editorActionsRef?.current).toBeNull();
fireEvent.click(screen.getByRole('button'));
expect(mockCloseEditor).not.toHaveBeenCalled();
expect(editorActionsRef?.current?.closeEditor).toBeDefined();
editorActionsRef!.current!.closeEditor();
expect(mockCloseEditor).toHaveBeenCalled();
expect(editorActionsRef!.current).toBeNull();
getByRole('button').click();
expect(mockOpenFieldEditor).toHaveBeenCalled();
expect(mockOnHide).toHaveBeenCalled();
});
});

View file

@ -5,155 +5,52 @@
* 2.0.
*/
import React, { MutableRefObject, useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback } from 'react';
import { EuiButton } from '@elastic/eui';
import styled from 'styled-components';
import { useDispatch } from 'react-redux';
import type {
DataViewField,
DataView,
} from '../../../../../../../../src/plugins/data_views/common';
import { useKibana } from '../../../../common/lib/kibana';
import type { CreateFieldComponent } from '../../../../../../timelines/common/types';
import type { OpenFieldEditor } from '..';
import * as i18n from './translations';
import { FieldBrowserOptions, TimelineId } from '../../../../../../timelines/common';
import { upsertColumn } 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';
export type CreateFieldEditorActions = { closeEditor: () => void } | null;
export type CreateFieldEditorActionsRef = MutableRefObject<CreateFieldEditorActions>;
export interface CreateFieldButtonProps {
selectedDataViewId: string;
onClick: () => void;
timelineId: TimelineId;
editorActionsRef?: CreateFieldEditorActionsRef;
}
const StyledButton = styled(EuiButton)`
margin-left: ${({ theme }) => theme.eui.paddingSizes.m};
`;
export const CreateFieldButton = React.memo<CreateFieldButtonProps>(
({ selectedDataViewId, onClick: onClickParam, timelineId, editorActionsRef }) => {
const [dataView, setDataView] = useState<DataView | null>(null);
const dispatch = useDispatch();
const { indexFieldsSearch } = useDataView();
const {
dataViewFieldEditor,
data: { dataViews },
application: { capabilities },
} = useKibana().services;
useEffect(() => {
dataViews.get(selectedDataViewId).then((dataViewResponse) => {
setDataView(dataViewResponse);
});
}, [selectedDataViewId, dataViews]);
const onClick = useCallback(() => {
if (dataView) {
const closeFieldEditor = dataViewFieldEditor?.openEditor({
ctx: { dataView },
onSave: async (field: DataViewField) => {
// Fetch the updated list of fields
await indexFieldsSearch(selectedDataViewId);
// Add the new field to the event table, after waiting for browserFields to be stored
dispatch(
upsertColumn({
column: {
columnHeaderType: defaultColumnHeaderType,
id: field.name,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
id: timelineId,
index: 0,
})
);
},
});
if (editorActionsRef) {
editorActionsRef.current = {
closeEditor: () => {
editorActionsRef.current = null;
closeFieldEditor();
},
};
}
}
onClickParam();
}, [
dataViewFieldEditor,
dataView,
onClickParam,
indexFieldsSearch,
selectedDataViewId,
dispatch,
timelineId,
editorActionsRef,
]);
if (
!dataViewFieldEditor?.userPermissions.editIndexPattern() ||
// remove below check once resolved: https://github.com/elastic/kibana/issues/122462
!capabilities.indexPatterns.save
) {
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';
export interface UseCreateFieldButtonProps {
hasFieldEditPermission: boolean;
loading: boolean;
openFieldEditor: OpenFieldEditor;
}
export type UseCreateFieldButton = (
props: UseCreateFieldButtonProps
) => CreateFieldComponent | undefined;
/**
*
* Returns a memoised 'CreateFieldButton' with only an 'onClick' property.
*/
export const useCreateFieldButton = (
sourcererScope: SourcererScopeName,
timelineId: TimelineId,
editorActionsRef?: CreateFieldEditorActionsRef
) => {
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const { missingPatterns, selectedDataViewId } = useDeepEqualSelector((state) =>
scopeIdSelector(state, sourcererScope)
export const useCreateFieldButton: UseCreateFieldButton = ({
hasFieldEditPermission,
loading,
openFieldEditor,
}) => {
const createFieldButton = useCallback<CreateFieldComponent>(
({ onHide }) => (
<StyledButton
iconType={loading ? 'none' : 'plusInCircle'}
aria-label={i18n.CREATE_FIELD}
data-test-subj="create-field"
onClick={() => {
openFieldEditor();
onHide();
}}
isLoading={loading}
>
{i18n.CREATE_FIELD}
</StyledButton>
),
[loading, openFieldEditor]
);
return useMemo(() => {
if (selectedDataViewId == null || missingPatterns.length > 0) {
return;
}
// It receives onClick props from field browser in order to close the modal.
const CreateFieldButtonComponent: FieldBrowserOptions['createFieldButton'] = ({ onClick }) => (
<CreateFieldButton
selectedDataViewId={selectedDataViewId}
onClick={onClick}
timelineId={timelineId}
editorActionsRef={editorActionsRef}
/>
);
return CreateFieldButtonComponent;
}, [missingPatterns.length, selectedDataViewId, timelineId, editorActionsRef]);
return hasFieldEditPermission ? createFieldButton : undefined;
};

View file

@ -0,0 +1,159 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { useFieldTableColumns, UseFieldTableColumnsProps, UseFieldTableColumns } from './index';
import { TestProviders } from '../../../../common/mock';
import { renderHook } from '@testing-library/react-hooks';
import { BrowserFieldItem } from '../../../../../../timelines/common/types';
import { EuiInMemoryTable } from '@elastic/eui';
const mockOnHide = jest.fn();
const mockOpenFieldEditor = jest.fn();
const mockOpenDeleteFieldModal = jest.fn();
// helper function to render the hook
const renderUseFieldTableColumns = (props: Partial<UseFieldTableColumnsProps> = {}) =>
renderHook<UseFieldTableColumnsProps, ReturnType<UseFieldTableColumns>>(
() =>
useFieldTableColumns({
hasFieldEditPermission: true,
openFieldEditor: mockOpenFieldEditor,
openDeleteFieldModal: mockOpenDeleteFieldModal,
...props,
}),
{
wrapper: TestProviders,
}
);
const fieldItem: BrowserFieldItem = {
name: 'field1',
isRuntime: true,
category: 'test',
selected: false,
};
describe('useFieldTableColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render all columns when user has edit permissions', async () => {
const { result } = renderUseFieldTableColumns({ hasFieldEditPermission: true });
const columns = result.current({
highlight: '',
onHide: mockOnHide,
});
const { getAllByRole, getByTestId } = render(
<EuiInMemoryTable items={[fieldItem]} columns={columns} />,
{
wrapper: TestProviders,
}
);
expect(getAllByRole('columnheader').length).toBe(5);
expect(getByTestId('actionEditRuntimeField')).toBeInTheDocument();
expect(getByTestId('actionDeleteRuntimeField')).toBeInTheDocument();
});
it('should render default columns when user do not has edit permissions', async () => {
const { result } = renderUseFieldTableColumns({ hasFieldEditPermission: false });
const columns = result.current({
highlight: '',
onHide: mockOnHide,
});
const { getAllByRole, queryByTestId } = render(
<EuiInMemoryTable items={[fieldItem]} columns={columns} />,
{
wrapper: TestProviders,
}
);
expect(getAllByRole('columnheader').length).toBe(4);
expect(queryByTestId('actionEditRuntimeField')).toBeNull();
expect(queryByTestId('actionDeleteRuntimeField')).toBeNull();
});
it('should not render the runtime action buttons when the field is not a runtime field', async () => {
const { result } = renderUseFieldTableColumns();
const columns = result.current({
highlight: '',
onHide: mockOnHide,
});
const { getAllByRole, queryByTestId } = render(
<EuiInMemoryTable items={[{ ...fieldItem, isRuntime: false }]} columns={columns} />,
{
wrapper: TestProviders,
}
);
expect(getAllByRole('columnheader').length).toBe(5);
expect(queryByTestId('actionEditRuntimeField')).toBeNull();
expect(queryByTestId('actionDeleteRuntimeField')).toBeNull();
});
it('should call onHide if any action button is pressed', async () => {
const { result } = renderUseFieldTableColumns();
const columns = result.current({
highlight: '',
onHide: mockOnHide,
});
const { getByTestId } = render(<EuiInMemoryTable items={[fieldItem]} columns={columns} />, {
wrapper: TestProviders,
});
getByTestId('actionEditRuntimeField').click();
expect(mockOnHide).toHaveBeenCalledTimes(1);
getByTestId('actionDeleteRuntimeField').click();
expect(mockOnHide).toHaveBeenCalledTimes(2);
});
it('should call openFieldEditor if edit action button is pressed', async () => {
const { result } = renderUseFieldTableColumns();
const columns = result.current({
highlight: '',
onHide: mockOnHide,
});
const { getByTestId } = render(<EuiInMemoryTable items={[fieldItem]} columns={columns} />, {
wrapper: TestProviders,
});
getByTestId('actionEditRuntimeField').click();
expect(mockOpenFieldEditor).toHaveBeenCalledTimes(1);
expect(mockOpenFieldEditor).toHaveBeenCalledWith(fieldItem.name);
});
it('should call openDeleteFieldModal if remove action button is pressed', async () => {
const { result } = renderUseFieldTableColumns();
const columns = result.current({
highlight: '',
onHide: mockOnHide,
});
const { getByTestId } = render(<EuiInMemoryTable items={[fieldItem]} columns={columns} />, {
wrapper: TestProviders,
});
getByTestId('actionDeleteRuntimeField').click();
expect(mockOpenDeleteFieldModal).toHaveBeenCalledTimes(1);
expect(mockOpenDeleteFieldModal).toHaveBeenCalledWith(fieldItem.name);
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import {
EuiToolTip,
@ -18,7 +18,12 @@ import {
EuiText,
EuiHighlight,
} from '@elastic/eui';
import type { FieldTableColumns } from '../../../../../../timelines/common/types';
import { Action } from '@elastic/eui/src/components/basic_table/action_types';
import type {
BrowserFieldItem,
GetFieldTableColumns,
} from '../../../../../../timelines/common/types';
import * as i18n from './translations';
import {
getExampleText,
@ -26,6 +31,15 @@ import {
} from '../../../../common/components/event_details/helpers';
import { getEmptyValue } from '../../../../common/components/empty_value';
import { EllipsisText } from '../../../../common/components/truncatable_text';
import { OpenFieldEditor, OpenDeleteFieldModal } from '..';
export interface UseFieldTableColumnsProps {
hasFieldEditPermission: boolean;
openFieldEditor: OpenFieldEditor;
openDeleteFieldModal: OpenDeleteFieldModal;
}
export type UseFieldTableColumns = (props: UseFieldTableColumnsProps) => GetFieldTableColumns;
const TypeIcon = styled(EuiIcon)`
margin: 0 4px;
@ -34,9 +48,9 @@ const TypeIcon = styled(EuiIcon)`
`;
TypeIcon.displayName = 'TypeIcon';
export const Description = styled.span`
export const Description = styled.span<{ width: string }>`
user-select: text;
width: 400px;
width: ${({ width }) => width};
`;
Description.displayName = 'Description';
@ -52,66 +66,123 @@ export const FieldName = React.memo<{
));
FieldName.displayName = 'FieldName';
export const getFieldTableColumns = (highlight: string): FieldTableColumns => [
{
field: 'name',
name: i18n.NAME,
render: (name: string, { type }) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content={type}>
<TypeIcon
data-test-subj={`field-${name}-icon`}
type={getIconFromType(type ?? null)}
/>
</EuiToolTip>
</EuiFlexItem>
export const useFieldTableColumns: UseFieldTableColumns = ({
hasFieldEditPermission,
openFieldEditor,
openDeleteFieldModal,
}) => {
const getFieldTableColumns = useCallback<GetFieldTableColumns>(
({ highlight, onHide }) => {
const actions: Array<Action<BrowserFieldItem>> = hasFieldEditPermission
? [
{
name: i18n.EDIT,
description: i18n.EDIT_DESCRIPTION,
type: 'icon',
icon: 'pencil',
isPrimary: true,
onClick: ({ name }: BrowserFieldItem) => {
openFieldEditor(name);
onHide();
},
available: ({ isRuntime }) => isRuntime,
'data-test-subj': 'actionEditRuntimeField',
},
{
name: i18n.REMOVE,
description: i18n.REMOVE_DESCRIPTION,
type: 'icon',
icon: 'trash',
color: 'danger',
isPrimary: true,
onClick: ({ name }: BrowserFieldItem) => {
openDeleteFieldModal(name);
onHide();
},
available: ({ isRuntime }) => isRuntime,
'data-test-subj': 'actionDeleteRuntimeField',
},
]
: [];
<EuiFlexItem grow={false}>
<FieldName fieldId={name} highlight={highlight} />
</EuiFlexItem>
</EuiFlexGroup>
);
return [
{
field: 'name',
name: i18n.NAME,
render: (name: string, { type }) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content={type}>
<TypeIcon
data-test-subj={`field-${name}-icon`}
type={getIconFromType(type ?? null)}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FieldName fieldId={name} highlight={highlight} />
</EuiFlexItem>
</EuiFlexGroup>
);
},
sortable: true,
width: '225px',
},
{
field: 'description',
name: i18n.DESCRIPTION,
render: (description, { name, example }) => (
<EuiToolTip content={description}>
<>
<EuiScreenReaderOnly data-test-subj="descriptionForScreenReaderOnly">
<p>{i18n.DESCRIPTION_FOR_FIELD(name)}</p>
</EuiScreenReaderOnly>
<EllipsisText>
<Description
width={actions.length > 0 ? '335px' : '400px'}
data-test-subj={`field-${name}-description`}
>
{`${description ?? getEmptyValue()} ${getExampleText(example)}`}
</Description>
</EllipsisText>
</>
</EuiToolTip>
),
sortable: true,
width: actions.length > 0 ? '335px' : '400px',
},
{
field: 'isRuntime',
name: i18n.RUNTIME,
render: (isRuntime: boolean) =>
isRuntime ? <EuiHealth color="success" title={i18n.RUNTIME_FIELD} /> : null,
sortable: true,
width: '80px',
},
{
field: 'category',
name: i18n.CATEGORY,
render: (category: string, { name }) => (
<EuiBadge data-test-subj={`field-${name}-category`}>{category}</EuiBadge>
),
sortable: true,
width: '115px',
},
...(actions.length > 0
? [
{
name: i18n.ACTIONS,
actions,
width: '80px',
},
]
: []),
];
},
sortable: true,
width: '200px',
},
{
field: 'description',
name: i18n.DESCRIPTION,
render: (description, { name, example }) => (
<EuiToolTip content={description}>
<>
<EuiScreenReaderOnly data-test-subj="descriptionForScreenReaderOnly">
<p>{i18n.DESCRIPTION_FOR_FIELD(name)}</p>
</EuiScreenReaderOnly>
<EllipsisText>
<Description data-test-subj={`field-${name}-description`}>
{`${description ?? getEmptyValue()} ${getExampleText(example)}`}
</Description>
</EllipsisText>
</>
</EuiToolTip>
),
sortable: true,
width: '400px',
},
{
field: 'isRuntime',
name: i18n.RUNTIME,
render: (isRuntime: boolean) =>
isRuntime ? <EuiHealth color="success" title={i18n.RUNTIME_FIELD} /> : null,
sortable: true,
width: '80px',
},
{
field: 'category',
name: i18n.CATEGORY,
render: (category: string, { name }) => (
<EuiBadge data-test-subj={`field-${name}-category`}>{category}</EuiBadge>
),
sortable: true,
width: '100px',
},
];
[hasFieldEditPermission, openFieldEditor, openDeleteFieldModal]
);
return getFieldTableColumns;
};

View file

@ -34,3 +34,29 @@ export const RUNTIME = i18n.translate('xpack.securitySolution.fieldBrowser.runti
export const RUNTIME_FIELD = i18n.translate('xpack.securitySolution.fieldBrowser.runtimeTitle', {
defaultMessage: 'Runtime Field',
});
export const ACTIONS = i18n.translate('xpack.securitySolution.fieldBrowser.actionsLabel', {
defaultMessage: 'Actions',
});
export const EDIT = i18n.translate('xpack.securitySolution.fieldBrowser.editButton', {
defaultMessage: 'Edit',
});
export const REMOVE = i18n.translate('xpack.securitySolution.fieldBrowser.removeButton', {
defaultMessage: 'Remove',
});
export const EDIT_DESCRIPTION = i18n.translate(
'xpack.securitySolution.fieldBrowser.editButtonDescription',
{
defaultMessage: 'Edit runtime field',
}
);
export const REMOVE_DESCRIPTION = i18n.translate(
'xpack.securitySolution.fieldBrowser.removeButtonDescription',
{
defaultMessage: 'Delete runtime field',
}
);

View file

@ -0,0 +1,335 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, act } from '@testing-library/react';
import {
useFieldBrowserOptions,
UseFieldBrowserOptionsProps,
UseFieldBrowserOptions,
FieldEditorActionsRef,
} from './index';
import {
indexPatternFieldEditorPluginMock,
Start,
} from '../../../../../../../src/plugins/data_view_field_editor/public/mocks';
import { TestProviders } from '../../../common/mock';
import { useKibana } from '../../../common/lib/kibana';
import type { DataView, DataViewField } from '../../../../../../../src/plugins/data/common';
import { TimelineId } from '../../../../common/types';
import { renderHook, RenderHookResult } from '@testing-library/react-hooks';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { removeColumn, upsertColumn } from '../../store/timeline/actions';
import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants';
import { BrowserFieldItem } from '../../../../../timelines/common/types';
import { EuiInMemoryTable } from '@elastic/eui';
let mockIndexPatternFieldEditor: Start;
jest.mock('../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const mockIndexFieldsSearch = jest.fn();
jest.mock('../../../common/containers/source/use_data_view', () => ({
useDataView: () => ({
indexFieldsSearch: mockIndexFieldsSearch,
}),
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const mockOnHide = jest.fn();
const runAllPromises = () => new Promise(setImmediate);
// helper function to render the hook
const renderUseFieldBrowserOptions = (props: Partial<UseFieldBrowserOptionsProps> = {}) =>
renderHook<UseFieldBrowserOptionsProps, ReturnType<UseFieldBrowserOptions>>(
() =>
useFieldBrowserOptions({
sourcererScope: SourcererScopeName.default,
timelineId: TimelineId.test,
...props,
}),
{
wrapper: TestProviders,
}
);
// helper function to render the hook and wait for the first update
const renderUpdatedUseFieldBrowserOptions = async (
props: Partial<UseFieldBrowserOptionsProps> = {}
) => {
let renderHookResult: RenderHookResult<
UseFieldBrowserOptionsProps,
ReturnType<UseFieldBrowserOptions>
> | null = null;
await act(async () => {
renderHookResult = renderUseFieldBrowserOptions(props);
await renderHookResult.waitForNextUpdate();
});
return renderHookResult!;
};
const fieldItem: BrowserFieldItem = {
name: 'field1',
isRuntime: true,
category: 'test',
selected: false,
};
describe('useFieldBrowserOptions', () => {
beforeEach(() => {
mockIndexPatternFieldEditor = indexPatternFieldEditorPluginMock.createStartContract();
mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true;
useKibanaMock().services.dataViewFieldEditor = mockIndexPatternFieldEditor;
useKibanaMock().services.data.dataViews.get = () => new Promise(() => undefined);
useKibanaMock().services.application.capabilities = {
...useKibanaMock().services.application.capabilities,
indexPatterns: { save: true },
};
jest.clearAllMocks();
});
// refactor below tests once resolved: https://github.com/elastic/kibana/issues/122462
it('should return the button and action column when user has edit permissions', async () => {
const { result } = renderUseFieldBrowserOptions();
expect(result.current.createFieldButton).toBeDefined();
expect(result.current.getFieldTableColumns({ highlight: '', onHide: mockOnHide })).toHaveLength(
5
);
});
it("should not return the button and action column when user doesn't have read permissions", () => {
mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => false;
const { result } = renderUseFieldBrowserOptions();
expect(result.current.createFieldButton).toBeUndefined();
expect(result.current.getFieldTableColumns({ highlight: '', onHide: mockOnHide })).toHaveLength(
4
);
});
it('should call onHide when button is pressed', async () => {
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
const { result } = await renderUpdatedUseFieldBrowserOptions();
const CreateFieldButton = result!.current.createFieldButton!;
const { getByRole } = render(<CreateFieldButton onHide={mockOnHide} />, {
wrapper: TestProviders,
});
expect(getByRole('button')).toBeInTheDocument();
getByRole('button').click();
expect(mockOnHide).toHaveBeenCalled();
});
it('should call onHide when the column action buttons are pressed', async () => {
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
const { result } = await renderUpdatedUseFieldBrowserOptions();
const columns = result.current.getFieldTableColumns({
highlight: '',
onHide: mockOnHide,
});
const { getByTestId } = render(<EuiInMemoryTable items={[fieldItem]} columns={columns} />, {
wrapper: TestProviders,
});
getByTestId('actionEditRuntimeField').click();
expect(mockOnHide).toHaveBeenCalledTimes(1);
getByTestId('actionDeleteRuntimeField').click();
expect(mockOnHide).toHaveBeenCalledTimes(2);
});
it('should dispatch the proper action when a new field is saved', async () => {
let onSave: ((field: DataViewField) => void) | undefined;
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
useKibanaMock().services.dataViewFieldEditor.openEditor = (options) => {
onSave = options.onSave;
return () => {};
};
const { result } = await renderUpdatedUseFieldBrowserOptions();
const CreateFieldButton = result.current.createFieldButton!;
const { getByRole } = render(<CreateFieldButton onHide={mockOnHide} />, {
wrapper: TestProviders,
});
getByRole('button').click();
expect(onSave).toBeDefined();
const savedField = { name: 'newField' } as DataViewField;
onSave!(savedField);
await runAllPromises();
expect(mockIndexFieldsSearch).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
upsertColumn({
id: TimelineId.test,
column: {
columnHeaderType: defaultColumnHeaderType,
id: savedField.name,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
index: 0,
})
);
});
it('should dispatch the proper actions when a field is edited', async () => {
let onSave: ((field: DataViewField) => void) | undefined;
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
useKibanaMock().services.dataViewFieldEditor.openEditor = (options) => {
onSave = options.onSave;
return () => {};
};
const { result } = await renderUpdatedUseFieldBrowserOptions();
const columns = result.current.getFieldTableColumns({
highlight: '',
onHide: mockOnHide,
});
const { getByTestId } = render(<EuiInMemoryTable items={[fieldItem]} columns={columns} />, {
wrapper: TestProviders,
});
getByTestId('actionEditRuntimeField').click();
expect(onSave).toBeDefined();
const savedField = { name: `new ${fieldItem.name}` } as DataViewField;
onSave!(savedField);
await runAllPromises();
expect(mockIndexFieldsSearch).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenCalledWith(
removeColumn({
id: TimelineId.test,
columnId: fieldItem.name,
})
);
expect(mockDispatch).toHaveBeenCalledWith(
upsertColumn({
id: TimelineId.test,
column: {
columnHeaderType: defaultColumnHeaderType,
id: savedField.name,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
index: 0,
})
);
});
it('should dispatch the proper actions when a field is removed', async () => {
let onDelete: ((fields: string[]) => void) | undefined;
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
useKibanaMock().services.dataViewFieldEditor.openDeleteModal = (options) => {
onDelete = options.onDelete;
return () => {};
};
const { result } = await renderUpdatedUseFieldBrowserOptions();
const columns = result.current.getFieldTableColumns({
highlight: '',
onHide: mockOnHide,
});
const { getByTestId } = render(<EuiInMemoryTable items={[fieldItem]} columns={columns} />, {
wrapper: TestProviders,
});
getByTestId('actionDeleteRuntimeField').click();
expect(onDelete).toBeDefined();
onDelete!([fieldItem.name]);
await runAllPromises();
expect(mockIndexFieldsSearch).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
removeColumn({
id: TimelineId.test,
columnId: fieldItem.name,
})
);
});
it("should store 'closeEditor' in the actions ref when editor is open by create button", async () => {
const mockCloseEditor = jest.fn();
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
useKibanaMock().services.dataViewFieldEditor.openEditor = () => mockCloseEditor;
const editorActionsRef: FieldEditorActionsRef = React.createRef();
const { result } = await renderUpdatedUseFieldBrowserOptions({ editorActionsRef });
const CreateFieldButton = result!.current.createFieldButton!;
const { getByRole } = render(<CreateFieldButton onHide={mockOnHide} />, {
wrapper: TestProviders,
});
expect(editorActionsRef?.current).toBeNull();
getByRole('button').click();
expect(mockCloseEditor).not.toHaveBeenCalled();
expect(editorActionsRef?.current?.closeEditor).toBeDefined();
editorActionsRef!.current!.closeEditor();
expect(mockCloseEditor).toHaveBeenCalled();
expect(editorActionsRef!.current).toBeNull();
});
it("should store 'closeEditor' in the actions ref when editor is open by edit button", async () => {
const mockCloseEditor = jest.fn();
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
useKibanaMock().services.dataViewFieldEditor.openEditor = () => mockCloseEditor;
const editorActionsRef: FieldEditorActionsRef = React.createRef();
const { result } = await renderUpdatedUseFieldBrowserOptions({ editorActionsRef });
const columns = result.current.getFieldTableColumns({
highlight: '',
onHide: mockOnHide,
});
const { getByTestId } = render(<EuiInMemoryTable items={[fieldItem]} columns={columns} />, {
wrapper: TestProviders,
});
expect(editorActionsRef?.current).toBeNull();
getByTestId('actionEditRuntimeField').click();
expect(mockCloseEditor).not.toHaveBeenCalled();
expect(editorActionsRef?.current?.closeEditor).toBeDefined();
editorActionsRef!.current!.closeEditor();
expect(mockCloseEditor).toHaveBeenCalled();
expect(editorActionsRef!.current).toBeNull();
});
});

View file

@ -5,25 +5,167 @@
* 2.0.
*/
import { MutableRefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { DataViewField, DataView } from '../../../../../../../src/plugins/data_views/common';
import type {
CreateFieldComponent,
GetFieldTableColumns,
} from '../../../../../timelines/common/types';
import { TimelineId } from '../../../../common/types';
import { useDataView } from '../../../common/containers/source/use_data_view';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useKibana } from '../../../common/lib/kibana';
import { sourcererSelectors } from '../../../common/store';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useCreateFieldButton, CreateFieldEditorActionsRef } from './create_field_button';
import { getFieldTableColumns } from './field_table_columns';
import { upsertColumn, removeColumn } from '../../store/timeline/actions';
import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants';
import { useCreateFieldButton } from './create_field_button';
import { useFieldTableColumns } from './field_table_columns';
export type { CreateFieldEditorActions } from './create_field_button';
export type FieldEditorActions = { closeEditor: () => void } | null;
export type FieldEditorActionsRef = MutableRefObject<FieldEditorActions>;
export interface UseFieldBrowserOptions {
export type OpenFieldEditor = (fieldName?: string) => void;
export type OpenDeleteFieldModal = (fieldName: string) => void;
export interface UseFieldBrowserOptionsProps {
sourcererScope: SourcererScopeName;
timelineId: TimelineId;
editorActionsRef?: CreateFieldEditorActionsRef;
editorActionsRef?: FieldEditorActionsRef;
}
export const useFieldBrowserOptions = ({
export type UseFieldBrowserOptions = (props: UseFieldBrowserOptionsProps) => {
createFieldButton: CreateFieldComponent | undefined;
getFieldTableColumns: GetFieldTableColumns;
};
export const useFieldBrowserOptions: UseFieldBrowserOptions = ({
sourcererScope,
timelineId,
editorActionsRef,
}: UseFieldBrowserOptions) => {
const createFieldButton = useCreateFieldButton(sourcererScope, timelineId, editorActionsRef);
}) => {
const dispatch = useDispatch();
const [dataView, setDataView] = useState<DataView | null>(null);
const { indexFieldsSearch } = useDataView();
const {
dataViewFieldEditor,
data: { dataViews },
} = useKibana().services;
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const { missingPatterns, selectedDataViewId } = useDeepEqualSelector((state) =>
scopeIdSelector(state, sourcererScope)
);
useEffect(() => {
if (selectedDataViewId != null && !missingPatterns.length) {
dataViews.get(selectedDataViewId).then((dataViewResponse) => {
setDataView(dataViewResponse);
});
}
}, [selectedDataViewId, missingPatterns, dataViews]);
const openFieldEditor = useCallback<OpenFieldEditor>(
(fieldName) => {
if (dataView && selectedDataViewId) {
const closeFieldEditor = dataViewFieldEditor.openEditor({
ctx: { dataView },
fieldName,
onSave: async (savedField: DataViewField) => {
// Fetch the updated list of fields
// Using cleanCache since the number of fields might have not changed, but we need to update the state anyway
await indexFieldsSearch({ dataViewId: selectedDataViewId, cleanCache: true });
if (fieldName && fieldName !== savedField.name) {
// Remove old field from event table when renaming a field
dispatch(
removeColumn({
columnId: fieldName,
id: timelineId,
})
);
}
// Add the saved column field to the table in any case
dispatch(
upsertColumn({
column: {
columnHeaderType: defaultColumnHeaderType,
id: savedField.name,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
id: timelineId,
index: 0,
})
);
if (editorActionsRef) {
editorActionsRef.current = null;
}
},
});
if (editorActionsRef) {
editorActionsRef.current = {
closeEditor: () => {
editorActionsRef.current = null;
closeFieldEditor();
},
};
}
}
},
[
dataView,
selectedDataViewId,
dataViewFieldEditor,
editorActionsRef,
indexFieldsSearch,
dispatch,
timelineId,
]
);
const openDeleteFieldModal = useCallback<OpenDeleteFieldModal>(
(fieldName: string) => {
if (dataView && selectedDataViewId) {
dataViewFieldEditor.openDeleteModal({
ctx: { dataView },
fieldName,
onDelete: async () => {
// Fetch the updated list of fields
await indexFieldsSearch({ dataViewId: selectedDataViewId });
dispatch(
removeColumn({
columnId: fieldName,
id: timelineId,
})
);
},
});
}
},
[dataView, selectedDataViewId, dataViewFieldEditor, indexFieldsSearch, dispatch, timelineId]
);
const hasFieldEditPermission = useMemo(
() => dataViewFieldEditor?.userPermissions.editIndexPattern(),
[dataViewFieldEditor?.userPermissions]
);
const createFieldButton = useCreateFieldButton({
hasFieldEditPermission,
loading: !dataView,
openFieldEditor,
});
const getFieldTableColumns = useFieldTableColumns({
hasFieldEditPermission,
openFieldEditor,
openDeleteFieldModal,
});
return {
createFieldButton,
getFieldTableColumns,

View file

@ -24,12 +24,13 @@ import { Direction } from '../../../../../../common/search_strategy';
import { getDefaultControlColumn } from '../control_columns';
import { testTrailingControlColumns } from '../../../../../common/mock/mock_timeline_control_columns';
import { HeaderActions } from '../actions/header_actions';
import { UseFieldBrowserOptionsProps } from '../../../fields_browser';
jest.mock('../../../../../common/lib/kibana');
const mockUseCreateFieldButton = jest.fn().mockReturnValue(<></>);
jest.mock('../../../fields_browser/create_field_button', () => ({
useCreateFieldButton: (...params: unknown[]) => mockUseCreateFieldButton(...params),
const mockUseFieldBrowserOptions = jest.fn();
jest.mock('../../../fields_browser', () => ({
useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props),
}));
const mockDispatch = jest.fn();
@ -257,9 +258,9 @@ describe('ColumnHeaders', () => {
describe('Field Editor', () => {
test('Closes field editor when the timeline is unmounted', () => {
const mockCloseEditor = jest.fn();
mockUseCreateFieldButton.mockImplementation((_, __, fieldEditorActionsRef) => {
fieldEditorActionsRef.current = { closeEditor: mockCloseEditor };
return <></>;
mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => {
editorActionsRef.current = { closeEditor: mockCloseEditor };
return {};
});
const wrapper = mount(
@ -275,9 +276,9 @@ describe('ColumnHeaders', () => {
test('Closes field editor when the timeline is closed', () => {
const mockCloseEditor = jest.fn();
mockUseCreateFieldButton.mockImplementation((_, __, fieldEditorActionsRef) => {
fieldEditorActionsRef.current = { closeEditor: mockCloseEditor };
return <></>;
mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => {
editorActionsRef.current = { closeEditor: mockCloseEditor };
return {};
});
const Proxy = (props: ColumnHeadersComponentProps) => (

View file

@ -34,7 +34,7 @@ import { Sort } from '../sort';
import { ColumnHeader } from './column_header';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import { useFieldBrowserOptions, CreateFieldEditorActions } from '../../../fields_browser';
import { useFieldBrowserOptions, FieldEditorActions } from '../../../fields_browser';
export interface ColumnHeadersComponentProps {
actionsColumnWidth: number;
@ -103,7 +103,7 @@ export const ColumnHeadersComponent = ({
trailingControlColumns,
}: ColumnHeadersComponentProps) => {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const fieldEditorActionsRef = useRef<CreateFieldEditorActions>(null);
const fieldEditorActionsRef = useRef<FieldEditorActions>(null);
useEffect(() => {
return () => {

View file

@ -31,6 +31,9 @@ jest.mock('../../../containers/index', () => ({
jest.mock('../../../containers/details/index', () => ({
useTimelineEventsDetails: jest.fn(),
}));
jest.mock('../../fields_browser', () => ({
useFieldBrowserOptions: jest.fn(),
}));
jest.mock('../body/events/index', () => ({
Events: () => <></>,
}));

View file

@ -32,6 +32,9 @@ jest.mock('../../../containers/index', () => ({
jest.mock('../../../containers/details/index', () => ({
useTimelineEventsDetails: jest.fn(),
}));
jest.mock('../../fields_browser', () => ({
useFieldBrowserOptions: jest.fn(),
}));
jest.mock('../body/events/index', () => ({
Events: () => <></>,
}));

View file

@ -34,6 +34,9 @@ jest.mock('../../../containers/index', () => ({
jest.mock('../../../containers/details/index', () => ({
useTimelineEventsDetails: jest.fn(),
}));
jest.mock('../../fields_browser', () => ({
useFieldBrowserOptions: jest.fn(),
}));
jest.mock('../body/events/index', () => ({
Events: () => <></>,
}));

View file

@ -25,10 +25,13 @@ export interface BrowserFieldItem {
export type OnFieldSelected = (fieldId: string) => void;
export type CreateFieldComponent = React.FC<{
onClick: () => void;
onHide: () => void;
}>;
export type FieldTableColumns = Array<EuiBasicTableColumn<BrowserFieldItem>>;
export type GetFieldTableColumns = (highlight: string) => FieldTableColumns;
export type GetFieldTableColumns = (params: {
highlight: string;
onHide: () => void;
}) => FieldTableColumns;
export interface FieldBrowserOptions {
createFieldButton?: CreateFieldComponent;
getFieldTableColumns?: GetFieldTableColumns;

View file

@ -166,7 +166,7 @@ const FieldsBrowserComponent: React.FC<Props> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
{CreateFieldButton && dataViewId != null && dataViewId.length > 0 && (
<CreateFieldButton onClick={onHide} />
<CreateFieldButton onHide={onHide} />
)}
</EuiFlexItem>
</EuiFlexGroup>
@ -185,6 +185,7 @@ const FieldsBrowserComponent: React.FC<Props> = ({
searchInput={appliedFilterInput}
selectedCategoryIds={selectedCategoryIds}
getFieldTableColumns={getFieldTableColumns}
onHide={onHide}
/>
</EuiModalBody>

View file

@ -121,13 +121,16 @@ describe('field_items', () => {
describe('getFieldColumns', () => {
const onToggleColumn = jest.fn();
const getFieldColumnsParams = { onToggleColumn, onHide: () => {} };
beforeEach(() => {
onToggleColumn.mockClear();
});
it('should return default field columns', () => {
expect(getFieldColumns({ onToggleColumn }).map((column) => omit('render', column))).toEqual([
expect(
getFieldColumns(getFieldColumnsParams).map((column) => omit('render', column))
).toEqual([
{
field: 'selected',
name: '',
@ -150,7 +153,7 @@ describe('field_items', () => {
field: 'category',
name: 'Category',
sortable: true,
width: '100px',
width: '130px',
},
]);
});
@ -173,7 +176,7 @@ describe('field_items', () => {
expect(
getFieldColumns({
onToggleColumn,
...getFieldColumnsParams,
getFieldTableColumns: () => customColumns,
}).map((column) => omit('render', column))
).toEqual([
@ -195,7 +198,7 @@ describe('field_items', () => {
columnHeaders: [],
});
const columns = getFieldColumns({ onToggleColumn });
const columns = getFieldColumns(getFieldColumnsParams);
const { getByTestId, getAllByText } = render(
<EuiInMemoryTable items={fieldItems} itemId="name" columns={columns} />
);
@ -218,7 +221,7 @@ describe('field_items', () => {
columnHeaders: [],
});
const columns = getFieldColumns({ onToggleColumn });
const columns = getFieldColumns(getFieldColumnsParams);
const { getByTestId } = render(
<EuiInMemoryTable items={fieldItems} itemId="name" columns={columns} />
);

View file

@ -149,7 +149,7 @@ const getDefaultFieldTableColumns = (highlight: string): FieldTableColumns => [
<EuiBadge data-test-subj={`field-${name}-category`}>{category}</EuiBadge>
),
sortable: true,
width: '100px',
width: '130px',
},
];
@ -161,10 +161,12 @@ export const getFieldColumns = ({
onToggleColumn,
highlight = '',
getFieldTableColumns,
onHide,
}: {
onToggleColumn: (id: string) => void;
highlight?: string;
getFieldTableColumns?: GetFieldTableColumns;
onHide: () => void;
}): FieldTableColumns => [
{
field: 'selected',
@ -185,7 +187,7 @@ export const getFieldColumns = ({
width: '25px',
},
...(getFieldTableColumns
? getFieldTableColumns(highlight)
? getFieldTableColumns({ highlight, onHide })
: getDefaultFieldTableColumns(highlight)),
];

View file

@ -13,7 +13,7 @@ import { defaultColumnHeaderType } from '../../body/column_headers/default_heade
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../body/constants';
import { ColumnHeaderOptions } from '../../../../../common';
import { FieldTable } from './field_table';
import { FieldTable, FieldTableProps } from './field_table';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
@ -39,9 +39,17 @@ const columnHeaders: ColumnHeaderOptions[] = [
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
},
];
const timelineId = 'test';
const defaultProps: FieldTableProps = {
selectedCategoryIds: [],
columnHeaders: [],
filteredBrowserFields: {},
searchInput: '',
timelineId,
onHide: jest.fn(),
};
describe('FieldTable', () => {
const timelineId = 'test';
const timestampField = mockBrowserFields.base.fields![timestampFieldId];
const defaultPageSize = 10;
const totalFields = Object.values(mockBrowserFields).reduce(
@ -56,13 +64,7 @@ describe('FieldTable', () => {
it('should render empty field table', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={[]}
columnHeaders={[]}
filteredBrowserFields={{}}
searchInput=""
timelineId={timelineId}
/>
<FieldTable {...defaultProps} />
</TestProviders>
);
@ -73,13 +75,7 @@ describe('FieldTable', () => {
it('should render field table with fields of all categories', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={[]}
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
searchInput=""
timelineId={timelineId}
/>
<FieldTable {...defaultProps} filteredBrowserFields={mockBrowserFields} />
</TestProviders>
);
@ -99,11 +95,9 @@ describe('FieldTable', () => {
const result = render(
<TestProviders>
<FieldTable
{...defaultProps}
selectedCategoryIds={selectedCategoryIds}
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
@ -124,12 +118,9 @@ describe('FieldTable', () => {
const result = render(
<TestProviders>
<FieldTable
{...defaultProps}
getFieldTableColumns={() => fieldTableColumns}
selectedCategoryIds={[]}
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
@ -143,11 +134,9 @@ describe('FieldTable', () => {
const result = render(
<TestProviders>
<FieldTable
{...defaultProps}
selectedCategoryIds={['base']}
columnHeaders={[]}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
@ -160,11 +149,10 @@ describe('FieldTable', () => {
const result = render(
<TestProviders>
<FieldTable
{...defaultProps}
selectedCategoryIds={['base']}
columnHeaders={columnHeaders}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
@ -177,11 +165,10 @@ describe('FieldTable', () => {
const result = render(
<TestProviders>
<FieldTable
{...defaultProps}
selectedCategoryIds={['base']}
columnHeaders={columnHeaders}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
@ -198,11 +185,9 @@ describe('FieldTable', () => {
const result = render(
<TestProviders>
<FieldTable
{...defaultProps}
selectedCategoryIds={['base']}
columnHeaders={[]}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);

View file

@ -16,7 +16,7 @@ import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from './helpers';
import { tGridActions } from '../../../../store/t_grid';
import type { GetFieldTableColumns } from '../../../../../common/types/fields_browser';
interface FieldTableProps {
export interface FieldTableProps {
timelineId: string;
columnHeaders: ColumnHeaderOptions[];
/**
@ -36,6 +36,10 @@ interface FieldTableProps {
/** The text displayed in the search input */
/** Invoked when a user chooses to view a new set of columns in the timeline */
searchInput: string;
/**
* Hides the field browser when invoked
*/
onHide: () => void;
}
const TableContainer = styled.div<{ height: number }>`
@ -58,6 +62,7 @@ const FieldTableComponent: React.FC<FieldTableProps> = ({
searchInput,
selectedCategoryIds,
timelineId,
onHide,
}) => {
const dispatch = useDispatch();
@ -94,8 +99,8 @@ const FieldTableComponent: React.FC<FieldTableProps> = ({
);
const columns = useMemo(
() => getFieldColumns({ highlight: searchInput, onToggleColumn, getFieldTableColumns }),
[onToggleColumn, searchInput, getFieldTableColumns]
() => getFieldColumns({ highlight: searchInput, onToggleColumn, getFieldTableColumns, onHide }),
[onToggleColumn, searchInput, getFieldTableColumns, onHide]
);
const hasActions = useMemo(() => columns.some((column) => isActionsColumn(column)), [columns]);