mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
53420d8658
commit
53ba0305f7
25 changed files with 1151 additions and 491 deletions
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
|
|
|
@ -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 }));
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
() => (
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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: () => <></>,
|
||||
}));
|
||||
|
|
|
@ -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: () => <></>,
|
||||
}));
|
||||
|
|
|
@ -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: () => <></>,
|
||||
}));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
|
|
|
@ -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)),
|
||||
];
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue